Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/hairstyle-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 새 실행을 만들지만 같은 입력의 순위를 무작위로 섞지는 않는다.
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public ApiResponse<RecommendationResponse> generate(
}

@GetMapping("/recommendations/latest")
@Operation(summary = "최신 추천 조회")
@Operation(summary = "최신 추천 조회",
description = "기존 추천이 있으면 현재 프로필·시술기록·카탈로그·알고리즘 기준으로 검증하고 "
+ "변경된 결과는 자동 재생성합니다. 최초 추천이 없으면 404를 반환합니다.")
public ApiResponse<RecommendationResponse> latest(
@AuthenticationPrincipal UUID userId,
HttpServletRequest servletRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserStylePreference> preferences = preferenceRepositoryPort.findAllByUserId(userId);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -245,7 +257,7 @@ private String canonicalSnapshot(
List<HairstyleCandidate> 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('|')
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HairLength> 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());
Expand Down
Loading