1616import team .wego .wegobackend .group .domain .exception .GroupException ;
1717import team .wego .wegobackend .group .v2 .application .dto .common .Address ;
1818import team .wego .wegobackend .group .v2 .application .dto .common .GroupImageItem ;
19- import team .wego .wegobackend .group .v2 .application .dto .common .GroupImageVariantItem ;
2019import team .wego .wegobackend .group .v2 .application .dto .common .PreUploadedGroupImage ;
20+ import team .wego .wegobackend .group .v2 .application .dto .request .CreateGroupImageV2Request ;
2121import team .wego .wegobackend .group .v2 .application .dto .request .UpdateGroupV2Request ;
2222import team .wego .wegobackend .group .v2 .application .dto .response .UpdateGroupV2Response ;
2323import team .wego .wegobackend .group .v2 .domain .entity .GroupImageV2 ;
24- import team .wego .wegobackend .group .v2 .domain .entity .GroupImageV2VariantType ;
2524import team .wego .wegobackend .group .v2 .domain .entity .GroupTagV2 ;
2625import team .wego .wegobackend .group .v2 .domain .entity .GroupUserV2Status ;
2726import team .wego .wegobackend .group .v2 .domain .entity .GroupV2 ;
2827import team .wego .wegobackend .group .v2 .domain .entity .GroupV2Address ;
29- import team .wego .wegobackend .group .v2 .domain .entity .ImageV2Format ;
3028import team .wego .wegobackend .group .v2 .domain .repository .GroupImageV2Repository ;
3129import team .wego .wegobackend .group .v2 .domain .repository .GroupUserV2Repository ;
3230import 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
0 commit comments