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/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); 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(