From 4cafcd5fac3b298a8a3dd090250801b5860ce236 Mon Sep 17 00:00:00 2001 From: hoshino-love0102 Date: Fri, 16 Oct 2026 20:15:54 +0900 Subject: [PATCH] fix: enforce recommendation lengths and refresh stale results --- docs/hairstyle-catalog.md | 15 +++ .../RecommendationController.java | 4 +- .../service/RecommendationService.java | 35 ++++- .../service/RuleBasedV1Scorer.java | 15 ++- .../RecommendationApiIntegrationTest.java | 126 +++++++++++++++++- .../service/RuleBasedV1ScorerTest.java | 57 ++++++++ 6 files changed, 243 insertions(+), 9 deletions(-) diff --git a/docs/hairstyle-catalog.md b/docs/hairstyle-catalog.md index 8b9ab194..d00a6298 100644 --- a/docs/hairstyle-catalog.md +++ b/docs/hairstyle-catalog.md @@ -6,3 +6,18 @@ - 이미지는 저작권이 확인된 파일만 S3에 업로드하고 DB에는 `file_id`만 연결한다. - 운영 seed에는 외부 공개 URL이나 검수되지 않은 예시 스타일을 넣지 않는다. - 실제 스타일·호환성 데이터는 디자이너 또는 도메인 담당자의 검수를 거쳐야 한다. + +## 추천 길이 및 갱신 기준 + +- 현재 머리 길이가 있으면 `minimum_hair_length`와 `compatible_hair_lengths`의 최단 길이 중 + 더 긴 길이를 최소 필요 길이로 사용한다. 최소 길이가 누락돼도 호환 길이로 검증한다. +- 두 길이 정보가 모두 없으면 길이를 확인할 수 없으므로 해당 후보를 제외한다. + 사용자의 길이 입력 자체가 없는 경우에는 기존 콜드 스타트 동작을 유지한다. +- 긴 머리 사용자가 짧은 커트를 선택하는 것은 허용한다. 시술 이력·선호 점수가 높아도 + 현재 머리보다 긴 길이가 필요한 후보는 통과하지 않는다. +- `/recommendations/latest`는 기존 실행이 있을 때 현재 입력과 알고리즘을 검증하고, + 변경됐거나 STALE인 결과는 재생성한다. 변경이 없으면 기존 실행을 재사용한다. + 최초 실행이 없으면 404, 적합한 후보가 없으면 422를 반환한다. +- 카탈로그 호환성·점수·정렬 조건도 입력 해시에 포함한다. 운영 메타데이터 버전을 빠뜨린 + 수정도 이전 결과를 재사용하지 않는다. `/recommendations/{id}`는 과거 실행 조회를 유지한다. +- `force_refresh=true`는 새 실행을 만들지만 같은 입력의 순위를 무작위로 섞지는 않는다. diff --git a/src/main/java/com/heddy/adapter/in/web/recommendation/RecommendationController.java b/src/main/java/com/heddy/adapter/in/web/recommendation/RecommendationController.java index 150cf7b3..005b607f 100644 --- a/src/main/java/com/heddy/adapter/in/web/recommendation/RecommendationController.java +++ b/src/main/java/com/heddy/adapter/in/web/recommendation/RecommendationController.java @@ -44,7 +44,9 @@ public ApiResponse generate( } @GetMapping("/recommendations/latest") - @Operation(summary = "최신 추천 조회") + @Operation(summary = "최신 추천 조회", + description = "기존 추천이 있으면 현재 프로필·시술기록·카탈로그·알고리즘 기준으로 검증하고 " + + "변경된 결과는 자동 재생성합니다. 최초 추천이 없으면 404를 반환합니다.") public ApiResponse latest( @AuthenticationPrincipal UUID userId, HttpServletRequest servletRequest diff --git a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java index aaf1ad14..3ae5ffe5 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -121,8 +121,16 @@ public RecommendationService( @Override @Transactional public RecommendationResult generate(UUID userId, boolean forceRefresh) { + lockActiveAccount(userId); + return generateForLockedAccount(userId, forceRefresh); + } + + private void lockActiveAccount(UUID userId) { validateActiveAccount(accountRepositoryPort.findByIdForUpdate(userId) .orElseThrow(() -> new AccountException(AccountError.ACCOUNT_NOT_FOUND))); + } + + private RecommendationResult generateForLockedAccount(UUID userId, boolean forceRefresh) { Instant generatedAt = clock.instant(); HairProfile profile = hairProfileRepositoryPort.findByUserId(userId).orElse(null); List preferences = preferenceRepositoryPort.findAllByUserId(userId); @@ -163,9 +171,13 @@ public RecommendationResult generate(UUID userId, boolean forceRefresh) { } @Override + @Transactional public RecommendationResult getLatest(UUID userId) { - return render(recommendationRepositoryPort.findLatestByUserId(userId) - .orElseThrow(() -> new RecommendationException(RecommendationError.NOT_FOUND))); + lockActiveAccount(userId); + recommendationRepositoryPort.findLatestByUserId(userId) + .orElseThrow(() -> new RecommendationException(RecommendationError.NOT_FOUND)); + // 화면 재방문 시에도 현재 입력/알고리즘으로 검증한다. 변경이 없으면 기존 실행을 재사용한다. + return generateForLockedAccount(userId, false); } @Override @@ -245,7 +257,7 @@ private String canonicalSnapshot( List candidates, TreatmentHistorySummary treatmentHistory ) { - StringBuilder value = new StringBuilder("RULE_BASED_V1|"); + StringBuilder value = new StringBuilder("RULE_BASED_V1:MIN_LENGTH_V2|"); HairProfile profile = context.hairProfile(); if (profile != null) { value.append(profile.hairLength()).append('|').append(profile.hairType()).append('|') @@ -264,7 +276,22 @@ private String canonicalSnapshot( candidates.stream().sorted(Comparator.comparing(HairstyleCandidate::hairstyleId)) .forEach(candidate -> value.append('|').append(candidate.hairstyleId()).append(':') .append(candidate.assetVersion()).append(':').append(candidate.metadataVersion()) - .append(':').append(candidate.arMode())); + .append(':').append(candidate.arMode()) + .append(':').append(candidate.category()) + .append(':').append(candidate.thumbnailFileId()) + .append(':').append(candidate.active()) + .append(':').append(candidate.minimumHairLength()) + .append(':').append(candidate.estimatedDailyCareMinutes()) + .append(':').append(candidate.managementDifficulty()) + .append(':').append(candidate.chemicalStressLevel()) + .append(':').append(candidate.editorialPriority()) + .append(':').append(candidate.serviceTypes().stream().sorted().toList()) + .append(':').append(candidate.compatibleHairLengths().stream().sorted().toList()) + .append(':').append(candidate.compatibleHairTypes().stream().sorted().toList()) + .append(':').append(candidate.compatibleHairThicknesses().stream().sorted().toList()) + .append(':').append(candidate.compatibleHairConditions().stream().sorted().toList()) + .append(':').append(candidate.contraindicatedHairConditions().stream().sorted().toList()) + .append(':').append(candidate.tags().keySet().stream().sorted().toList())); return value.toString(); } diff --git a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java index 5d983faf..b775e50e 100644 --- a/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java +++ b/src/main/java/com/heddy/domain/recommendation/service/RuleBasedV1Scorer.java @@ -87,9 +87,18 @@ private boolean eligible(HairstyleCandidate candidate, RecommendationContext con if (profile == null) { return true; } - if (candidate.minimumHairLength() != null && profile.hairLength() != null - && profile.hairLength().ordinal() < candidate.minimumHairLength().ordinal()) { - return false; + if (profile.hairLength() != null) { + HairProfile.HairLength compatibleMinimum = candidate.compatibleHairLengths().stream() + .min(Comparator.naturalOrder()).orElse(null); + HairProfile.HairLength minimumLength = candidate.minimumHairLength(); + // 명시된 최소 길이가 누락되거나 호환 길이보다 짧아도 긴 스타일을 통과시키지 않는다. + if (minimumLength == null || (compatibleMinimum != null + && compatibleMinimum.compareTo(minimumLength) > 0)) { + minimumLength = compatibleMinimum; + } + if (minimumLength == null || profile.hairLength().compareTo(minimumLength) < 0) { + return false; + } } return profile.hairCondition() == null || !candidate.contraindicatedHairConditions().contains(profile.hairCondition()); diff --git a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java index 3c61c85d..cf5b43d2 100644 --- a/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/recommendation/RecommendationApiIntegrationTest.java @@ -162,9 +162,133 @@ void deletingReferencedTreatmentMarksRunStale() throws Exception { .with(authentication(auth(USER_ID)))) .andExpect(status().isNoContent()); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM recommendation_runs WHERE user_id = ?", String.class, USER_ID)) + .isEqualTo("STALE"); mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("STALE")); + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.recommendation_basis.treatment_history.count").value(0)); + } + + @Test + void latestRegeneratesOldAlgorithmAndThenReusesTheCorrectedRun() throws Exception { + String oldId = generateRun(); + jdbcTemplate.update("UPDATE recommendation_runs SET input_hash = ? WHERE recommendation_run_id = ?", + "0".repeat(64), UUID.fromString(oldId)); + entityManager.clear(); + + String currentId = latestRun(); + assertThat(currentId).isNotEqualTo(oldId); + assertThat(latestRun()).isEqualTo(currentId); + assertThat(runCount()).isEqualTo(2); + } + + @Test + void latestReplacesLongStylesAfterProfileChangesToShort() throws Exception { + String oldId = generateRun(); + insertShortStyle(); + jdbcTemplate.update("UPDATE hair_profiles SET hair_length = 'SHORT' WHERE user_id = ?", USER_ID); + entityManager.clear(); + + String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_length").value("SHORT")) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("클린 픽시컷")) + .andReturn().getResponse().getContentAsString(); + String currentId = runId(response); + assertThat(currentId).isNotEqualTo(oldId); + assertThat(latestRun()).isEqualTo(currentId); + assertThat(runCount()).isEqualTo(2); + mockMvc.perform(get("/recommendations/{id}", oldId).with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("레이어드 C컬")); + } + + @Test + void latestReevaluatesCatalogLengthsEvenWithoutMetadataVersionChange() throws Exception { + String oldId = generateRun(); + jdbcTemplate.update(""" + UPDATE hairstyle_recommendation_profiles + SET compatible_hair_lengths = '["BELOW_CHEST"]'::jsonb + WHERE hairstyle_id = ? + """, UUID.fromString("94000000-0000-4000-8000-000000000001")); + entityManager.clear(); + + String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[*].hairstyle.style_name") + .value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.hasItem("레이어드 C컬")))) + .andReturn().getResponse().getContentAsString(); + assertThat(runId(response)).isNotEqualTo(oldId); + } + + @Test + void latestDoesNotReturnIncompatibleOldResultWhenNoCandidatesRemain() throws Exception { + generateRun(); + jdbcTemplate.update("UPDATE hair_profiles SET hair_length = 'SHORT' WHERE user_id = ?", USER_ID); + entityManager.clear(); + + mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().is(422)); + assertThat(runCount()).isEqualTo(1); + } + + @Test + void latestWithoutAnyPreviousRunStillReturnsNotFound() throws Exception { + mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isNotFound()); + assertThat(runCount()).isZero(); + } + + @Test + void forcedRefreshStillCreatesANewRunAndFiltersLongStyles() throws Exception { + insertShortStyle(); + jdbcTemplate.update("UPDATE hair_profiles SET hair_length = 'SHORT' WHERE user_id = ?", USER_ID); + String oldId = generateRun(); + String response = mockMvc.perform(post("/recommendations/generate") + .with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{\"force_refresh\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("클린 픽시컷")) + .andReturn().getResponse().getContentAsString(); + assertThat(runId(response)).isNotEqualTo(oldId); + assertThat(runCount()).isEqualTo(2); + } + + private void insertShortStyle() { + String id = "94000000-0000-4000-8000-000000000004"; + insertStyle(id, "클린 픽시컷", "SHORT", "NONE", 60, false); + jdbcTemplate.update(""" + UPDATE hairstyle_recommendation_profiles + SET compatible_hair_lengths = '["SHORT", "BELOW_CHIN"]'::jsonb + WHERE hairstyle_id = ? + """, UUID.fromString(id)); + } + + private String generateRun() throws Exception { + return runId(mockMvc.perform(post("/recommendations/generate") + .with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()); + } + + private String latestRun() throws Exception { + return runId(mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString()); + } + + private String runId(String response) throws Exception { + return new com.fasterxml.jackson.databind.ObjectMapper().readTree(response) + .path("data").path("recommendation_run_id").asText(); + } + + private int runCount() { + return jdbcTemplate.queryForObject("SELECT count(*) FROM recommendation_runs WHERE user_id = ?", + Integer.class, USER_ID); } @Test diff --git a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java index 078f1cd8..56c07e75 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java @@ -13,6 +13,8 @@ import com.heddy.domain.treatment.model.ServiceType; import com.heddy.domain.treatment.model.TreatmentRecord; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -110,6 +112,61 @@ void excludedTagMinimumLengthAndContraindicationAreHardFilters() { HairCondition.DAMAGED, 10), Set.of(), Set.of(), Set.of(), List.of()))).isEmpty(); } + @Test + void highHistoryAndPreferenceScoresCannotOverrideMissingMinimumLength() { + UUID tag = UUID.randomUUID(); + HairstyleCandidate medium = candidate(Set.of(ServiceType.CUT, ServiceType.PERM), Map.of(tag, "선호")); + RecommendationContext shortHair = context( + profile(HairLength.SHORT, HairType.WAVY, HairThickness.NORMAL, HairCondition.HEALTHY, 15), + Set.of(tag), Set.of(), Set.of(medium.hairstyleId()), + List.of(treatment(Set.of(ServiceType.CUT, ServiceType.PERM), 5, 0))); + + assertThat(scorer.score(medium, shortHair)).isEmpty(); + } + + @ParameterizedTest + @EnumSource(HairLength.class) + void inferredMinimumAllowsOnlySufficientLength(HairLength length) { + HairstyleCandidate medium = withLengths(null, Set.of(HairLength.BELOW_CHIN, HairLength.BELOW_SHOULDER)); + var result = scorer.score(medium, context(profile(length, null, null, null, null), + Set.of(), Set.of(), Set.of(), List.of())); + + assertThat(result.isPresent()).isEqualTo(length != HairLength.SHORT); + } + + @Test + void stricterOfExplicitAndCompatibleMinimumWins() { + var chinLength = context(profile(HairLength.BELOW_CHIN, null, null, null, null), + Set.of(), Set.of(), Set.of(), List.of()); + assertThat(scorer.score(withLengths(HairLength.BELOW_CHIN, + Set.of(HairLength.BELOW_SHOULDER, HairLength.BELOW_CHEST)), chinLength)).isEmpty(); + assertThat(scorer.score(withLengths(HairLength.BELOW_SHOULDER, + Set.of(HairLength.SHORT, HairLength.BELOW_CHIN)), chinLength)).isEmpty(); + } + + @Test + void unknownCatalogLengthIsNotAssumedSuitableForKnownUserLength() { + var shortHair = context(profile(HairLength.SHORT, null, null, null, null), + Set.of(), Set.of(), Set.of(), List.of()); + assertThat(scorer.score(withLengths(null, Set.of()), shortHair)).isEmpty(); + assertThat(scorer.score(withLengths(HairLength.SHORT, Set.of()), shortHair)).isPresent(); + assertThat(scorer.score(withLengths(null, Set.of()), + context(null, Set.of(), Set.of(), Set.of(), List.of()))).isPresent(); + } + + @Test + void longerHairCanStillChooseShortCut() { + assertThat(scorer.score(withLengths(null, Set.of(HairLength.SHORT)), + context(profile(HairLength.BELOW_CHEST, null, null, null, null), + Set.of(), Set.of(), Set.of(), List.of()))).isPresent(); + } + + private HairstyleCandidate withLengths(HairLength minimum, Set lengths) { + return new HairstyleCandidate(UUID.randomUUID(), "style", "CUT", UUID.randomUUID(), + "NONE", true, "1", Set.of(), lengths, Set.of(), Set.of(), Set.of(), Set.of(), + minimum, 0, ManagementDifficulty.LOW, ChemicalStressLevel.LOW, 1, "1", Map.of()); + } + @Test void coldStartUsesZeroPersonalizedScore() { RecommendationContext context = context(null, Set.of(), Set.of(), Set.of(), List.of());