diff --git a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java index db68eb7..ba2aae5 100644 --- a/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java +++ b/src/main/java/com/heddy/adapter/in/web/recommendation/dto/RecommendationResponse.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.heddy.domain.recommendation.model.RecommendationReason; +import com.heddy.domain.recommendation.model.RecommendationBasis; import com.heddy.domain.recommendation.model.RecommendationReference; import com.heddy.domain.recommendation.port.in.RecommendationResult; @@ -17,14 +18,64 @@ public record RecommendationResponse( String status, @JsonProperty("generated_at") Instant generatedAt, boolean fallback, + @JsonProperty("recommendation_basis") Basis recommendationBasis, List items ) { public static RecommendationResponse from(RecommendationResult result) { return new RecommendationResponse(result.recommendationRunId(), result.strategy().name(), result.status().name(), result.generatedAt(), result.fallback(), + Basis.from(result.recommendationBasis()), result.items().stream().map(Item::from).toList()); } + public record Basis( + @JsonProperty("treatment_history") TreatmentHistory treatmentHistory, + @JsonProperty("ar_candidate_style_count") int arCandidateStyleCount, + @JsonProperty("style_preferences") StylePreferences stylePreferences, + @JsonProperty("current_hair") CurrentHair currentHair, + @JsonProperty("available_care_time_minutes") Integer availableCareTimeMinutes + ) { + static Basis from(RecommendationBasis basis) { + if (basis == null) { + return null; + } + return new Basis( + new TreatmentHistory(basis.treatmentHistory().count(), + basis.treatmentHistory().highestSatisfaction()), + basis.arCandidateStyleCount(), + new StylePreferences(basis.stylePreferences().preferredCount(), + basis.stylePreferences().excludedCount()), + CurrentHair.from(basis.currentHair()), basis.availableCareTimeMinutes()); + } + } + + public record TreatmentHistory( + long count, + @JsonProperty("highest_satisfaction") Integer highestSatisfaction + ) { } + + public record StylePreferences( + @JsonProperty("preferred_count") int preferredCount, + @JsonProperty("excluded_count") int excludedCount + ) { } + + public record CurrentHair( + @JsonProperty("hair_type") String hairType, + @JsonProperty("hair_condition") String hairCondition, + @JsonProperty("hair_length") String hairLength, + @JsonProperty("hair_thickness") String hairThickness + ) { + static CurrentHair from(RecommendationBasis.CurrentHair hair) { + return hair == null ? null : new CurrentHair(name(hair.hairType()), + name(hair.hairCondition()), name(hair.hairLength()), + name(hair.hairThickness())); + } + + private static String name(Enum value) { + return value == null ? null : value.name(); + } + } + public record Item( int rank, BigDecimal score, @@ -41,7 +92,7 @@ static Item from(RecommendationResult.Item value) { return new Item(item.displayRank(), item.score(), new Hairstyle( value.hairstyle().hairstyleId(), value.hairstyle().styleName(), value.thumbnailUrl() == null ? null : value.thumbnailUrl().toString(), - value.hairstyle().assetVersion()), item.colorId(), + value.hairstyle().arMode(), value.hairstyle().assetVersion()), item.colorId(), item.managementDifficulty().name(), item.estimatedDailyCareMinutes(), item.reasons().stream().map(Reason::from).toList(), reference == null ? List.of() : List.of(ReferenceRecord.from(reference))); @@ -52,6 +103,7 @@ public record Hairstyle( @JsonProperty("hairstyle_id") UUID hairstyleId, @JsonProperty("style_name") String styleName, @JsonProperty("thumbnail_url") String thumbnailUrl, + @JsonProperty("ar_mode") String arMode, @JsonProperty("asset_version") String assetVersion ) { } diff --git a/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleAssetEntity.java b/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleAssetEntity.java index af222c0..986b9ca 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleAssetEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleAssetEntity.java @@ -24,6 +24,9 @@ class HairstyleAssetEntity extends BaseEntity { @Column(name = "thumbnail_file_id") private UUID thumbnailFileId; + @Column(name = "ar_mode", nullable = false, length = 30) + private String arMode; + @Column(nullable = false) private boolean active; @@ -36,6 +39,7 @@ protected HairstyleAssetEntity() { } String styleName() { return styleName; } String category() { return category; } UUID thumbnailFileId() { return thumbnailFileId; } + String arMode() { return arMode; } boolean active() { return active; } String assetVersion() { return assetVersion; } } diff --git a/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleCatalogPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleCatalogPersistenceAdapter.java index fa4dd7b..22c94ff 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleCatalogPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/hairstyle/HairstyleCatalogPersistenceAdapter.java @@ -66,7 +66,7 @@ private HairstyleCandidate toDomain( Map tags ) { return new HairstyleCandidate(asset.hairstyleId(), asset.styleName(), asset.category(), - asset.thumbnailFileId(), asset.active(), asset.assetVersion(), + asset.thumbnailFileId(), asset.arMode(), asset.active(), asset.assetVersion(), parse(profile.serviceTypes(), ServiceType.class), parse(profile.compatibleHairLengths(), HairLength.class), parse(profile.compatibleHairTypes(), HairType.class), diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java index 257f2f1..3b5b0d3 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationRunEntity.java @@ -1,6 +1,11 @@ package com.heddy.adapter.out.persistence.recommendation; import com.heddy.domain.recommendation.model.RecommendationRun; +import com.heddy.domain.recommendation.model.RecommendationBasis; +import com.heddy.domain.account.model.HairProfile.HairCondition; +import com.heddy.domain.account.model.HairProfile.HairLength; +import com.heddy.domain.account.model.HairProfile.HairThickness; +import com.heddy.domain.account.model.HairProfile.HairType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; @@ -46,7 +51,7 @@ protected RecommendationRunEntity() { } userId = run.userId(); strategy = run.strategy().name(); status = run.status().name(); - inputSnapshot = Map.of("fallback", run.fallback(), "canonical", canonicalSnapshot); + inputSnapshot = inputSnapshot(run, canonicalSnapshot); inputHash = run.inputHash(); generatedAt = run.generatedAt(); } @@ -54,8 +59,107 @@ protected RecommendationRunEntity() { } RecommendationRun toDomain(List items) { return new RecommendationRun(recommendationRunId, userId, RecommendationRun.Strategy.valueOf(strategy), RecommendationRun.Status.valueOf(status), - inputHash, Boolean.TRUE.equals(inputSnapshot.get("fallback")), generatedAt, items); + inputHash, Boolean.TRUE.equals(inputSnapshot.get("fallback")), + recommendationBasis(), generatedAt, items); } UUID recommendationRunId() { return recommendationRunId; } + + private static Map inputSnapshot( + RecommendationRun run, + String canonicalSnapshot + ) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("fallback", run.fallback()); + snapshot.put("canonical", canonicalSnapshot); + if (run.recommendationBasis() != null) { + snapshot.put("recommendation_basis", basisMap(run.recommendationBasis())); + } + return snapshot; + } + + private static Map basisMap(RecommendationBasis basis) { + Map treatmentHistory = new LinkedHashMap<>(); + treatmentHistory.put("count", basis.treatmentHistory().count()); + treatmentHistory.put("highest_satisfaction", basis.treatmentHistory().highestSatisfaction()); + + Map stylePreferences = new LinkedHashMap<>(); + stylePreferences.put("preferred_count", basis.stylePreferences().preferredCount()); + stylePreferences.put("excluded_count", basis.stylePreferences().excludedCount()); + + Map value = new LinkedHashMap<>(); + value.put("treatment_history", treatmentHistory); + value.put("ar_candidate_style_count", basis.arCandidateStyleCount()); + value.put("style_preferences", stylePreferences); + if (basis.currentHair() != null) { + Map currentHair = new LinkedHashMap<>(); + putEnum(currentHair, "hair_type", basis.currentHair().hairType()); + putEnum(currentHair, "hair_condition", basis.currentHair().hairCondition()); + putEnum(currentHair, "hair_length", basis.currentHair().hairLength()); + putEnum(currentHair, "hair_thickness", basis.currentHair().hairThickness()); + value.put("current_hair", currentHair); + } + value.put("available_care_time_minutes", basis.availableCareTimeMinutes()); + return value; + } + + private RecommendationBasis recommendationBasis() { + Object raw = inputSnapshot.get("recommendation_basis"); + if (!(raw instanceof Map basis)) { + return null; + } + Map treatmentHistory = map(basis.get("treatment_history")); + Map stylePreferences = map(basis.get("style_preferences")); + Map currentHair = map(basis.get("current_hair")); + RecommendationBasis.CurrentHair hair = currentHair.isEmpty() ? null + : new RecommendationBasis.CurrentHair( + enumValue(currentHair, "hair_type", HairType.class), + enumValue(currentHair, "hair_condition", HairCondition.class), + enumValue(currentHair, "hair_length", HairLength.class), + enumValue(currentHair, "hair_thickness", HairThickness.class)); + return new RecommendationBasis( + new RecommendationBasis.TreatmentHistory( + longValue(treatmentHistory, "count", 0), + integer(treatmentHistory, "highest_satisfaction")), + integer(basis, "ar_candidate_style_count", 0), + new RecommendationBasis.StylePreferences( + integer(stylePreferences, "preferred_count", 0), + integer(stylePreferences, "excluded_count", 0)), + hair, + integer(basis, "available_care_time_minutes")); + } + + private static void putEnum(Map target, String key, Enum value) { + if (value != null) { + target.put(key, value.name()); + } + } + + private static Map map(Object value) { + return value instanceof Map map ? map : Map.of(); + } + + private static Integer integer(Map source, String key) { + Object value = source.get(key); + return value instanceof Number number ? number.intValue() : null; + } + + private static int integer(Map source, String key, int fallback) { + Integer value = integer(source, key); + return value == null ? fallback : value; + } + + private static long longValue(Map source, String key, long fallback) { + Object value = source.get(key); + return value instanceof Number number ? number.longValue() : fallback; + } + + private static > E enumValue( + Map source, + String key, + Class type + ) { + Object value = source.get(key); + return value == null ? null : Enum.valueOf(type, String.valueOf(value)); + } } diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java index bbaae71..cdca986 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapter.java @@ -2,6 +2,7 @@ import com.heddy.domain.treatment.model.TreatmentPhoto; import com.heddy.domain.treatment.model.TreatmentRecord; +import com.heddy.domain.treatment.model.TreatmentHistorySummary; import com.heddy.domain.treatment.model.TreatmentRecordFilter; import com.heddy.domain.treatment.model.TreatmentRecordPage; import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; @@ -126,6 +127,13 @@ public List findRecentByUserId(UUID userId, int limit) { .toList(); } + @Override + public TreatmentHistorySummary summarizeByUserId(UUID userId) { + Short highest = recordRepository.findHighestSatisfactionByUserId(userId); + return new TreatmentHistorySummary(recordRepository.countByUserId(userId), + highest == null ? null : highest.intValue()); + } + private List photosOf(UUID recordId) { return photoRepository.findByRecordIdOrderBySortOrderAscCreatedAtAscPhotoIdAsc(recordId).stream() .map(TreatmentPhotoEntity::toDomain) diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java index fba53ec..a76ae59 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordJpaRepository.java @@ -23,6 +23,12 @@ interface TreatmentRecordJpaRepository extends JpaRepository findTop10ByUserIdOrderByPerformedAtDescRecordIdDesc(UUID userId); + long countByUserId(UUID userId); + + @Query("select max(record.satisfaction) from TreatmentRecordEntity record " + + "where record.userId = :userId") + Short findHighestSatisfactionByUserId(@Param("userId") UUID userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select record from TreatmentRecordEntity record where record.recordId = :recordId") java.util.Optional findByIdForUpdate(@Param("recordId") UUID recordId); 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 f41a250..aaf1ad1 100644 --- a/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java +++ b/src/main/java/com/heddy/application/recommendation/service/RecommendationService.java @@ -13,6 +13,7 @@ import com.heddy.domain.recommendation.exception.RecommendationException; import com.heddy.domain.recommendation.model.HairstyleCandidate; import com.heddy.domain.recommendation.model.RecommendationContext; +import com.heddy.domain.recommendation.model.RecommendationBasis; import com.heddy.domain.recommendation.model.RecommendationItem; import com.heddy.domain.recommendation.model.RecommendationReason; import com.heddy.domain.recommendation.model.RecommendationReference; @@ -30,6 +31,7 @@ import com.heddy.domain.style.port.out.SavedStyleRepositoryPort; import com.heddy.domain.style.port.out.UserStylePreferenceRepositoryPort; import com.heddy.domain.treatment.model.TreatmentRecord; +import com.heddy.domain.treatment.model.TreatmentHistorySummary; import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -128,16 +130,17 @@ public RecommendationResult generate(UUID userId, boolean forceRefresh) { Set excluded = preferenceIds(preferences, UserStylePreference.PreferenceType.EXCLUDED); Set saved = Set.copyOf(savedStyleRepositoryPort.findHairstyleIdsByUserId(userId)); List treatments = treatmentRepositoryPort.findRecentByUserId(userId, 10); + TreatmentHistorySummary treatmentHistory = treatmentRepositoryPort.summarizeByUserId(userId); List candidates = catalogRepositoryPort.findEligibleCandidates(); RecommendationContext context = new RecommendationContext( profile, preferred, excluded, saved, treatments, generatedAt); - String canonical = canonicalSnapshot(context, candidates); + String canonical = canonicalSnapshot(context, candidates, treatmentHistory); String inputHash = sha256(canonical); if (!forceRefresh) { RecommendationRun reusable = recommendationRepositoryPort.findActiveByInputHash( userId, RecommendationRun.Strategy.RULE_BASED_V1.name(), inputHash).orElse(null); - if (reusable != null) { + if (reusable != null && reusable.recommendationBasis() != null) { return render(reusable); } } @@ -152,9 +155,10 @@ public RecommendationResult generate(UUID userId, boolean forceRefresh) { UUID runId = UUID.randomUUID(); List items = java.util.stream.IntStream.range(0, selected.size()) .mapToObj(index -> toItem(selected.get(index), index + 1, records)).toList(); + RecommendationBasis basis = recommendationBasis(context, treatmentHistory, selected); RecommendationRun run = new RecommendationRun(runId, userId, RecommendationRun.Strategy.RULE_BASED_V1, RecommendationRun.Status.ACTIVE, - inputHash, context.coldStart(), generatedAt, items); + inputHash, context.coldStart(), basis, generatedAt, items); return render(recommendationRepositoryPort.insert(run, canonical)); } @@ -190,7 +194,27 @@ private RecommendationResult render(RecommendationRun run) { return new RecommendationResult.Item(item, hairstyle, url); }).toList(); return new RecommendationResult(run.recommendationRunId(), run.strategy(), run.status(), - run.generatedAt(), run.fallback(), items); + run.generatedAt(), run.fallback(), run.recommendationBasis(), items); + } + + private RecommendationBasis recommendationBasis( + RecommendationContext context, + TreatmentHistorySummary treatmentHistory, + List selected + ) { + HairProfile profile = context.hairProfile(); + RecommendationBasis.CurrentHair currentHair = profile == null ? null + : new RecommendationBasis.CurrentHair(profile.hairType(), profile.hairCondition(), + profile.hairLength(), profile.hairThickness()); + return new RecommendationBasis( + new RecommendationBasis.TreatmentHistory( + treatmentHistory.count(), treatmentHistory.highestSatisfaction()), + Math.toIntExact(selected.stream() + .filter(result -> result.candidate().supportsAr()).count()), + new RecommendationBasis.StylePreferences( + context.preferredTagIds().size(), context.excludedTagIds().size()), + currentHair, + profile == null ? null : profile.availableCareTimeMinutes()); } private RecommendationItem toItem( @@ -218,7 +242,8 @@ private Set preferenceIds( private String canonicalSnapshot( RecommendationContext context, - List candidates + List candidates, + TreatmentHistorySummary treatmentHistory ) { StringBuilder value = new StringBuilder("RULE_BASED_V1|"); HairProfile profile = context.hairProfile(); @@ -230,13 +255,16 @@ private String canonicalSnapshot( appendSorted(value, context.preferredTagIds()); appendSorted(value, context.excludedTagIds()); appendSorted(value, context.savedHairstyleIds()); + value.append('|').append(treatmentHistory.count()).append(':') + .append(treatmentHistory.highestSatisfaction()); context.recentTreatments().stream().sorted(Comparator.comparing(TreatmentRecord::recordId)) .forEach(record -> value.append('|').append(record.recordId()).append(':') .append(record.performedAt()).append(':').append(record.satisfaction()).append(':') .append(record.serviceTypes().stream().map(Enum::name).sorted().toList())); candidates.stream().sorted(Comparator.comparing(HairstyleCandidate::hairstyleId)) .forEach(candidate -> value.append('|').append(candidate.hairstyleId()).append(':') - .append(candidate.assetVersion()).append(':').append(candidate.metadataVersion())); + .append(candidate.assetVersion()).append(':').append(candidate.metadataVersion()) + .append(':').append(candidate.arMode())); return value.toString(); } diff --git a/src/main/java/com/heddy/domain/recommendation/model/HairstyleCandidate.java b/src/main/java/com/heddy/domain/recommendation/model/HairstyleCandidate.java index 6d69fd8..458f755 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/HairstyleCandidate.java +++ b/src/main/java/com/heddy/domain/recommendation/model/HairstyleCandidate.java @@ -18,6 +18,7 @@ public record HairstyleCandidate( String styleName, String category, UUID thumbnailFileId, + String arMode, boolean active, String assetVersion, Set serviceTypes, @@ -41,6 +42,7 @@ public enum ChemicalStressLevel { LOW, MEDIUM, HIGH } Objects.requireNonNull(hairstyleId, "hairstyleId"); Objects.requireNonNull(styleName, "styleName"); Objects.requireNonNull(category, "category"); + Objects.requireNonNull(arMode, "arMode"); Objects.requireNonNull(assetVersion, "assetVersion"); Objects.requireNonNull(managementDifficulty, "managementDifficulty"); Objects.requireNonNull(chemicalStressLevel, "chemicalStressLevel"); @@ -64,4 +66,8 @@ private static Set immutable(Set values) { public UUID representativeTagId() { return tags.keySet().stream().sorted().findFirst().orElse(null); } + + public boolean supportsAr() { + return !"NONE".equals(arMode); + } } diff --git a/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java b/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java new file mode 100644 index 0000000..7ba7d8c --- /dev/null +++ b/src/main/java/com/heddy/domain/recommendation/model/RecommendationBasis.java @@ -0,0 +1,26 @@ +package com.heddy.domain.recommendation.model; + +import com.heddy.domain.account.model.HairProfile.HairCondition; +import com.heddy.domain.account.model.HairProfile.HairLength; +import com.heddy.domain.account.model.HairProfile.HairThickness; +import com.heddy.domain.account.model.HairProfile.HairType; + +/** 추천 생성 시점에 사용한 사용자 입력과 결과 특성을 설명하는 스냅샷. */ +public record RecommendationBasis( + TreatmentHistory treatmentHistory, + int arCandidateStyleCount, + StylePreferences stylePreferences, + CurrentHair currentHair, + Integer availableCareTimeMinutes +) { + public record TreatmentHistory(long count, Integer highestSatisfaction) { } + + public record StylePreferences(int preferredCount, int excludedCount) { } + + public record CurrentHair( + HairType hairType, + HairCondition hairCondition, + HairLength hairLength, + HairThickness hairThickness + ) { } +} diff --git a/src/main/java/com/heddy/domain/recommendation/model/RecommendationRun.java b/src/main/java/com/heddy/domain/recommendation/model/RecommendationRun.java index 2a72c21..990a8d3 100644 --- a/src/main/java/com/heddy/domain/recommendation/model/RecommendationRun.java +++ b/src/main/java/com/heddy/domain/recommendation/model/RecommendationRun.java @@ -11,6 +11,7 @@ public record RecommendationRun( Status status, String inputHash, boolean fallback, + RecommendationBasis recommendationBasis, Instant generatedAt, List items ) { @@ -23,6 +24,6 @@ public enum Status { ACTIVE, STALE } public RecommendationRun markStale() { return new RecommendationRun(recommendationRunId, userId, strategy, Status.STALE, - inputHash, fallback, generatedAt, items); + inputHash, fallback, recommendationBasis, generatedAt, items); } } diff --git a/src/main/java/com/heddy/domain/recommendation/port/in/RecommendationResult.java b/src/main/java/com/heddy/domain/recommendation/port/in/RecommendationResult.java index 1ff2020..39b6d9e 100644 --- a/src/main/java/com/heddy/domain/recommendation/port/in/RecommendationResult.java +++ b/src/main/java/com/heddy/domain/recommendation/port/in/RecommendationResult.java @@ -2,6 +2,7 @@ import com.heddy.domain.recommendation.model.HairstyleCandidate; import com.heddy.domain.recommendation.model.RecommendationItem; +import com.heddy.domain.recommendation.model.RecommendationBasis; import com.heddy.domain.recommendation.model.RecommendationRun; import java.net.URI; @@ -15,6 +16,7 @@ public record RecommendationResult( RecommendationRun.Status status, Instant generatedAt, boolean fallback, + RecommendationBasis recommendationBasis, List items ) { public RecommendationResult { diff --git a/src/main/java/com/heddy/domain/treatment/model/TreatmentHistorySummary.java b/src/main/java/com/heddy/domain/treatment/model/TreatmentHistorySummary.java new file mode 100644 index 0000000..5e3c79a --- /dev/null +++ b/src/main/java/com/heddy/domain/treatment/model/TreatmentHistorySummary.java @@ -0,0 +1,4 @@ +package com.heddy.domain.treatment.model; + +/** 사용자의 전체 시술 이력 건수와 입력된 만족도 중 최고값. */ +public record TreatmentHistorySummary(long count, Integer highestSatisfaction) { } diff --git a/src/main/java/com/heddy/domain/treatment/port/out/TreatmentRecordRepositoryPort.java b/src/main/java/com/heddy/domain/treatment/port/out/TreatmentRecordRepositoryPort.java index 69a24c7..1e27857 100644 --- a/src/main/java/com/heddy/domain/treatment/port/out/TreatmentRecordRepositoryPort.java +++ b/src/main/java/com/heddy/domain/treatment/port/out/TreatmentRecordRepositoryPort.java @@ -4,6 +4,7 @@ import com.heddy.domain.treatment.model.TreatmentRecord; import com.heddy.domain.treatment.model.TreatmentRecordFilter; import com.heddy.domain.treatment.model.TreatmentRecordPage; +import com.heddy.domain.treatment.model.TreatmentHistorySummary; import java.util.Optional; import java.util.List; @@ -57,4 +58,7 @@ public interface TreatmentRecordRepositoryPort { /** 추천 계산용 최근 기록. 사진은 적재하지 않고 점수 계산기가 만족도 없는 기록을 제외한다. */ List findRecentByUserId(UUID userId, int limit); + + /** 추천 근거 표시에 사용할 사용자의 전체 시술 건수와 최고 만족도. */ + TreatmentHistorySummary summarizeByUserId(UUID userId); } 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 483cae4..3c61c85 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 @@ -1,6 +1,7 @@ package com.heddy.adapter.in.web.recommendation; import com.heddy.support.PostgresIntegrationTest; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -48,6 +49,7 @@ AwsCredentialsProvider offlineAwsCredentialsProvider() { @Autowired MockMvc mockMvc; @Autowired JdbcTemplate jdbcTemplate; + @Autowired EntityManager entityManager; private UUID treatmentRecordId; @@ -71,9 +73,12 @@ INSERT INTO hair_profiles (hair_profile_id, user_id, hair_type, hair_condition, (record_id, user_id, service_types, performed_at, satisfaction) VALUES (?, ?, '["CUT"]'::jsonb, now() - interval '1 day', 5) """, treatmentRecordId, USER_ID); - insertStyle("94000000-0000-4000-8000-000000000001", "레이어드 C컬", "MEDIUM", 30, true); - insertStyle("94000000-0000-4000-8000-000000000002", "내추럴 보브", "BOB", 20, false); - insertStyle("94000000-0000-4000-8000-000000000003", "소프트 웨이브", "LONG", 10, false); + insertStyle("94000000-0000-4000-8000-000000000001", "레이어드 C컬", + "MEDIUM", "OVERLAY", 30, true); + insertStyle("94000000-0000-4000-8000-000000000002", "내추럴 보브", + "BOB", "OVERLAY", 20, false); + insertStyle("94000000-0000-4000-8000-000000000003", "소프트 웨이브", + "LONG", "NONE", 10, false); } @Test @@ -85,9 +90,28 @@ void generatesReusesAndProtectsRuleBasedRecommendation() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.strategy").value("RULE_BASED_V1")) .andExpect(jsonPath("$.data.fallback").value(false)) + .andExpect(jsonPath("$.data.recommendation_basis.treatment_history.count").value(1)) + .andExpect(jsonPath("$.data.recommendation_basis.treatment_history" + + ".highest_satisfaction").value(5)) + .andExpect(jsonPath("$.data.recommendation_basis.ar_candidate_style_count").value(2)) + .andExpect(jsonPath("$.data.recommendation_basis.style_preferences" + + ".preferred_count").value(1)) + .andExpect(jsonPath("$.data.recommendation_basis.style_preferences" + + ".excluded_count").value(0)) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_type") + .value("WAVY")) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_condition") + .value("HEALTHY")) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_length") + .value("BELOW_SHOULDER")) + .andExpect(jsonPath("$.data.recommendation_basis.current_hair.hair_thickness") + .value("NORMAL")) + .andExpect(jsonPath("$.data.recommendation_basis" + + ".available_care_time_minutes").value(15)) .andExpect(jsonPath("$.data.items.length()").value(3)) .andExpect(jsonPath("$.data.items[0].hairstyle.style_name").value("레이어드 C컬")) .andExpect(jsonPath("$.data.items[0].hairstyle.thumbnail_url").isNotEmpty()) + .andExpect(jsonPath("$.data.items[0].hairstyle.ar_mode").value("OVERLAY")) .andExpect(jsonPath("$.data.items[0].reasons[*].code") .value(org.hamcrest.Matchers.hasItem("PREFERRED_TAG_MATCH"))) .andReturn().getResponse().getContentAsString(); @@ -109,6 +133,18 @@ void generatesReusesAndProtectsRuleBasedRecommendation() throws Exception { "SELECT count(*) FROM recommendation_items WHERE recommendation_run_id = ?", Integer.class, UUID.fromString(runId))).isEqualTo(3); + jdbcTemplate.update("UPDATE hair_profiles SET available_care_time_minutes = 3 WHERE user_id = ?", + USER_ID); + jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID); + + mockMvc.perform(get("/recommendations/{id}", runId) + .with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis.style_preferences" + + ".preferred_count").value(1)) + .andExpect(jsonPath("$.data.recommendation_basis" + + ".available_care_time_minutes").value(15)); + mockMvc.perform(get("/recommendations/{id}", runId) .with(authentication(auth(OTHER_USER_ID)))) .andExpect(status().isNotFound()); @@ -131,8 +167,49 @@ void deletingReferencedTreatmentMarksRunStale() throws Exception { .andExpect(jsonPath("$.data.status").value("STALE")); } + @Test + void legacyRunWithoutRecommendationBasisIsReadableButNotReusable() throws Exception { + String firstResponse = mockMvc.perform(post("/recommendations/generate") + .with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String firstRunId = new com.fasterxml.jackson.databind.ObjectMapper().readTree(firstResponse) + .path("data").path("recommendation_run_id").asText(); + + jdbcTemplate.update(""" + UPDATE recommendation_runs + SET input_snapshot_json = input_snapshot_json - 'recommendation_basis' + WHERE recommendation_run_id = ? + """, UUID.fromString(firstRunId)); + entityManager.clear(); + + mockMvc.perform(get("/recommendations/{id}", firstRunId) + .with(authentication(auth(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis").doesNotExist()); + + String regeneratedResponse = mockMvc.perform(post("/recommendations/generate") + .with(authentication(auth(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recommendation_basis").exists()) + .andReturn().getResponse().getContentAsString(); + String regeneratedRunId = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(regeneratedResponse).path("data").path("recommendation_run_id").asText(); + + assertThat(regeneratedRunId).isNotEqualTo(firstRunId); + } + private void insertStyle( - String hairstyleIdText, String name, String category, int priority, boolean tagged + String hairstyleIdText, + String name, + String category, + String arMode, + int priority, + boolean tagged ) { UUID hairstyleId = UUID.fromString(hairstyleIdText); UUID fileId = UUID.randomUUID(); @@ -145,8 +222,8 @@ INSERT INTO files (file_id, upload_id, user_id, owner_type, purpose, status, obj jdbcTemplate.update(""" INSERT INTO hairstyle_assets (hairstyle_id, style_name, category, thumbnail_file_id, ar_mode, active, asset_version) - VALUES (?, ?, ?, ?, 'NONE', true, '1.0.0') - """, hairstyleId, name, category, fileId); + VALUES (?, ?, ?, ?, ?, true, '1.0.0') + """, hairstyleId, name, category, fileId, arMode); jdbcTemplate.update(""" INSERT INTO hairstyle_recommendation_profiles ( hairstyle_id, service_types, compatible_hair_lengths, diff --git a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java index 385ceeb..bafd311 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RecommendationDiversifierTest.java @@ -39,7 +39,7 @@ void relaxesLimitsWhenCandidatesAreInsufficientAndIsDeterministic() { private ScoredRecommendation item(String category, UUID tagId, double score) { HairstyleCandidate candidate = new HairstyleCandidate(UUID.randomUUID(), "style", category, - UUID.randomUUID(), true, "1", Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), + UUID.randomUUID(), "NONE", true, "1", Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), Set.of(), null, 0, ManagementDifficulty.LOW, ChemicalStressLevel.LOW, 0, "1", Map.of(tagId, "tag")); ScoreBreakdown breakdown = new ScoreBreakdown(0, 0, 0, 0, 0, 0, 100, score); 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 0edbba3..078f1cd 100644 --- a/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java +++ b/src/test/java/com/heddy/domain/recommendation/service/RuleBasedV1ScorerTest.java @@ -98,7 +98,7 @@ void excludedTagMinimumLengthAndContraindicationAreHardFilters() { context(null, Set.of(), Set.of(excluded), Set.of(), List.of()))).isEmpty(); HairstyleCandidate longOnly = new HairstyleCandidate(tagged.hairstyleId(), "style", "CUT", - UUID.randomUUID(), true, "1", Set.of(), Set.of(HairLength.BELOW_CHEST), + UUID.randomUUID(), "NONE", true, "1", Set.of(), Set.of(HairLength.BELOW_CHEST), Set.of(HairType.WAVY), Set.of(HairThickness.NORMAL), Set.of(HairCondition.HEALTHY), Set.of(HairCondition.DAMAGED), HairLength.BELOW_CHEST, 10, ManagementDifficulty.MEDIUM, ChemicalStressLevel.LOW, 1, "1", Map.of()); @@ -130,7 +130,7 @@ private RecommendationContext context( private HairstyleCandidate candidate(Set services, Map tags) { return new HairstyleCandidate(UUID.randomUUID(), "레이어드 C컬", "MEDIUM", - UUID.randomUUID(), true, "1.0.0", services, + UUID.randomUUID(), "NONE", true, "1.0.0", services, Set.of(HairLength.BELOW_SHOULDER), Set.of(HairType.WAVY), Set.of(HairThickness.NORMAL), Set.of(HairCondition.HEALTHY), Set.of(), null, 10, ManagementDifficulty.MEDIUM, ChemicalStressLevel.LOW, 10, "1", tags);