Skip to content

Commit 5c59768

Browse files
authored
[TASK] 모임 생성과 수정 images 필드 동일하게 수정
[TASK] 모임 생성과 수정 images 필드 동일하게 수정
2 parents 7f7aca1 + 6e9f868 commit 5c59768

File tree

7 files changed

+199
-78
lines changed

7 files changed

+199
-78
lines changed

src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
@Getter
99
@RequiredArgsConstructor
1010
public enum GroupErrorCode implements ErrorCode {
11+
INVALID_GROUP_IMAGE_ITEM(HttpStatus.BAD_REQUEST,
12+
"모임: images 항목이 올바르지 않습니다. 모임 ID: %s 회원 ID: %s"
13+
),
14+
INVALID_GROUP_IMAGE_KEY(HttpStatus.BAD_REQUEST,
15+
"모임: imageKey는 필수이며 공백일 수 없습니다. 모임 ID: %s 회원 ID: %s"
16+
),
17+
DUPLICATED_IMAGE_SORT_ORDER_IN_REQUEST(HttpStatus.BAD_REQUEST,
18+
"모임: images 내 sortOrder가 중복되었습니다. 모임 ID: %s 회원 ID: %s"
19+
),
20+
INVALID_GROUP_IMAGE_SORT_ORDER_RANGE(HttpStatus.BAD_REQUEST,
21+
"모임: images 내 sortOrder 범위가 올바르지 않습니다. (허용: 0~2) 모임 ID: %s 회원 ID: %s"
22+
),
1123
NO_PERMISSION_TO_VIEW_BANNED_TARGETS(HttpStatus.FORBIDDEN,
1224
"모임: BANNED(차단) 대상 목록 조회 권한이 없습니다. 모임 ID: %s 회원 ID: %s"
1325
),
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package team.wego.wegobackend.group.v2.application.dto.request;
22

3-
import jakarta.validation.constraints.Min;
3+
import jakarta.validation.constraints.Max;
44

55
public record CreateGroupImageV2Request(
66
String imageKey,
77

8-
@Min(value = 0, message = "모임 이미지: 모임 이미지 순서는 0 이상 숫자만 가능합니다.")
8+
@Max(value = 3, message = "모임 이미지: 모임 이미지 순서는 2 이하만 가능합니다.")
99
Integer sortOrder
10-
) {}
10+
) {
11+
12+
}

src/main/java/team/wego/wegobackend/group/v2/application/dto/request/UpdateGroupV2Request.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package team.wego.wegobackend.group.v2.application.dto.request;
22

3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.Max;
5+
import jakarta.validation.constraints.Min;
36
import jakarta.validation.constraints.Size;
47
import java.time.LocalDateTime;
58
import java.util.List;
@@ -29,8 +32,8 @@ public record UpdateGroupV2Request(
2932
@Size(max = 10)
3033
List<String> tags,
3134

32-
@Size(max = 3)
33-
List<String> images
35+
@Valid
36+
List<CreateGroupImageV2Request> images
3437
) {
3538

3639
}

src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2UpdateService.java

Lines changed: 149 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,15 @@
1616
import team.wego.wegobackend.group.domain.exception.GroupException;
1717
import team.wego.wegobackend.group.v2.application.dto.common.Address;
1818
import team.wego.wegobackend.group.v2.application.dto.common.GroupImageItem;
19-
import team.wego.wegobackend.group.v2.application.dto.common.GroupImageVariantItem;
2019
import team.wego.wegobackend.group.v2.application.dto.common.PreUploadedGroupImage;
20+
import team.wego.wegobackend.group.v2.application.dto.request.CreateGroupImageV2Request;
2121
import team.wego.wegobackend.group.v2.application.dto.request.UpdateGroupV2Request;
2222
import team.wego.wegobackend.group.v2.application.dto.response.UpdateGroupV2Response;
2323
import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2;
24-
import team.wego.wegobackend.group.v2.domain.entity.GroupImageV2VariantType;
2524
import team.wego.wegobackend.group.v2.domain.entity.GroupTagV2;
2625
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status;
2726
import team.wego.wegobackend.group.v2.domain.entity.GroupV2;
2827
import team.wego.wegobackend.group.v2.domain.entity.GroupV2Address;
29-
import team.wego.wegobackend.group.v2.domain.entity.ImageV2Format;
3028
import team.wego.wegobackend.group.v2.domain.repository.GroupImageV2Repository;
3129
import team.wego.wegobackend.group.v2.domain.repository.GroupUserV2Repository;
3230
import team.wego.wegobackend.group.v2.domain.repository.GroupV2Repository;
@@ -46,6 +44,7 @@ public class GroupV2UpdateService {
4644
private final TagService tagService;
4745

4846
private static final int TEMP_SORT_ORDER = Integer.MAX_VALUE;
47+
private static final int MAX_IMAGES = 3;
4948

5049
private final EntityManager em;
5150

@@ -82,14 +81,15 @@ public UpdateGroupV2Response update(Long userId, Long groupId, UpdateGroupV2Requ
8281
}
8382

8483
// 4 이미지 변경(null이면 변경 없음)
84+
8585
if (request.images() != null) {
8686
applyImagesWithSafeReorder(group, userId, request.images());
8787
}
8888

8989
// dirty checking으로 충분. 그래도 명시적으로 save 해도 무방.
9090
groupV2Repository.save(group);
9191

92-
// 응답 구성(조회로 안전하게)
92+
// 응답 구성(조회로 안전하게)
9393
List<String> tagNames = group.getGroupTags().stream()
9494
.map(gt -> gt.getTag().getName())
9595
.toList();
@@ -124,33 +124,6 @@ public UpdateGroupV2Response update(Long userId, Long groupId, UpdateGroupV2Requ
124124
);
125125
}
126126

127-
private GroupImageItem defaultLogoItem() {
128-
return new GroupImageItem(
129-
null,
130-
"DEFAULT",
131-
0,
132-
List.of(
133-
new GroupImageVariantItem(
134-
null,
135-
GroupImageV2VariantType.CARD_440_240,
136-
GroupImageV2VariantType.CARD_440_240.width(),
137-
GroupImageV2VariantType.CARD_440_240.height(),
138-
ImageV2Format.WEBP,
139-
null
140-
),
141-
new GroupImageVariantItem(
142-
null,
143-
GroupImageV2VariantType.THUMBNAIL_100_100,
144-
GroupImageV2VariantType.THUMBNAIL_100_100.width(),
145-
GroupImageV2VariantType.THUMBNAIL_100_100.height(),
146-
ImageV2Format.WEBP,
147-
null
148-
)
149-
)
150-
);
151-
}
152-
153-
154127
private void applyScalarUpdates(GroupV2 group, Long groupId, UpdateGroupV2Request request) {
155128
if (request.title() != null) { // 제목
156129
group.changeTitle(request.title());
@@ -221,62 +194,177 @@ private void applyTags(GroupV2 group, List<String> tagNames) {
221194
}
222195

223196
/**
224-
* 이미지 업데이트: 요청 imageKeys를 "최종 상태(순서)"로 해석한다. - 요청 순서대로 sortOrder=0.. 부여 (0번이 대표) - 요청에 없는 기존
225-
* 이미지는 삭제(orphanRemoval) - 요청에 있고 기존에 없으면 preupload(REDIS) consume 후 새로 생성(insert) - (group_id,
226-
* sort_order) 유니크를 유지하기 위해 2-phase 임시 sortOrder 사용
197+
* applyImagesWithSafeReorder()가 “DTO → 최종 desiredKeys”를 만들어주는 역할 이미지 업데이트: 요청 imageKeys를 "최종
198+
* 상태(순서)"로 해석한다. - 요청 순서대로 sortOrder=0.. 부여 (0번이 대표) - 요청에 없는 기존 이미지는 삭제(orphanRemoval) - 요청에
199+
* 있고 기존에 없으면 preupload(REDIS) consume 후 새로 생성(insert) - (group_id, sort_order) 유니크를 유지하기 위해
200+
* 2-phase 임시 sortOrder 사용
227201
* <p>
228202
* 정책: - 최대 3장 - 중복 key 금지 - [] 허용: 전체 삭제
229203
*/
230-
private void applyImagesWithSafeReorder(GroupV2 group, Long userId, List<String> raw) {
231-
List<String> desiredKeys = raw.stream()
232-
.filter(k -> k != null && !k.isBlank())
204+
private void applyImagesWithSafeReorder(
205+
GroupV2 group,
206+
Long userId,
207+
List<CreateGroupImageV2Request> raw
208+
) {
209+
// 0) raw는 null이 아닌 상태로 들어온다 (update()에서 null 체크)
210+
// [] => 전체 삭제는 "명시적"으로 raw.isEmpty()일 때만
211+
if (raw.isEmpty()) {
212+
new ArrayList<>(group.getImages()).forEach(group::removeImage);
213+
em.flush();
214+
return;
215+
}
216+
217+
// 1) 아이템 유효성(엄격): null item, null/blank imageKey는 400
218+
for (CreateGroupImageV2Request it : raw) {
219+
if (it == null) {
220+
throw new GroupException(GroupErrorCode.INVALID_GROUP_IMAGE_ITEM, group.getId(),
221+
userId);
222+
}
223+
if (it.imageKey() == null || it.imageKey().isBlank()) {
224+
throw new GroupException(GroupErrorCode.INVALID_GROUP_IMAGE_KEY, group.getId(),
225+
userId);
226+
}
227+
// sortOrder는 nullable 허용 (없으면 아래에서 자동 배정 가능)
228+
if (it.sortOrder() != null) {
229+
if (it.sortOrder() < 0) {
230+
throw new GroupException(GroupErrorCode.INVALID_GROUP_IMAGE_SORT_ORDER_RANGE,
231+
group.getId(), userId);
232+
}
233+
// 최대 3장 정책이면 0~2로 제한하는 게 예측 가능
234+
if (it.sortOrder() >= MAX_IMAGES) {
235+
throw new GroupException(GroupErrorCode.INVALID_GROUP_IMAGE_SORT_ORDER_RANGE,
236+
group.getId(), userId);
237+
}
238+
}
239+
}
240+
241+
// 2) trim 적용 (정합성 유지)
242+
List<CreateGroupImageV2Request> cleaned = raw.stream()
243+
.map(it -> new CreateGroupImageV2Request(it.imageKey().trim(), it.sortOrder()))
244+
.toList();
245+
246+
// 3) 최대 3장
247+
if (cleaned.size() > MAX_IMAGES) {
248+
throw new GroupException(GroupErrorCode.IMAGE_UPLOAD_EXCEED, cleaned.size());
249+
}
250+
251+
// 4) imageKey 중복 금지
252+
List<String> keys = cleaned.stream().map(CreateGroupImageV2Request::imageKey).toList();
253+
if (new LinkedHashSet<>(keys).size() != keys.size()) {
254+
throw new GroupException(GroupErrorCode.DUPLICATED_IMAGE_KEY_IN_REQUEST);
255+
}
256+
257+
// 5) 최종 순서 결정
258+
boolean allNullSortOrder = cleaned.stream().allMatch(it -> it.sortOrder() == null);
259+
260+
List<String> desiredKeys;
261+
if (allNullSortOrder) {
262+
// sortOrder가 모두 없으면 요청 순서가 곧 최종 순서 (0번이 대표)
263+
desiredKeys = cleaned.stream()
264+
.map(CreateGroupImageV2Request::imageKey)
265+
.toList();
266+
} else {
267+
// sortOrder 중복 검사
268+
Set<Integer> used = new HashSet<>();
269+
for (CreateGroupImageV2Request it : cleaned) {
270+
if (it.sortOrder() != null) {
271+
if (!used.add(it.sortOrder())) {
272+
throw new GroupException(
273+
GroupErrorCode.DUPLICATED_IMAGE_SORT_ORDER_IN_REQUEST,
274+
group.getId(), userId);
275+
}
276+
}
277+
}
278+
279+
// null sortOrder 자동 배정 (0..2 중 빈 자리)
280+
int next = 0;
281+
List<ItemWithIndex> normalized = new ArrayList<>();
282+
for (int i = 0; i < cleaned.size(); i++) {
283+
CreateGroupImageV2Request it = cleaned.get(i);
284+
Integer so = it.sortOrder();
285+
286+
if (so == null) {
287+
while (used.contains(next)) {
288+
next++;
289+
}
290+
// next도 0..2 보장(최대 3장이라 여기서 넘칠 일은 거의 없지만 방어)
291+
if (next >= MAX_IMAGES) {
292+
throw new GroupException(
293+
GroupErrorCode.INVALID_GROUP_IMAGE_SORT_ORDER_RANGE, group.getId(),
294+
userId);
295+
}
296+
so = next;
297+
used.add(so);
298+
}
299+
300+
normalized.add(new ItemWithIndex(i, it.imageKey(), so));
301+
}
302+
303+
// sortOrder 오름차순, tie면 원래 요청 순서
304+
desiredKeys = normalized.stream()
305+
.sorted(Comparator
306+
.comparingInt(ItemWithIndex::sortOrder)
307+
.thenComparingInt(ItemWithIndex::index))
308+
.map(ItemWithIndex::imageKey)
309+
.toList();
310+
}
311+
312+
// 6) 최종 상태 리스트(desiredKeys)를 기존 “안전 재정렬 로직”으로 반영
313+
applyFinalImageKeysWithSafeReorder(group, userId, desiredKeys);
314+
}
315+
316+
// applyFinalImageKeysWithSafeReorder()는 “최종 desiredKeys 적용” 역할
317+
private void applyFinalImageKeysWithSafeReorder(GroupV2 group, Long userId,
318+
List<String> finalKeys) {
319+
// 1) 최종 키 정규화(방어적으로 trim)
320+
List<String> desiredKeys = finalKeys.stream()
233321
.map(String::trim)
234322
.toList();
235323

236-
if (desiredKeys.size() > 3) {
324+
// 2) 공통 검증 (중복/개수)
325+
if (desiredKeys.size() > MAX_IMAGES) {
237326
throw new GroupException(GroupErrorCode.IMAGE_UPLOAD_EXCEED, desiredKeys.size());
238327
}
239328
if (new LinkedHashSet<>(desiredKeys).size() != desiredKeys.size()) {
240329
throw new GroupException(GroupErrorCode.DUPLICATED_IMAGE_KEY_IN_REQUEST);
241330
}
242331

332+
// 3) 최종 키가 빈 리스트면 전체 삭제
243333
if (desiredKeys.isEmpty()) {
244334
new ArrayList<>(group.getImages()).forEach(group::removeImage);
245-
em.flush(); // 완전 삭제 즉시 반영
335+
em.flush();
246336
return;
247337
}
248338

249-
// 요청에 없는 기존 이미지 삭제
339+
// 4) 요청에 없는 기존 이미지 삭제
250340
Set<String> desiredKeySet = new HashSet<>(desiredKeys);
251341
List<GroupImageV2> toRemove = group.getImages().stream()
252342
.filter(img -> !desiredKeySet.contains(img.getImageKey()))
253343
.toList();
254344
toRemove.forEach(group::removeImage);
255345

256-
// 남은 이미지들을 임시 음수로 이동: 유니크 충돌 방지
346+
// 5) 남은 이미지 임시 음수 이동 (유니크 충돌 방지)
257347
List<GroupImageV2> remaining = group.getImages();
258348
for (int i = 0; i < remaining.size(); i++) {
259349
remaining.get(i).changeSortOrder(-(i + 1));
260350
}
261351

262-
em.flush(); // 여기서 “임시 음수 update + 삭제”를 DB에 먼저 반영
352+
em.flush();
263353

264-
// 삭제 후 기준으로 존재하는 key map 구성
354+
// 6) 삭제 후 기준으로 존재하는 key map
265355
Map<String, GroupImageV2> afterRemoveByKey = group.getImages().stream()
266356
.collect(Collectors.toMap(GroupImageV2::getImageKey, img -> img));
267357

268-
// 새로 생성해야 하는 키(요청에는 있는데 현재 없는 것)
358+
// 7) 새로 생성해야 하는 키
269359
List<String> toCreateKeys = desiredKeys.stream()
270360
.filter(k -> !afterRemoveByKey.containsKey(k))
271361
.toList();
272362

273-
// 생성 (temp sortOrder는 서로 다르게)
274363
int temp = TEMP_SORT_ORDER;
275364
for (String key : toCreateKeys) {
276365
PreUploadedGroupImage pre = preUploadedGroupImageRedisRepository.consume(key)
277-
.orElseThrow(
278-
() -> new GroupException(GroupErrorCode.PRE_UPLOADED_IMAGE_NOT_FOUND,
279-
key));
366+
.orElseThrow(() -> new GroupException(
367+
GroupErrorCode.PRE_UPLOADED_IMAGE_NOT_FOUND, key));
280368

281369
if (!userId.equals(pre.uploaderId())) {
282370
throw new GroupException(GroupErrorCode.PRE_UPLOADED_IMAGE_OWNER_MISMATCH, key);
@@ -285,21 +373,30 @@ private void applyImagesWithSafeReorder(GroupV2 group, Long userId, List<String>
285373
GroupImageV2.create(group, temp--, pre.imageKey(), pre.url440x240(), pre.url100x100());
286374
}
287375

288-
// 최종 매핑 + 검증
376+
// 8) 최종 매핑 + 검증
289377
Map<String, GroupImageV2> afterByKey = group.getImages().stream()
290378
.collect(Collectors.toMap(GroupImageV2::getImageKey, img -> img, (a, b) -> a));
291379

292380
for (String key : desiredKeys) {
293381
if (!afterByKey.containsKey(key)) {
294-
throw new GroupException(GroupErrorCode.GROUP_IMAGE_NOT_FOUND_IN_GROUP_AFTER_UPDATE,
295-
key);
382+
throw new GroupException(
383+
GroupErrorCode.GROUP_IMAGE_NOT_FOUND_IN_GROUP_AFTER_UPDATE, key);
296384
}
297385
}
298386

299-
// 최종 sortOrder 0.. 부여
387+
// 9) 최종 sortOrder 부여
300388
for (int i = 0; i < desiredKeys.size(); i++) {
301389
afterByKey.get(desiredKeys.get(i)).changeSortOrder(i);
302390
}
303391
}
392+
393+
private record ItemWithIndex(
394+
int index,
395+
String imageKey,
396+
int sortOrder
397+
) {
398+
399+
}
400+
304401
}
305402

src/test/http/group/v2/v2-group-create.http

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,17 @@ Authorization: Bearer {{accessToken}}
9696
Content-Disposition: form-data; name="images"; filename="test-webp1.webp"
9797
Content-Type: image/webp
9898

99-
< ../image/resources/test-webp1.webp
99+
< ../../image/resources/test-webp1.webp
100100
--boundary
101101
Content-Disposition: form-data; name="images"; filename="test-webp2.webp"
102102
Content-Type: image/webp
103103

104-
< ../image/resources/test-webp2.webp
104+
< ../../image/resources/test-webp2.webp
105105
--boundary
106106
Content-Disposition: form-data; name="images"; filename="img1.png"
107107
Content-Type: image/png
108108

109-
< ../image/resources/img1.png
109+
< ../../image/resources/img1.png
110110
--boundary--
111111

112112
> {%

src/test/http/group/v2/v2-group-kick-targets.http

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,11 @@ Authorization: Bearer {{kt_member2AccessToken}}
145145
{}
146146

147147
### 5-1. HOST가 kick 대상 조회 (kick-targets) - 성공 기대 (HOST 제외 ATTEND 멤버들)
148-
POST http://localhost:8080/api/v2/groups/{{kt_groupId}}/attendance/kick-targets
148+
GET http://localhost:8080/api/v2/groups/{{kt_groupId}}/attendance/kick-targets
149149
Authorization: Bearer {{kt_hostAccessToken}}
150150

151151
### 5-2. 예외: MEMBER가 kick-targets 조회 (HOST only)
152-
POST http://localhost:8080/api/v2/groups/{{kt_groupId}}/attendance/kick-targets
152+
GET http://localhost:8080/api/v2/groups/{{kt_groupId}}/attendance/kick-targets
153153
Authorization: Bearer {{kt_member1AccessToken}}
154154

155155
### 6-1. (선택) 참여자 없는 새 모임 생성 -> kick-targets 빈 배열 확인

0 commit comments

Comments
 (0)