From 415644498045690f4138837dff1830f4f48da057 Mon Sep 17 00:00:00 2001 From: sangsong0734-creator Date: Thu, 20 Aug 2026 16:23:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=A3=A9=EB=B6=81=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=95=AD=EB=AA=A9=EC=97=90=20saveId=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20=E2=80=94=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=94=EB=A1=9C=20=EC=A0=80=EC=9E=A5/=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상세로 안 들어가고도 피드/마이클로젯 목록의 룩북 썸네일에서 하트 옆에 저장 버튼을 붙일 수 있도록, LookbookItem(목록 조회 응답)에 saveId를 추가. null이면 저장 안 한 상태, 값이 있으면 그 값이 저장취소(기존 DELETE /api/v1/closet-saves/{saveId})에 쓰는 id — 상세 조회 응답의 saveId와 동일한 규칙. ClosetSaveRepository에 배치 조회용 메서드 추가 (findAllByMemberIdAndTargetTypeAndTargetIdIn), LookbookService가 좋아요 여부(isLiked)와 같은 방식으로 목록 조회 시 한 번에 계산. LookbookServiceTest에 저장된 항목/안 된 항목이 섞인 목록에서 saveId가 정확히 채워지는지 확인하는 테스트 추가. --- .../repository/ClosetSaveRepository.java | 7 +++ .../domain/lookbook/dto/LookbookResponse.java | 10 +++- .../lookbook/service/LookbookService.java | 20 +++++++- .../lookbook/service/LookbookServiceTest.java | 46 +++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/fitback/backend/domain/closet/repository/ClosetSaveRepository.java b/src/main/java/com/fitback/backend/domain/closet/repository/ClosetSaveRepository.java index ccf3e43b..f07389c2 100644 --- a/src/main/java/com/fitback/backend/domain/closet/repository/ClosetSaveRepository.java +++ b/src/main/java/com/fitback/backend/domain/closet/repository/ClosetSaveRepository.java @@ -132,4 +132,11 @@ Set findSavedTargetIds( @Param("targetType") ClosetTargetType targetType, @Param("targetIds") List targetIds ); + + // 목록 화면(피드 등)에서 항목마다 저장 취소용 saveId를 같이 내려주기 위한 배치 조회 + List findAllByMemberIdAndTargetTypeAndTargetIdIn( + Long memberId, + ClosetTargetType targetType, + List targetIds + ); } diff --git a/src/main/java/com/fitback/backend/domain/lookbook/dto/LookbookResponse.java b/src/main/java/com/fitback/backend/domain/lookbook/dto/LookbookResponse.java index 5420a77b..1dd5c138 100644 --- a/src/main/java/com/fitback/backend/domain/lookbook/dto/LookbookResponse.java +++ b/src/main/java/com/fitback/backend/domain/lookbook/dto/LookbookResponse.java @@ -92,7 +92,11 @@ public record LookbookItem( String authorProfileImageUrl, List tags, Integer likeCount, - boolean isLiked + boolean isLiked, + // 상세로 안 들어가고 목록(썸네일)에서 바로 저장/저장취소할 수 있도록 — + // null이면 저장 안 한 상태, 값이 있으면 그 값이 저장취소(DELETE) 호출에 + // 쓰는 closet-save id (상세 조회의 saveId와 동일한 규칙) + Long saveId ) { public static LookbookItem toLookbookItem( @@ -101,7 +105,8 @@ public static LookbookItem toLookbookItem( String matchedImageUrl, String authorProfileImageUrl, List tags, - boolean isLiked + boolean isLiked, + Long saveId ) { return LookbookItem.builder() .lookbookId(lookbook.getId()) @@ -115,6 +120,7 @@ public static LookbookItem toLookbookItem( .tags(List.copyOf(tags)) .likeCount(lookbook.getLikeCount()) .isLiked(isLiked) + .saveId(saveId) .build(); } } diff --git a/src/main/java/com/fitback/backend/domain/lookbook/service/LookbookService.java b/src/main/java/com/fitback/backend/domain/lookbook/service/LookbookService.java index 726004e3..1214059d 100644 --- a/src/main/java/com/fitback/backend/domain/lookbook/service/LookbookService.java +++ b/src/main/java/com/fitback/backend/domain/lookbook/service/LookbookService.java @@ -668,6 +668,22 @@ private Set findLikedLookbookIds(List lookbookIds, Member member) { return lookbookLikeRepository.findLikedLookbookIds(member.getId(), lookbookIds); } + // 목록에서 상세로 안 들어가고도 저장/저장취소할 수 있게, 룩북별 closet-save id를 조회 + // (없으면 저장 안 한 상태 — 맵에 없는 키로 취급) + private Map findSaveIdsByLookbookId(List lookbookIds, Member member) { + if (member == null || lookbookIds.isEmpty()) { + return Map.of(); + } + return closetSaveRepository + .findAllByMemberIdAndTargetTypeAndTargetIdIn( + member.getId(), + ClosetTargetType.LOOKBOOK, + lookbookIds + ) + .stream() + .collect(Collectors.toMap(ClosetSave::getTargetId, ClosetSave::getId)); + } + private List toLookbookItems( List lookbooks, Member member @@ -679,6 +695,7 @@ private List toLookbookItems( lookbookIds ); Set likedLookbookIds = findLikedLookbookIds(lookbookIds, member); + Map saveIdsByLookbookId = findSaveIdsByLookbookId(lookbookIds, member); Map profileImageUrls = memberProfileImageService.resolveProfileImageUrls( lookbooks.stream() @@ -692,7 +709,8 @@ private List toLookbookItems( resolveMatchedImageUrl(lookbook), profileImageUrls.get(lookbook.getMember().getId()), tagNamesByLookbookId.getOrDefault(lookbook.getId(), List.of()), - likedLookbookIds.contains(lookbook.getId()) + likedLookbookIds.contains(lookbook.getId()), + saveIdsByLookbookId.get(lookbook.getId()) )) .toList(); } diff --git a/src/test/java/com/fitback/backend/domain/lookbook/service/LookbookServiceTest.java b/src/test/java/com/fitback/backend/domain/lookbook/service/LookbookServiceTest.java index 4143bd60..1498a7aa 100644 --- a/src/test/java/com/fitback/backend/domain/lookbook/service/LookbookServiceTest.java +++ b/src/test/java/com/fitback/backend/domain/lookbook/service/LookbookServiceTest.java @@ -1025,6 +1025,52 @@ void getLookbooksUsesRequestedPageSizeAndReturnsNextCursor() { verify(memberProfileImageService).resolveProfileImageUrls(anyList()); } + // 상세로 안 들어가고 목록에서 바로 저장/저장취소할 수 있도록 각 항목에 saveId를 채워주는지 확인 + @Test + void getLookbooksIncludesSaveIdOnlyForSavedLookbooks() { + LocalDateTime latestCreatedAt = LocalDateTime.of(2026, 7, 16, 12, 0); + List lookbookPage = List.of( + createListLookbook(100L, latestCreatedAt), + createListLookbook(99L, latestCreatedAt.minusMinutes(1)) + ); + List returnedLookbookIds = List.of(100L, 99L); + when(lookbookRepository + .findAllByDeletedAtIsNullAndModerationStatusOrderByCreatedAtDescIdDesc( + eq(LookbookModerationStatus.VISIBLE), + any(Pageable.class) + )) + .thenReturn(lookbookPage); + when(lookbookTagRepository.findAllByLookbookIdInOrderByIdAsc(returnedLookbookIds)) + .thenReturn(List.of()); + when(memberProfileImageService.resolveProfileImageUrls(anyList())) + .thenReturn(Map.of()); + + ClosetSave savedEntry = ClosetSave.create(member, ClosetTargetType.LOOKBOOK, 100L); + ReflectionTestUtils.setField(savedEntry, "id", 555L); + when(closetSaveRepository.findAllByMemberIdAndTargetTypeAndTargetIdIn( + eq(1L), + eq(ClosetTargetType.LOOKBOOK), + eq(returnedLookbookIds) + )).thenReturn(List.of(savedEntry)); + + LookbookResponse.LookbookList response = lookbookService.getLookbooks( + null, + 20, + null, + member + ); + + assertThat(response.items()) + .extracting( + LookbookResponse.LookbookItem::lookbookId, + LookbookResponse.LookbookItem::saveId + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(100L, 555L), + org.assertj.core.groups.Tuple.tuple(99L, null) + ); + } + @Test void getMyLookbooksUsesRequestedPageSizeAndReturnsNextCursor() { LocalDateTime latestCreatedAt = LocalDateTime.of(2026, 7, 16, 12, 0); From 347ce32805ba6b4d3b45d6b570a70fb8ae2a1bad Mon Sep 17 00:00:00 2001 From: sangsong0734-creator Date: Thu, 20 Aug 2026 16:49:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=A7=81=EC=A0=91=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80=20=EC=9E=AC=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20500=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 태그 분석 화면에서 매치율만 바꿔 동일한 커스텀 태그명으로 재확인하면 서버 내부 오류(500)가 발생하던 문제를 수정. 원인: AnalysisReport.confirmRecommendationInput()에서 customTags를 clear() 후 addAll()로 통째로 교체하고 있었는데, 이름이 안 바뀐 커스텀 태그까지 같은 flush 안에서 delete 후 insert가 일어나 (report_id, normalized_name) 유니크 제약을 일시적으로 위반할 수 있었음 (Hibernate flush 순서가 delete-before-insert를 보장하지 않음). 수정: 바로 위 reportTags 처리와 동일한 패턴으로, 실제로 제거된 이름만 removeIf하고 새로 추가된 이름만 add하도록 변경 — 이름이 그대로인 커스텀 태그는 기존 엔티티를 유지해 delete+insert 자체가 발생하지 않음. 테스트: - resubmittingSameCustomTagWithDifferentMatchPercentageSucceeds: 수정 전 500으로 실패하던 재현 테스트, 수정 후 통과 확인 - rejectsCustomTagNameOverFiftyCharsWithValidationErrorNotServerError: 50자 초과 커스텀 태그명이 여전히 400(COMMON400_2)으로 정상 거부되는지 회귀 확인 Co-Authored-By: Claude Sonnet 5 --- .../analysis/entity/AnalysisReport.java | 24 ++++-- .../RecommendationApiFlowIntegrationTest.java | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/fitback/backend/domain/analysis/entity/AnalysisReport.java b/src/main/java/com/fitback/backend/domain/analysis/entity/AnalysisReport.java index 3541d014..dc2d8831 100644 --- a/src/main/java/com/fitback/backend/domain/analysis/entity/AnalysisReport.java +++ b/src/main/java/com/fitback/backend/domain/analysis/entity/AnalysisReport.java @@ -220,12 +220,13 @@ public void confirmRecommendationInput( .filter(ReportTag::isConfirmed) .map(reportTag -> reportTag.getTag().getId()) .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); - Set currentCustomTagNames = customTags.stream() - .map(ReportCustomTag::getNormalizedName) - .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + Map currentCustomTagsByName = customTags.stream() + .collect(LinkedHashMap::new, + (tags, customTag) -> tags.put(customTag.getNormalizedName(), customTag), + LinkedHashMap::putAll); boolean sameKnownTags = reportTags.size() == confirmedTagIds.size() && currentConfirmedTagIds.equals(confirmedTagIds); - boolean sameCustomTags = currentCustomTagNames.equals( + boolean sameCustomTags = currentCustomTagsByName.keySet().equals( new LinkedHashSet<>(uniqueCustomTags.keySet()) ); if (sameKnownTags @@ -243,8 +244,19 @@ public void confirmRecommendationInput( reportTag.confirm(); } } - customTags.clear(); - customTags.addAll(uniqueCustomTags.values()); + // 이름이 그대로인 커스텀 태그는 기존 엔티티를 유지한다 — clear()+addAll()로 + // 전부 갈아끼우면, 이름이 안 바뀐 태그까지 같은 flush 안에서 delete 후 insert가 + // 일어나는데 그 순서가 보장되지 않아 (report_id, normalized_name) 유니크 + // 제약을 일시적으로 위반할 수 있었다 — 실사용 재현: 매치율만 바꿔 재확인하면 + // 500(서버 내부 오류)이 났음. + customTags.removeIf( + customTag -> !uniqueCustomTags.containsKey(customTag.getNormalizedName()) + ); + for (Map.Entry entry : uniqueCustomTags.entrySet()) { + if (!currentCustomTagsByName.containsKey(entry.getKey())) { + customTags.add(entry.getValue()); + } + } this.matchPercentage = matchPercentage; this.recommendationInputRevision++; } diff --git a/src/test/java/com/fitback/backend/domain/recommendation/controller/RecommendationApiFlowIntegrationTest.java b/src/test/java/com/fitback/backend/domain/recommendation/controller/RecommendationApiFlowIntegrationTest.java index 36de806e..dd84eb56 100644 --- a/src/test/java/com/fitback/backend/domain/recommendation/controller/RecommendationApiFlowIntegrationTest.java +++ b/src/test/java/com/fitback/backend/domain/recommendation/controller/RecommendationApiFlowIntegrationTest.java @@ -177,6 +177,69 @@ void keepsEightEmptyGroupsWhenThresholdExcludesAllCandidates() throws Exception } } + // 프론트 "직접 태그 입력"이 글자 수 제한 없이 그대로 보낼 수 있어서 재현한 케이스 — + // 50자를 넘는 커스텀 태그명이 500(서버 내부 오류)이 아니라 400으로 깔끔하게 막히는지 확인 + @Test + void rejectsCustomTagNameOverFiftyCharsWithValidationErrorNotServerError() throws Exception { + String email = "recommendation-api-long-tag@fitback.com"; + String accessToken = signUpAndGetAccessToken(email); + AnalysisReport report = createReport(email, "Fixture"); + Long tagId = report.getDisplayTags().getFirst().getId(); + String tooLongTagName = "가".repeat(51); + + mockMvc.perform(post( + "/api/v1/analyses/{reportId}/recommendations", + report.getId() + ) + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(recommendationRequestWithCustomTag( + List.of(tagId), + 70, + tooLongTagName + ))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400_2")); + } + + // 커스텀 태그는 그대로 두고 매치율만 바꿔 다시 확인하면 confirmRecommendationInput이 + // customTags.clear()+addAll()로 갈아끼우는데, 이때 안 바뀐 커스텀 태그까지 같이 + // 지웠다 새로 넣다가 (report_id, normalized_name) 유니크 제약을 스쳐서 위반하지 + // 않는지 확인 — 재현되면 500(서버 내부 오류)으로 나타난다는 사용자 리포트가 있었음 + @Test + void resubmittingSameCustomTagWithDifferentMatchPercentageSucceeds() throws Exception { + String email = "recommendation-api-resubmit@fitback.com"; + String accessToken = signUpAndGetAccessToken(email); + AnalysisReport report = createReport(email, "Fixture"); + Long tagId = report.getDisplayTags().getFirst().getId(); + + mockMvc.perform(post( + "/api/v1/analyses/{reportId}/recommendations", + report.getId() + ) + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(recommendationRequestWithCustomTag( + List.of(tagId), + 70, + "고프코어" + ))) + .andExpect(status().isOk()); + + mockMvc.perform(post( + "/api/v1/analyses/{reportId}/recommendations", + report.getId() + ) + .header("Authorization", bearer(accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content(recommendationRequestWithCustomTag( + List.of(tagId), + 80, + "고프코어" + ))) + .andExpect(status().isOk()); + } + @Test void enforcesAuthenticationAndReportOwnershipAcrossTheFlow() throws Exception { String ownerEmail = "recommendation-api-owner@fitback.com"; @@ -281,6 +344,18 @@ private String recommendationRequest(List tagIds, int matchPercentage) { )); } + private String recommendationRequestWithCustomTag( + List tagIds, + int matchPercentage, + String customTagName + ) { + return objectMapper.writeValueAsString(Map.of( + "confirmedTagIds", tagIds, + "customTagNames", List.of(customTagName), + "matchPercentage", matchPercentage + )); + } + private AnalysisReport createReport(String email, String... tagNames) { Member member = memberRepository.findByEmail(email).orElseThrow(); AnalysisReport report = AnalysisReport.create(