diff --git a/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisResponse.java b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisResponse.java index 806c062..137b076 100644 --- a/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisResponse.java +++ b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisResponse.java @@ -17,6 +17,10 @@ public record AnalysisResponse( @Schema(description = "분석 결과 식별자") @JsonProperty("analysis_id") UUID analysisId, + @JsonProperty("record_id") UUID recordId, + + @JsonProperty("photo_id") UUID photoId, + @Schema(description = "결과를 낸 분석 작업 식별자") @JsonProperty("job_id") UUID jobId, @@ -35,7 +39,7 @@ public record AnalysisResponse( @JsonProperty("model_version") String modelVersion, @Schema(description = "결과 요약 문장. 없을 수 있다") - String summary, + @JsonProperty("summary_comment") String summary, @Schema(description = "분석이 끝난 시각") @JsonProperty("analyzed_at") Instant analyzedAt, @@ -77,7 +81,8 @@ public record Overlay( public static AnalysisResponse from(GetLatestAnalysisUseCase.Result result) { var analysis = result.analysis(); return new AnalysisResponse( - analysis.analysisId(), analysis.jobId(), result.status().name(), + analysis.analysisId(), analysis.recordId(), analysis.photoId(), analysis.jobId(), + result.status().name(), metrics(result), confidence(result), analysis.modelVersion(), analysis.summary(), analysis.analyzedAt(), overlays(result.overlays())); } diff --git a/src/main/java/com/heddy/adapter/in/web/treatment/dto/CreateTreatmentRecordRequest.java b/src/main/java/com/heddy/adapter/in/web/treatment/dto/CreateTreatmentRecordRequest.java index c4caffe..552db68 100644 --- a/src/main/java/com/heddy/adapter/in/web/treatment/dto/CreateTreatmentRecordRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/treatment/dto/CreateTreatmentRecordRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.Instant; import java.util.List; @@ -27,6 +28,9 @@ public record CreateTreatmentRecordRequest( @Schema(description = "시술일시(ISO-8601). 미래일 수 없다") @JsonProperty("performed_at") Instant performedAt, + @Schema(description = "입력 기준 IANA 시간대", example = "Asia/Seoul") + @JsonProperty("timezone") String timezone, + @Schema(description = "미용실 이름. 선택 입력, 최대 50자") @JsonProperty("salon_name") String salonName, @@ -43,6 +47,25 @@ public record CreateTreatmentRecordRequest( + "적는다", example = "애쉬브라운 전체 염색") @JsonProperty("treatment_content") String treatmentContent, + @Size(max = 20) + @Schema(description = "커트 길이") + @JsonProperty("cut_length") String cutLength, + + @Size(max = 20) + @Schema(description = "커트 형태", example = "LAYERED") + @JsonProperty("cut_shape") String cutShape, + + @Size(max = 20) + @Schema(description = "펌 종류") + @JsonProperty("perm_type") String permType, + + @Size(max = 30) + @Schema(description = "염색 색상명", example = "애쉬 브라운") + @JsonProperty("color_name") String colorName, + + @Schema(description = "사용 제품 또는 약제 목록") + @JsonProperty("products") List<@NotNull String> products, + @Schema(description = "가격 금액. 선택 입력", example = "35000") @JsonProperty("price_amount") Long priceAmount, @@ -85,7 +108,8 @@ public PhotoRequest(UUID fileId, ImageType imageType) { public CreateTreatmentRecordUseCase.Command toCommand(UUID userId) { return new CreateTreatmentRecordUseCase.Command(userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - memo, nextVisitCautions, durationMinutes, treatmentContent, + memo, nextVisitCautions, durationMinutes, treatmentContent, timezone, + cutLength, cutShape, permType, colorName, products, photos == null ? List.of() : java.util.stream.IntStream.range(0, photos.size()) .mapToObj(index -> { PhotoRequest photo = photos.get(index); diff --git a/src/main/java/com/heddy/adapter/in/web/treatment/dto/TreatmentRecordResponse.java b/src/main/java/com/heddy/adapter/in/web/treatment/dto/TreatmentRecordResponse.java index 210a1e3..3fb85e5 100644 --- a/src/main/java/com/heddy/adapter/in/web/treatment/dto/TreatmentRecordResponse.java +++ b/src/main/java/com/heddy/adapter/in/web/treatment/dto/TreatmentRecordResponse.java @@ -32,6 +32,19 @@ public record TreatmentRecordResponse( @Schema(description = "시술일시") @JsonProperty("performed_at") Instant performedAt, + @Schema(description = "입력 기준 IANA 시간대") + String timezone, + + @JsonProperty("cut_length") String cutLength, + + @JsonProperty("cut_shape") String cutShape, + + @JsonProperty("perm_type") String permType, + + @JsonProperty("color_name") String colorName, + + List products, + @Schema(description = "만족도(1~5). 입력하지 않았으면 비어 있다") @JsonProperty("satisfaction") Integer satisfaction, @@ -139,7 +152,8 @@ public static TreatmentRecordResponse withPhotos(TreatmentRecord record, Map photos) { return new TreatmentRecordResponse( record.recordId(), record.serviceTypes(), record.salonName(), record.designerName(), - record.performedAt(), record.satisfaction(), + record.performedAt(), record.timezone(), record.cutLength(), record.cutShape(), + record.permType(), record.colorName(), record.products(), record.satisfaction(), record.priceAmount() == null ? null : new Price(record.priceAmount(), record.priceCurrency()), record.appointmentId(), record.memo(), record.nextVisitCautions(), diff --git a/src/main/java/com/heddy/adapter/in/web/treatment/dto/UpdateTreatmentRecordRequest.java b/src/main/java/com/heddy/adapter/in/web/treatment/dto/UpdateTreatmentRecordRequest.java index b9f61ae..418227d 100644 --- a/src/main/java/com/heddy/adapter/in/web/treatment/dto/UpdateTreatmentRecordRequest.java +++ b/src/main/java/com/heddy/adapter/in/web/treatment/dto/UpdateTreatmentRecordRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; @@ -53,6 +54,22 @@ public class UpdateTreatmentRecordRequest { private boolean durationMinutesPresent; private String treatmentContent; private boolean treatmentContentPresent; + private String timezone; + private boolean timezonePresent; + @Size(max = 20) + private String cutLength; + private boolean cutLengthPresent; + @Size(max = 20) + private String cutShape; + private boolean cutShapePresent; + @Size(max = 20) + private String permType; + private boolean permTypePresent; + @Size(max = 30) + private String colorName; + private boolean colorNamePresent; + private List<@NotNull String> products; + private boolean productsPresent; private final List unknownFields = new ArrayList<>(); @@ -149,6 +166,42 @@ public void setTreatmentContent(String treatmentContent) { treatmentContentPresent = true; } + @JsonSetter("timezone") + public void setTimezone(String timezone) { + this.timezone = timezone; + timezonePresent = true; + } + + @JsonSetter("cut_length") + public void setCutLength(String cutLength) { + this.cutLength = cutLength; + cutLengthPresent = true; + } + + @JsonSetter("cut_shape") + public void setCutShape(String cutShape) { + this.cutShape = cutShape; + cutShapePresent = true; + } + + @JsonSetter("perm_type") + public void setPermType(String permType) { + this.permType = permType; + permTypePresent = true; + } + + @JsonSetter("color_name") + public void setColorName(String colorName) { + this.colorName = colorName; + colorNamePresent = true; + } + + @JsonSetter("products") + public void setProducts(List products) { + this.products = products; + productsPresent = true; + } + public UpdateTreatmentRecordUseCase.Command toCommand(UUID requesterId, UUID recordId) { return new UpdateTreatmentRecordUseCase.Command( requesterId, recordId, @@ -163,7 +216,13 @@ public UpdateTreatmentRecordUseCase.Command toCommand(UUID requesterId, UUID rec patch(memoPresent, memo), patch(nextVisitCautionsPresent, nextVisitCautions), patch(durationMinutesPresent, durationMinutes), - patch(treatmentContentPresent, treatmentContent)); + patch(treatmentContentPresent, treatmentContent), + patch(timezonePresent, timezone), + patch(cutLengthPresent, cutLength), + patch(cutShapePresent, cutShape), + patch(permTypePresent, permType), + patch(colorNamePresent, colorName), + patch(productsPresent, products)); } private UpdateTreatmentRecordUseCase.Patch patch(boolean present, T value) { diff --git a/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisJobEntity.java b/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisJobEntity.java index c6d4838..a775987 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisJobEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisJobEntity.java @@ -42,12 +42,12 @@ class AnalysisJobEntity extends BaseEntity { private short progress; @Column(name = "attempt_count", nullable = false, updatable = false) - private short attemptCount; + private int attemptCount; @Column(name = "failure_code", length = 50) private String failureCode; - @Column(name = "failure_message", length = 500) + @Column(name = "failure_message", columnDefinition = "text") private String failureMessage; @Column(name = "started_at") @@ -64,7 +64,7 @@ protected AnalysisJobEntity() { userId = job.userId(); recordId = job.recordId(); photoId = job.photoId(); - attemptCount = (short) job.attemptCount(); + attemptCount = job.attemptCount(); apply(job); } diff --git a/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultEntity.java b/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultEntity.java index 28034c1..ec109f5 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultEntity.java @@ -79,11 +79,11 @@ class AnalysisResultEntity extends BaseEntity { @Column(name = "model_version", nullable = false, length = 50) private String modelVersion; - @Column(name = "summary", length = 500) + @Column(name = "summary_comment", length = 500) private String summary; @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "evidence") + @Column(name = "evidence_json") private String evidence; @Column(name = "analyzed_at", nullable = false) 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..aa309d9 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 @@ -15,10 +15,10 @@ class HairstyleAssetEntity extends BaseEntity { @Column(name = "hairstyle_id", nullable = false, updatable = false) private UUID hairstyleId; - @Column(name = "style_name", nullable = false, length = 100) + @Column(name = "style_name", nullable = false, length = 30) private String styleName; - @Column(nullable = false, length = 50) + @Column(nullable = false, length = 20) private String category; @Column(name = "thumbnail_file_id") @@ -27,7 +27,7 @@ class HairstyleAssetEntity extends BaseEntity { @Column(nullable = false) private boolean active; - @Column(name = "asset_version", nullable = false, length = 30) + @Column(name = "asset_version", nullable = false, length = 20) private String assetVersion; protected HairstyleAssetEntity() { } diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java index 8e71658..9ec004a 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationItemEntity.java @@ -30,7 +30,7 @@ class RecommendationItemEntity { @Column(name = "color_id", updatable = false) private UUID colorId; @Column(name = "display_rank", nullable = false, updatable = false) - private int displayRank; + private short displayRank; @Column(nullable = false, precision = 5, scale = 2, updatable = false) private BigDecimal score; @JdbcTypeCode(SqlTypes.JSON) @@ -39,7 +39,7 @@ class RecommendationItemEntity { @JdbcTypeCode(SqlTypes.JSON) @Column(name = "reasons_json", nullable = false, columnDefinition = "jsonb", updatable = false) private List> reasons = List.of(); - @Column(name = "management_difficulty", nullable = false, length = 20, updatable = false) + @Column(name = "management_difficulty", nullable = false, length = 10, updatable = false) private String managementDifficulty; @Column(name = "estimated_daily_care_minutes", nullable = false, updatable = false) private int estimatedDailyCareMinutes; @@ -51,7 +51,7 @@ protected RecommendationItemEntity() { } recommendationRunId = runId; hairstyleId = item.hairstyleId(); colorId = item.colorId(); - displayRank = item.displayRank(); + displayRank = (short) item.displayRank(); score = item.score(); scoreBreakdown = breakdownMap(item.scoreBreakdown()); reasons = item.reasons().stream().map(reason -> Map.of( diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java index 99a44a0..94d1728 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceEntity.java @@ -18,7 +18,7 @@ class RecommendationReferenceEntity { private UUID recommendationItemId; @Id @Column(name = "record_id", nullable = false, updatable = false) private UUID recordId; - @Column(name = "reference_reason_code", nullable = false, length = 60, updatable = false) + @Column(name = "reference_reason", nullable = false, length = 255, updatable = false) private String referenceReasonCode; protected RecommendationReferenceEntity() { } diff --git a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java index 06503ed..e9124df 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java +++ b/src/main/java/com/heddy/adapter/out/persistence/recommendation/RecommendationReferenceQueryRepository.java @@ -22,7 +22,7 @@ Map findByItemIds(Collection itemIds) { Map result = new LinkedHashMap<>(); jdbcTemplate.query(""" SELECT reference.recommendation_item_id, reference.record_id, - reference.reference_reason_code, record.performed_at, record.satisfaction + reference.reference_reason, record.performed_at, record.satisfaction FROM recommendation_reference_records reference JOIN treatment_records record ON record.record_id = reference.record_id WHERE reference.recommendation_item_id IN (:ids) @@ -32,7 +32,7 @@ WHERE reference.recommendation_item_id IN (:ids) new RecommendationReference(rows.getObject("record_id", UUID.class), rows.getTimestamp("performed_at").toInstant(), rows.getObject("satisfaction", Integer.class), - rows.getString("reference_reason_code")))); + rows.getString("reference_reason")))); return result; } } diff --git a/src/main/java/com/heddy/adapter/out/persistence/style/SavedStyleEntity.java b/src/main/java/com/heddy/adapter/out/persistence/style/SavedStyleEntity.java index 7f0c588..d276d64 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/style/SavedStyleEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/style/SavedStyleEntity.java @@ -38,7 +38,7 @@ class SavedStyleEntity extends BaseEntity { @Column(name = "capture_id", updatable = false) private UUID captureId; - @Column(length = 500) + @Column(columnDefinition = "text") private String memo; protected SavedStyleEntity() { diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPhotoEntity.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPhotoEntity.java index 89b7d0c..b7c937f 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPhotoEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentPhotoEntity.java @@ -33,11 +33,11 @@ class TreatmentPhotoEntity extends BaseEntity { private UUID fileId; @Enumerated(EnumType.STRING) - @Column(name = "image_type", nullable = false, length = 20) + @Column(name = "image_type", nullable = false, length = 10) private ImageType imageType; @Column(name = "sort_order", nullable = false) - private int sortOrder; + private short sortOrder; protected TreatmentPhotoEntity() { } @@ -47,7 +47,7 @@ protected TreatmentPhotoEntity() { recordId = photo.recordId(); fileId = photo.fileId(); imageType = photo.imageType(); - sortOrder = photo.sortOrder(); + sortOrder = (short) photo.sortOrder(); } /** 페이지 조립이 사진을 기록별로 모을 때 쓴다(#66). */ @@ -63,6 +63,6 @@ TreatmentPhoto toDomain() { void update(TreatmentPhoto photo) { fileId = photo.fileId(); imageType = photo.imageType(); - sortOrder = photo.sortOrder(); + sortOrder = (short) photo.sortOrder(); } } diff --git a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordEntity.java b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordEntity.java index e7c66ea..83c13e7 100644 --- a/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordEntity.java +++ b/src/main/java/com/heddy/adapter/out/persistence/treatment/TreatmentRecordEntity.java @@ -15,6 +15,7 @@ import java.time.Instant; import java.util.LinkedHashSet; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; @@ -70,11 +71,30 @@ class TreatmentRecordEntity extends BaseEntity { private String nextVisitCautions; @Column(name = "duration_minutes") - private Short durationMinutes; + private Integer durationMinutes; @Column(name = "treatment_content", length = 255) private String treatmentContent; + @Column(name = "timezone", nullable = false, length = 50) + private String timezone; + + @Column(name = "cut_length", length = 20) + private String cutLength; + + @Column(name = "cut_shape", length = 20) + private String cutShape; + + @Column(name = "perm_type", length = 20) + private String permType; + + @Column(name = "color_name", length = 30) + private String colorName; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "products") + private List products; + protected TreatmentRecordEntity() { } @@ -93,9 +113,14 @@ protected TreatmentRecordEntity() { appointmentId = record.appointmentId(); memo = record.memo(); nextVisitCautions = record.nextVisitCautions(); - durationMinutes = record.durationMinutes() == null - ? null : record.durationMinutes().shortValue(); + durationMinutes = record.durationMinutes(); treatmentContent = record.treatmentContent(); + timezone = record.timezone(); + cutLength = record.cutLength(); + cutShape = record.cutShape(); + permType = record.permType(); + colorName = record.colorName(); + products = record.products() == null ? null : new ArrayList<>(record.products()); } TreatmentRecord toDomain(List photos) { @@ -106,7 +131,8 @@ TreatmentRecord toDomain(List photos) { recordId, userId, parsedServiceTypes, salonName, designerName, performedAt, satisfaction == null ? null : satisfaction.intValue(), priceAmount, priceCurrency, appointmentId, memo, nextVisitCautions, - durationMinutes == null ? null : durationMinutes.intValue(), treatmentContent, + durationMinutes, treatmentContent, timezone, cutLength, cutShape, + permType, colorName, products, photos, getCreatedAt()); } @@ -127,9 +153,14 @@ void update(TreatmentRecord record) { appointmentId = record.appointmentId(); memo = record.memo(); nextVisitCautions = record.nextVisitCautions(); - durationMinutes = record.durationMinutes() == null - ? null : record.durationMinutes().shortValue(); + durationMinutes = record.durationMinutes(); treatmentContent = record.treatmentContent(); + timezone = record.timezone(); + cutLength = record.cutLength(); + cutShape = record.cutShape(); + permType = record.permType(); + colorName = record.colorName(); + products = record.products() == null ? null : new ArrayList<>(record.products()); } private static ServiceType parseServiceType(String name) { diff --git a/src/main/java/com/heddy/application/treatment/service/TreatmentRecordService.java b/src/main/java/com/heddy/application/treatment/service/TreatmentRecordService.java index 69cb546..51b0a58 100644 --- a/src/main/java/com/heddy/application/treatment/service/TreatmentRecordService.java +++ b/src/main/java/com/heddy/application/treatment/service/TreatmentRecordService.java @@ -126,7 +126,8 @@ public TreatmentRecord create(CreateTreatmentRecordUseCase.Command command) { command.performedAt(), command.satisfaction(), command.priceAmount(), command.priceCurrency(), command.appointmentId(), command.memo(), command.nextVisitCautions(), command.durationMinutes(), - command.treatmentContent()); + command.treatmentContent(), command.timezone(), command.cutLength(), + command.cutShape(), command.permType(), command.colorName(), command.products()); for (CreateTreatmentRecordUseCase.Command.Photo photo : command.photos()) { requireOwnedReadyFile(command.userId(), photo.fileId()); record = record.attachPhoto( @@ -202,7 +203,13 @@ public TreatmentRecord update(UpdateTreatmentRecordUseCase.Command command) { command.memo().orElse(current.memo()), command.nextVisitCautions().orElse(current.nextVisitCautions()), command.durationMinutes().orElse(current.durationMinutes()), - command.treatmentContent().orElse(current.treatmentContent())); + command.treatmentContent().orElse(current.treatmentContent()), + command.timezone().orElse(current.timezone()), + command.cutLength().orElse(current.cutLength()), + command.cutShape().orElse(current.cutShape()), + command.permType().orElse(current.permType()), + command.colorName().orElse(current.colorName()), + command.products().orElse(current.products())); return recordRepositoryPort.update(updated) .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); } diff --git a/src/main/java/com/heddy/domain/treatment/exception/TreatmentError.java b/src/main/java/com/heddy/domain/treatment/exception/TreatmentError.java index 8f36dc2..bf3eb17 100644 --- a/src/main/java/com/heddy/domain/treatment/exception/TreatmentError.java +++ b/src/main/java/com/heddy/domain/treatment/exception/TreatmentError.java @@ -14,11 +14,17 @@ public enum TreatmentError { PHOTO_LIMIT_EXCEEDED("TREATMENT_PHOTO_LIMIT_EXCEEDED", "사진은 기록당 최대 10장까지 등록할 수 있습니다."), PHOTO_RECORD_MISMATCH("TREATMENT_PHOTO_RECORD_MISMATCH", "다른 기록에 속한 사진은 등록할 수 없습니다."), PHOTO_SORT_ORDER_NEGATIVE("TREATMENT_PHOTO_SORT_ORDER_NEGATIVE", "사진 순서는 0 이상이어야 합니다."), + PHOTO_SORT_ORDER_TOO_LARGE( + "TREATMENT_PHOTO_SORT_ORDER_TOO_LARGE", "사진 순서는 32767 이하여야 합니다."), PHOTO_COMPARISON_NOT_AVAILABLE("PHOTO_COMPARISON_NOT_AVAILABLE", "시술 전후 사진이 모두 있어야 비교할 수 있습니다."), DURATION_MINUTES_NEGATIVE( "TREATMENT_DURATION_MINUTES_NEGATIVE", "소요 시간은 0분 이상이어야 합니다."), TREATMENT_CONTENT_TOO_LONG( - "TREATMENT_CONTENT_TOO_LONG", "시술 내용은 255자를 넘을 수 없습니다."); + "TREATMENT_CONTENT_TOO_LONG", "시술 내용은 255자를 넘을 수 없습니다."), + TIMEZONE_INVALID( + "TREATMENT_TIMEZONE_INVALID", "유효한 IANA 시간대를 입력해야 합니다."), + DETAIL_TOO_LONG( + "TREATMENT_DETAIL_TOO_LONG", "시술 상세 항목이 허용 길이를 초과했습니다."); private final String code; private final String message; diff --git a/src/main/java/com/heddy/domain/treatment/model/TreatmentPhoto.java b/src/main/java/com/heddy/domain/treatment/model/TreatmentPhoto.java index 9ad2678..15263fd 100644 --- a/src/main/java/com/heddy/domain/treatment/model/TreatmentPhoto.java +++ b/src/main/java/com/heddy/domain/treatment/model/TreatmentPhoto.java @@ -29,6 +29,9 @@ public record TreatmentPhoto( if (sortOrder < 0) { throw new TreatmentException(TreatmentError.PHOTO_SORT_ORDER_NEGATIVE); } + if (sortOrder > Short.MAX_VALUE) { + throw new TreatmentException(TreatmentError.PHOTO_SORT_ORDER_TOO_LARGE); + } } /** 새 사진을 만든다. 식별자는 도메인이 발급하고 {@code createdAt} 은 저장 계층이 채운다. */ diff --git a/src/main/java/com/heddy/domain/treatment/model/TreatmentRecord.java b/src/main/java/com/heddy/domain/treatment/model/TreatmentRecord.java index 57f7971..27dd035 100644 --- a/src/main/java/com/heddy/domain/treatment/model/TreatmentRecord.java +++ b/src/main/java/com/heddy/domain/treatment/model/TreatmentRecord.java @@ -4,6 +4,8 @@ import com.heddy.domain.treatment.exception.TreatmentException; import java.time.Instant; +import java.time.DateTimeException; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -42,6 +44,12 @@ public record TreatmentRecord( String nextVisitCautions, Integer durationMinutes, String treatmentContent, + String timezone, + String cutLength, + String cutShape, + String permType, + String colorName, + List products, List photos, Instant createdAt ) { @@ -49,6 +57,7 @@ public record TreatmentRecord( private static final int SALON_NAME_MAX_LENGTH = 50; private static final int DESIGNER_NAME_MAX_LENGTH = 30; private static final int TREATMENT_CONTENT_MAX_LENGTH = 255; + private static final String DEFAULT_TIMEZONE = "Asia/Seoul"; /** * 통화를 생략한 가격에 채울 기본값. 국내 전용 서비스라 통화를 고를 자리가 화면에 없다. @@ -101,6 +110,12 @@ public record TreatmentRecord( } treatmentContent = normalizeName(treatmentContent, TREATMENT_CONTENT_MAX_LENGTH, TreatmentError.TREATMENT_CONTENT_TOO_LONG); + timezone = normalizeTimezone(timezone); + cutLength = normalizeName(cutLength, 20, TreatmentError.DETAIL_TOO_LONG); + cutShape = normalizeName(cutShape, 20, TreatmentError.DETAIL_TOO_LONG); + permType = normalizeName(permType, 20, TreatmentError.DETAIL_TOO_LONG); + colorName = normalizeName(colorName, 30, TreatmentError.DETAIL_TOO_LONG); + products = products == null ? null : List.copyOf(products); photos = photos == null ? List.of() : List.copyOf(photos); if (photos.size() > MAX_PHOTOS) { @@ -132,7 +147,8 @@ public TreatmentRecord( ) { this(recordId, userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - memo, nextVisitCautions, null, null, photos, createdAt); + memo, nextVisitCautions, null, null, DEFAULT_TIMEZONE, + null, null, null, null, null, photos, createdAt); } /** 메모 컬럼 도입 전 호출부와의 호환을 위한 생성자. */ @@ -152,7 +168,8 @@ public TreatmentRecord( ) { this(recordId, userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - null, null, null, null, photos, createdAt); + null, null, null, null, DEFAULT_TIMEZONE, + null, null, null, null, null, photos, createdAt); } /** 새 기록을 만든다. 식별자는 도메인이 발급하고 사진은 빈 채로 시작한다. */ @@ -208,7 +225,36 @@ public static TreatmentRecord create( return new TreatmentRecord( UUID.randomUUID(), userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - memo, nextVisitCautions, durationMinutes, treatmentContent, List.of(), null); + memo, nextVisitCautions, durationMinutes, treatmentContent, DEFAULT_TIMEZONE, + null, null, null, null, null, List.of(), null); + } + + public static TreatmentRecord create( + UUID userId, + Set serviceTypes, + String salonName, + String designerName, + Instant performedAt, + Integer satisfaction, + Long priceAmount, + String priceCurrency, + UUID appointmentId, + String memo, + String nextVisitCautions, + Integer durationMinutes, + String treatmentContent, + String timezone, + String cutLength, + String cutShape, + String permType, + String colorName, + List products + ) { + return new TreatmentRecord( + UUID.randomUUID(), userId, serviceTypes, salonName, designerName, + performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, + memo, nextVisitCautions, durationMinutes, treatmentContent, timezone, + cutLength, cutShape, permType, colorName, products, List.of(), null); } /** @@ -241,7 +287,8 @@ public TreatmentRecord attachPhoto(TreatmentPhoto photo) { return new TreatmentRecord( recordId, userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - memo, nextVisitCautions, durationMinutes, treatmentContent, attached, createdAt); + memo, nextVisitCautions, durationMinutes, treatmentContent, timezone, + cutLength, cutShape, permType, colorName, products, attached, createdAt); } /** 부분 수정에서 결정된 최종 값으로 기록의 새 스냅샷을 만든다. */ @@ -260,7 +307,8 @@ public TreatmentRecord update( ) { return update(serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, memo, nextVisitCautions, - durationMinutes, treatmentContent); + durationMinutes, treatmentContent, timezone, cutLength, cutShape, + permType, colorName, products); } public TreatmentRecord update( @@ -275,12 +323,19 @@ public TreatmentRecord update( String memo, String nextVisitCautions, Integer durationMinutes, - String treatmentContent + String treatmentContent, + String timezone, + String cutLength, + String cutShape, + String permType, + String colorName, + List products ) { return new TreatmentRecord( recordId, userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, - memo, nextVisitCautions, durationMinutes, treatmentContent, photos, createdAt); + memo, nextVisitCautions, durationMinutes, treatmentContent, timezone, + cutLength, cutShape, permType, colorName, products, photos, createdAt); } private static String normalizeName(String value, int maxLength, TreatmentError tooLong) { @@ -302,6 +357,16 @@ private static String normalizeCurrency(String currency) { return upper; } + private static String normalizeTimezone(String value) { + String timezone = value == null || value.isBlank() ? DEFAULT_TIMEZONE : value.strip(); + try { + ZoneId.of(timezone); + return timezone; + } catch (DateTimeException invalidTimezone) { + throw new TreatmentException(TreatmentError.TIMEZONE_INVALID); + } + } + private static String normalizeText(String value) { if (value == null || value.isBlank()) { return null; diff --git a/src/main/java/com/heddy/domain/treatment/port/in/CreateTreatmentRecordUseCase.java b/src/main/java/com/heddy/domain/treatment/port/in/CreateTreatmentRecordUseCase.java index 36ebab1..51f9eb0 100644 --- a/src/main/java/com/heddy/domain/treatment/port/in/CreateTreatmentRecordUseCase.java +++ b/src/main/java/com/heddy/domain/treatment/port/in/CreateTreatmentRecordUseCase.java @@ -33,6 +33,12 @@ record Command( String nextVisitCautions, Integer durationMinutes, String treatmentContent, + String timezone, + String cutLength, + String cutShape, + String permType, + String colorName, + List products, List photos ) { public Command( @@ -48,7 +54,8 @@ public Command( List photos ) { this(userId, serviceTypes, salonName, designerName, performedAt, satisfaction, - priceAmount, priceCurrency, appointmentId, null, null, null, null, photos); + priceAmount, priceCurrency, appointmentId, null, null, null, null, + null, null, null, null, null, null, photos); } /** 소요 시간·시술 내용 도입 전 호출부와의 호환을 위한 생성자. */ @@ -68,7 +75,7 @@ public Command( ) { this(userId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, memo, nextVisitCautions, - null, null, photos); + null, null, null, null, null, null, null, null, photos); } public record Photo(UUID fileId, ImageType imageType, int sortOrder) { diff --git a/src/main/java/com/heddy/domain/treatment/port/in/UpdateTreatmentRecordUseCase.java b/src/main/java/com/heddy/domain/treatment/port/in/UpdateTreatmentRecordUseCase.java index a783a83..0d77b53 100644 --- a/src/main/java/com/heddy/domain/treatment/port/in/UpdateTreatmentRecordUseCase.java +++ b/src/main/java/com/heddy/domain/treatment/port/in/UpdateTreatmentRecordUseCase.java @@ -4,6 +4,7 @@ import com.heddy.domain.treatment.model.TreatmentRecord; import java.time.Instant; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -26,7 +27,13 @@ record Command( Patch memo, Patch nextVisitCautions, Patch durationMinutes, - Patch treatmentContent + Patch treatmentContent, + Patch timezone, + Patch cutLength, + Patch cutShape, + Patch permType, + Patch colorName, + Patch> products ) { /** 소요 시간·시술 내용 도입 전 호출부와의 호환을 위한 생성자. */ public Command( @@ -45,7 +52,8 @@ public Command( ) { this(requesterId, recordId, serviceTypes, salonName, designerName, performedAt, satisfaction, priceAmount, priceCurrency, appointmentId, memo, - nextVisitCautions, Patch.absent(), Patch.absent()); + nextVisitCautions, Patch.absent(), Patch.absent(), Patch.absent(), + Patch.absent(), Patch.absent(), Patch.absent(), Patch.absent(), Patch.absent()); } } diff --git a/src/main/resources/db/migration/V32__align_schema_with_api_v2.sql b/src/main/resources/db/migration/V32__align_schema_with_api_v2.sql new file mode 100644 index 0000000..88ce85f --- /dev/null +++ b/src/main/resources/db/migration/V32__align_schema_with_api_v2.sql @@ -0,0 +1,134 @@ +-- Heddy API v2 / 테이블 명세와 실제 저장 스키마의 정합화. +-- 기존 운영 컬럼은 하위 호환을 위해 유지하고, 명세가 요구하는 컬럼과 관계를 추가한다. + +ALTER TABLE treatment_records + ADD COLUMN timezone VARCHAR(50), + ADD COLUMN cut_length VARCHAR(20), + ADD COLUMN cut_shape VARCHAR(20), + ADD COLUMN perm_type VARCHAR(20), + ADD COLUMN color_name VARCHAR(30), + ADD COLUMN products JSONB; + +UPDATE treatment_records SET timezone = 'Asia/Seoul' WHERE timezone IS NULL; +ALTER TABLE treatment_records ALTER COLUMN timezone SET DEFAULT 'Asia/Seoul'; +ALTER TABLE treatment_records ALTER COLUMN timezone SET NOT NULL; +ALTER TABLE treatment_records + ALTER COLUMN duration_minutes TYPE INTEGER USING duration_minutes::INTEGER; + +ALTER TABLE treatment_record_photos + ALTER COLUMN image_type TYPE VARCHAR(10), + ALTER COLUMN sort_order TYPE SMALLINT USING sort_order::SMALLINT; + +ALTER TABLE analysis_jobs + ADD COLUMN analysis_id UUID REFERENCES analysis_results(analysis_id) ON DELETE SET NULL, + ADD COLUMN previous_record_id UUID REFERENCES treatment_records(record_id) ON DELETE SET NULL; + +UPDATE analysis_jobs job +SET analysis_id = result.analysis_id +FROM analysis_results result +WHERE result.job_id = job.job_id; + +ALTER TABLE analysis_jobs + ALTER COLUMN attempt_count TYPE INTEGER USING attempt_count::INTEGER, + ALTER COLUMN failure_message TYPE TEXT; + +ALTER TABLE analysis_results ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'SUCCEEDED'; +ALTER TABLE analysis_results RENAME COLUMN summary TO summary_comment; +ALTER TABLE analysis_results RENAME COLUMN evidence TO evidence_json; + +ALTER TABLE analysis_results + ALTER COLUMN color_uniformity_score DROP NOT NULL, + ALTER COLUMN color_uniformity_grade DROP NOT NULL, + ALTER COLUMN shape_symmetry_score DROP NOT NULL, + ALTER COLUMN shape_symmetry_grade DROP NOT NULL, + ALTER COLUMN volume_balance_score DROP NOT NULL, + ALTER COLUMN volume_balance_grade DROP NOT NULL, + ALTER COLUMN roughness_score DROP NOT NULL, + ALTER COLUMN roughness_grade DROP NOT NULL; + +CREATE FUNCTION link_analysis_result_to_job() RETURNS TRIGGER AS $$ +BEGIN + UPDATE analysis_jobs + SET analysis_id = NEW.analysis_id + WHERE job_id = NEW.job_id; + UPDATE analysis_results result + SET status = job.status + FROM analysis_jobs job + WHERE result.analysis_id = NEW.analysis_id + AND job.job_id = NEW.job_id + AND job.status IN ('SUCCEEDED', 'STALE'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_link_analysis_result_to_job + AFTER INSERT ON analysis_results + FOR EACH ROW EXECUTE FUNCTION link_analysis_result_to_job(); + +CREATE FUNCTION sync_analysis_result_status() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IN ('SUCCEEDED', 'STALE') THEN + UPDATE analysis_results + SET status = NEW.status + WHERE job_id = NEW.job_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sync_analysis_result_status + AFTER UPDATE OF status ON analysis_jobs + FOR EACH ROW EXECUTE FUNCTION sync_analysis_result_status(); + +-- 기존 공용 색상 카탈로그는 유지하고 스타일별 지원 관계를 명세 이름으로 제공한다. +CREATE TABLE hairstyle_colors ( + color_id UUID NOT NULL REFERENCES hair_colors(color_id), + hairstyle_id UUID NOT NULL REFERENCES hairstyle_assets(hairstyle_id) ON DELETE CASCADE, + name VARCHAR(30) NOT NULL, + hex_code CHAR(7) NOT NULL, + sort_order SMALLINT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (hairstyle_id, color_id) +); + +ALTER TABLE hairstyle_assets + ALTER COLUMN style_name TYPE VARCHAR(30), + ALTER COLUMN category TYPE VARCHAR(20), + ALTER COLUMN ar_mode TYPE VARCHAR(20), + ALTER COLUMN asset_version TYPE VARCHAR(20); + +CREATE INDEX idx_hairstyle_colors_color_id ON hairstyle_colors(color_id); + +INSERT INTO hairstyle_colors (color_id, hairstyle_id, name, hex_code, sort_order) +SELECT color.color_id, style.hairstyle_id, color.name, color.hex_code, color.sort_order::SMALLINT +FROM hair_colors color +CROSS JOIN hairstyle_assets style; + +CREATE TABLE ar_captures ( + capture_id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(user_id), + hairstyle_id UUID NOT NULL REFERENCES hairstyle_assets(hairstyle_id), + color_id UUID NOT NULL REFERENCES hair_colors(color_id), + file_id UUID NOT NULL REFERENCES files(file_id), + captured_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE ar_captures + ADD CONSTRAINT fk_ar_captures_supported_color + FOREIGN KEY (hairstyle_id, color_id) + REFERENCES hairstyle_colors(hairstyle_id, color_id); + +CREATE INDEX idx_ar_captures_user_captured + ON ar_captures(user_id, captured_at DESC, capture_id DESC); + +ALTER TABLE recommendation_reference_records + RENAME COLUMN reference_reason_code TO reference_reason; +ALTER TABLE recommendation_reference_records + ALTER COLUMN reference_reason TYPE VARCHAR(255); + +ALTER TABLE recommendation_items + ALTER COLUMN display_rank TYPE SMALLINT USING display_rank::SMALLINT, + ALTER COLUMN management_difficulty TYPE VARCHAR(10); + +ALTER TABLE saved_styles ALTER COLUMN memo TYPE TEXT; diff --git a/src/test/java/com/heddy/adapter/in/web/analysis/AnalysisApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/analysis/AnalysisApiIntegrationTest.java index 53ac729..d1b1b8f 100644 --- a/src/test/java/com/heddy/adapter/in/web/analysis/AnalysisApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/analysis/AnalysisApiIntegrationTest.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -56,8 +57,12 @@ void returnsEveryMetricWithItsDirectionForTheDetailScreen() throws Exception { mockMvc.perform(get("/treatment-records/{recordId}/analyses/latest", recordId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.record_id").value(recordId.toString())) + .andExpect(jsonPath("$.data.photo_id").value(photoId.toString())) .andExpect(jsonPath("$.data.status").value("SUCCEEDED")) .andExpect(jsonPath("$.data.model_version").value("hair-v1.2.0")) + .andExpect(jsonPath("$.data.summary_comment") + .value("사진에서 거칠게 보이는 영역이 감지되었습니다")) .andExpect(jsonPath("$.data.confidence.score").value(82.40)) .andExpect(jsonPath("$.data.confidence.grade").value("HIGH")) .andExpect(jsonPath("$.data.metrics", hasSize(4))) @@ -77,6 +82,10 @@ void reportsStaleWithoutHidingTheResult() throws Exception { UUID jobId = insertJob("STALE"); insertResult(jobId); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM analysis_results WHERE job_id = ?", String.class, jobId)) + .isEqualTo("STALE"); + mockMvc.perform(get("/treatment-records/{recordId}/analyses/latest", recordId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) @@ -135,7 +144,7 @@ INSERT INTO analysis_results ( volume_balance_score, volume_balance_grade, roughness_score, roughness_grade, confidence_score, confidence_grade, - model_version, summary, analyzed_at + model_version, summary_comment, analyzed_at ) VALUES (?, ?, ?, ?, ?, 78.00, 'HIGH', 71.00, 'HIGH', 64.00, 'MEDIUM', 41.00, 'LOW', 82.40, 'HIGH', 'hair-v1.2.0', ?, ?) """, UUID.randomUUID(), jobId, USER_ID, recordId, photoId, diff --git a/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java index a644e11..51faad1 100644 --- a/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/treatment/TreatmentRecordApiIntegrationTest.java @@ -392,6 +392,44 @@ void storesAndReturnsDurationAndTreatmentContent() throws Exception { .andExpect(jsonPath("$.data.treatment_content").value("애쉬브라운 전체 염색")); } + @Test + void storesAndReturnsApiV2TreatmentDetails() throws Exception { + String created = mockMvc.perform(post("/treatment-records") + .with(authentication(userAuthentication(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "service_types": ["CUT", "PERM", "COLOR"], + "performed_at": "2026-08-20T10:00:00Z", + "timezone": "Asia/Seoul", + "cut_length": "MEDIUM", + "cut_shape": "LAYERED", + "perm_type": "SETTING", + "color_name": "애쉬 브라운", + "products": ["클리닉 A", "염모제 B"] + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.timezone").value("Asia/Seoul")) + .andExpect(jsonPath("$.data.cut_length").value("MEDIUM")) + .andExpect(jsonPath("$.data.cut_shape").value("LAYERED")) + .andExpect(jsonPath("$.data.perm_type").value("SETTING")) + .andExpect(jsonPath("$.data.color_name").value("애쉬 브라운")) + .andExpect(jsonPath("$.data.products", containsInAnyOrder("클리닉 A", "염모제 B"))) + .andReturn().getResponse().getContentAsString(); + String recordId = new ObjectMapper().readTree(created).path("data").path("record_id").asText(); + + mockMvc.perform(get("/treatment-records/{recordId}", recordId) + .with(authentication(userAuthentication(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.timezone").value("Asia/Seoul")) + .andExpect(jsonPath("$.data.cut_length").value("MEDIUM")) + .andExpect(jsonPath("$.data.cut_shape").value("LAYERED")) + .andExpect(jsonPath("$.data.perm_type").value("SETTING")) + .andExpect(jsonPath("$.data.color_name").value("애쉬 브라운")) + .andExpect(jsonPath("$.data.products", hasSize(2))); + } + @Test void rejectsNegativeDurationMinutes() throws Exception { mockMvc.perform(post("/treatment-records") @@ -513,6 +551,43 @@ void patchesOnlyPresentedFieldsAndClearsExplicitNulls() throws Exception { assertThat(stored.get("next_visit_cautions")).isNull(); } + @Test + void patchesApiV2TreatmentDetails() throws Exception { + String recordId = createRecord(USER_ID); + + mockMvc.perform(patch("/treatment-records/" + recordId) + .with(authentication(userAuthentication(USER_ID))) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "timezone": "Asia/Tokyo", + "cut_length": "SHORT", + "cut_shape": "BOB", + "perm_type": "DIGITAL", + "color_name": "다크 브라운", + "products": ["제품 A"] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.timezone").value("Asia/Tokyo")) + .andExpect(jsonPath("$.data.cut_length").value("SHORT")) + .andExpect(jsonPath("$.data.cut_shape").value("BOB")) + .andExpect(jsonPath("$.data.perm_type").value("DIGITAL")) + .andExpect(jsonPath("$.data.color_name").value("다크 브라운")) + .andExpect(jsonPath("$.data.products[0]").value("제품 A")); + + var stored = jdbcTemplate.queryForMap(""" + SELECT timezone, cut_length, cut_shape, perm_type, color_name, products + FROM treatment_records WHERE record_id = ? + """, UUID.fromString(recordId)); + assertThat(stored.get("timezone")).isEqualTo("Asia/Tokyo"); + assertThat(stored.get("cut_length")).isEqualTo("SHORT"); + assertThat(stored.get("cut_shape")).isEqualTo("BOB"); + assertThat(stored.get("perm_type")).isEqualTo("DIGITAL"); + assertThat(stored.get("color_name")).isEqualTo("다크 브라운"); + assertThat(stored.get("products").toString()).contains("제품 A"); + } + @Test void rejectsUnknownFieldsInPatchInsteadOfSilentlyDroppingThem() throws Exception { UUID fileId = readyFile(USER_ID); diff --git a/src/test/java/com/heddy/adapter/out/persistence/SchemaAlignmentIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/SchemaAlignmentIntegrationTest.java new file mode 100644 index 0000000..5f63b4b --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/persistence/SchemaAlignmentIntegrationTest.java @@ -0,0 +1,55 @@ +package com.heddy.adapter.out.persistence; + +import com.heddy.support.PostgresIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SchemaAlignmentIntegrationTest extends PostgresIntegrationTest { + + @Autowired JdbcTemplate jdbcTemplate; + + @Test + void apiV2TablesAndColumnsExist() { + assertThat(tableNames()).contains("hairstyle_colors", "ar_captures"); + assertThat(columnNames("treatment_records")).contains( + "timezone", "cut_length", "cut_shape", "perm_type", "color_name", "products"); + assertThat(columnNames("analysis_jobs")).contains("analysis_id", "previous_record_id"); + assertThat(columnNames("analysis_results")).contains( + "status", "summary_comment", "evidence_json"); + assertThat(columnNames("recommendation_reference_records")) + .contains("reference_reason") + .doesNotContain("reference_reason_code"); + assertThat(dataType("treatment_records", "duration_minutes")).isEqualTo("integer"); + assertThat(dataType("analysis_jobs", "attempt_count")).isEqualTo("integer"); + assertThat(dataType("analysis_jobs", "failure_message")).isEqualTo("text"); + } + + private List tableNames() { + return jdbcTemplate.queryForList(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + """, String.class); + } + + private List columnNames(String tableName) { + return jdbcTemplate.queryForList(""" + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? + """, String.class, tableName); + } + + private String dataType(String tableName, String columnName) { + return jdbcTemplate.queryForObject(""" + SELECT data_type + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? AND column_name = ? + """, String.class, tableName, columnName); + } +} diff --git a/src/test/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultPersistenceAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultPersistenceAdapterIntegrationTest.java index d7d8363..287f32b 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultPersistenceAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/analysis/AnalysisResultPersistenceAdapterIntegrationTest.java @@ -52,7 +52,8 @@ void setUpOwnerAndPhoto() { @Test void savesResultAndReadsEveryMetricBack() { - AnalysisResult saved = adapter.insert(resultOf(succeededJob(photoId))); + AnalysisJob job = succeededJob(photoId); + AnalysisResult saved = adapter.insert(resultOf(job)); AnalysisResult found = adapter.findByIdAndUserId(saved.analysisId(), USER_ID).orElseThrow(); assertThat(found.metric(MetricType.COLOR_UNIFORMITY).score()) @@ -65,6 +66,24 @@ void savesResultAndReadsEveryMetricBack() { assertThat(found.modelVersion()).isEqualTo("hair-v1.2.0"); assertThat(found.summary()).isEqualTo("사진에서 거칠게 보이는 영역이 감지되었습니다"); assertThat(found.analyzedAt()).isEqualTo(NOW); + assertThat(jdbcTemplate.queryForObject( + "SELECT analysis_id FROM analysis_jobs WHERE job_id = ?", UUID.class, job.jobId())) + .isEqualTo(saved.analysisId()); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM analysis_results WHERE analysis_id = ?", String.class, + saved.analysisId())).isEqualTo("SUCCEEDED"); + } + + @Test + void keepsResultStatusInSyncWhenJobBecomesStale() { + AnalysisJob job = succeededJob(photoId); + AnalysisResult saved = adapter.insert(resultOf(job)); + + jobAdapter.update(job.markStale(NOW.plusSeconds(1))); + + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM analysis_results WHERE analysis_id = ?", String.class, + saved.analysisId())).isEqualTo("STALE"); } /** 소수점이 깎이면 비교 분석의 Δ값이 그만큼 어긋난다. */ diff --git a/src/test/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapterIntegrationTest.java index 05d2e09..e7166c2 100644 --- a/src/test/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/persistence/treatment/TreatmentPersistenceAdapterIntegrationTest.java @@ -317,6 +317,13 @@ void treatmentRecordsTableMatchesMigration() { assertColumn("treatment_records", "salon_name", "character varying", 50, true); assertColumn("treatment_records", "designer_name", "character varying", 30, true); assertColumn("treatment_records", "performed_at", "timestamp with time zone", null, false); + assertColumn("treatment_records", "timezone", "character varying", 50, false); + assertColumn("treatment_records", "cut_length", "character varying", 20, true); + assertColumn("treatment_records", "cut_shape", "character varying", 20, true); + assertColumn("treatment_records", "perm_type", "character varying", 20, true); + assertColumn("treatment_records", "color_name", "character varying", 30, true); + assertColumn("treatment_records", "products", "jsonb", null, true); + assertColumn("treatment_records", "duration_minutes", "integer", null, true); assertColumn("treatment_records", "satisfaction", "smallint", null, true); assertColumn("treatment_records", "price_amount", "bigint", null, true); assertColumn("treatment_records", "price_currency", "character varying", 3, true); @@ -332,8 +339,8 @@ void treatmentRecordPhotosTableMatchesMigration() { assertColumn("treatment_record_photos", "photo_id", "uuid", null, false); assertColumn("treatment_record_photos", "record_id", "uuid", null, false); assertColumn("treatment_record_photos", "file_id", "uuid", null, false); - assertColumn("treatment_record_photos", "image_type", "character varying", 20, false); - assertColumn("treatment_record_photos", "sort_order", "integer", null, false); + assertColumn("treatment_record_photos", "image_type", "character varying", 10, false); + assertColumn("treatment_record_photos", "sort_order", "smallint", null, false); assertColumn("treatment_record_photos", "created_at", "timestamp with time zone", null, false); assertColumn("treatment_record_photos", "updated_at", "timestamp with time zone", null, false); }