diff --git a/Dockerfile b/Dockerfile index 78dc8d2..adedaf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,15 @@ RUN chmod +x ./gradlew && ./gradlew --no-daemon dependencies > /dev/null 2>&1 || COPY src ./src RUN ./gradlew --no-daemon bootJar -FROM eclipse-temurin:21-jre-alpine AS runtime +FROM eclipse-temurin:21-jre AS runtime WORKDIR /app -RUN addgroup -S spring && adduser -S spring -G spring +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system spring \ + && useradd --system --gid spring spring COPY --from=build /app/build/libs/*.jar app.jar diff --git a/build.gradle b/build.gradle index fbb68dd..5a9d261 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation platform('software.amazon.awssdk:bom:2.28.16') implementation 'software.amazon.awssdk:s3' + // 로컬 얼굴 파싱 모델을 JVM 안에서 실행한다. 외부 AI API 로 사진을 보내지 않는다. + implementation 'com.microsoft.onnxruntime:onnxruntime:1.29.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'org.flywaydb:flyway-database-postgresql' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 12f4508..bd4c59c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -34,10 +34,17 @@ services: # 사진 조회 Presigned GET URL 의 만료(초). 짧을수록 URL 유출 창이 좁지만, # 화면 체류 시간보다 짧으면 이미지가 403 으로 깨진다. DOWNLOAD_URL_TTL_SECONDS: ${DOWNLOAD_URL_TTL_SECONDS:-300} + # 로컬 ONNX 얼굴 파싱 모델. 호스트의 읽기 전용 파일을 컨테이너에 마운트한다. + HAIR_ANALYSIS_ENABLED: ${HAIR_ANALYSIS_ENABLED:-false} + HAIR_ANALYSIS_MODEL_PATH: ${HAIR_ANALYSIS_MODEL_PATH:-/opt/heddy/models/face-parsing.onnx} + HAIR_ANALYSIS_MODEL_VERSION: ${HAIR_ANALYSIS_MODEL_VERSION:-segformer-face-parsing-cv-v1} + HAIR_ANALYSIS_WORKER_THREADS: ${HAIR_ANALYSIS_WORKER_THREADS:-1} ports: - "8080:8080" + volumes: + - ${HAIR_ANALYSIS_MODEL_HOST_DIR:-/home/ubuntu/heddy-models}:/opt/heddy/models:ro healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'"] interval: 15s timeout: 5s retries: 10 diff --git a/docs/deploy-setup.md b/docs/deploy-setup.md index feabbff..2d5fbc0 100644 --- a/docs/deploy-setup.md +++ b/docs/deploy-setup.md @@ -58,6 +58,28 @@ SHARE_PUBLIC_BASE_URL=https://heddy.site/s # 선택: 미지정 시에도 이 기능별 선택 변수(소셜 로그인 `KAKAO_APP_ID` · `GOOGLE_CLIENT_ID` · `APPLE_CLIENT_ID`, SMS `SOLAPI_*` 등)는 해당 기능을 켤 때 같은 파일에 추가하면 `.env` 째로 컨테이너에 전달된다. +헤어 분석을 켤 때는 아래 값도 추가한다. 모델 파일은 이미지에 넣지 않고 EC2의 +`/home/ubuntu/heddy-models`에서 컨테이너로 읽기 전용 마운트한다. + +```dotenv +HAIR_ANALYSIS_ENABLED=true +HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models +HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx +HAIR_ANALYSIS_MODEL_VERSION=<학습 데이터와 가중치를 식별하는 버전> +HAIR_ANALYSIS_WORKER_THREADS=1 +``` + +- ONNX 입력은 RGB `float32 [1,3,512,512]`, 출력은 19개 이상의 클래스 logits + `[1,C,H,W]`여야 한다. 사용하는 클래스 번호는 skin=1, nose=2, left-eye=4, + right-eye=5, hair=13이다. +- 모델 파일이 없거나 열리지 않으면 앱을 분석 활성 상태로 기동하지 않는다. 기능을 끈 환경의 + 분석 요청은 더미 점수 대신 `503 ANALYSIS_ENGINE_UNAVAILABLE`을 반환한다. +- 참고용 공개 `jonathandinu/face-parsing`/CelebAMask-HQ 가중치는 비상업 용도 제한이 있으므로 + 운영 서비스에 그대로 배포하지 않는다. 실제 운영에는 사용 권리를 확보한 가중치를 같은 ONNX + 계약으로 내보내 배치한다. +- 인스턴스 역할에는 기존 업로드 권한과 함께 시술 사진 객체에 대한 `s3:GetObject`가 필요하다. + 서버가 분석 시 S3 원본을 직접 읽으며 Presigned URL을 외부 AI 서비스에 전달하지 않는다. + ## 3. 첫 배포 1. 위 1·2번이 끝났으면 `main` 에 푸시하거나 Actions 에서 `Deploy` 를 수동 실행한다. diff --git a/src/main/java/com/heddy/adapter/in/web/analysis/AnalysisController.java b/src/main/java/com/heddy/adapter/in/web/analysis/AnalysisController.java index 3d008f8..c9bf84c 100644 --- a/src/main/java/com/heddy/adapter/in/web/analysis/AnalysisController.java +++ b/src/main/java/com/heddy/adapter/in/web/analysis/AnalysisController.java @@ -1,7 +1,14 @@ package com.heddy.adapter.in.web.analysis; import com.heddy.adapter.in.web.analysis.dto.AnalysisResponse; +import com.heddy.adapter.in.web.analysis.dto.AnalysisJobAcceptedResponse; +import com.heddy.adapter.in.web.analysis.dto.AnalysisJobResponse; +import com.heddy.adapter.in.web.analysis.dto.RequestAnalysisRequest; +import com.heddy.domain.analysis.port.in.GetAnalysisJobUseCase; +import com.heddy.domain.analysis.port.in.GetAnalysisUseCase; import com.heddy.domain.analysis.port.in.GetLatestAnalysisUseCase; +import com.heddy.domain.analysis.port.in.RequestAnalysisUseCase; +import com.heddy.domain.analysis.port.in.RetryAnalysisUseCase; import com.heddy.global.docs.ApiDocs; import com.heddy.global.filter.RequestIdFilter; import com.heddy.global.response.ApiResponse; @@ -11,9 +18,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.UUID; @@ -25,6 +36,76 @@ public class AnalysisController { private final GetLatestAnalysisUseCase getLatestAnalysisUseCase; + private final RequestAnalysisUseCase requestAnalysisUseCase; + private final GetAnalysisJobUseCase getAnalysisJobUseCase; + private final RetryAnalysisUseCase retryAnalysisUseCase; + private final GetAnalysisUseCase getAnalysisUseCase; + + @PostMapping("/treatment-records/{recordId}/analyses") + @ApiDocs.Accepted + @ApiDocs.Authenticated + @ApiDocs.OwnedResource + @Operation(summary = "머리 분석 작업 요청", + description = "S3의 READY 시술 사진을 실제 SegFormer ONNX 모델로 비동기 분석한다. " + + "모델을 사용할 수 없으면 임의 점수를 만들지 않고 503으로 답한다.") + public ResponseEntity> request( + @AuthenticationPrincipal UUID userId, + @PathVariable UUID recordId, + @RequestBody(required = false) RequestAnalysisRequest request, + HttpServletRequest servletRequest + ) { + UUID photoId = request == null ? null : request.photoId(); + var job = requestAnalysisUseCase.request( + new RequestAnalysisUseCase.Command(userId, recordId, photoId)); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success( + AnalysisJobAcceptedResponse.from(job), RequestIdFilter.get(servletRequest))); + } + + @GetMapping("/analysis-jobs/{jobId}") + @ApiDocs.Ok + @ApiDocs.Authenticated + @ApiDocs.OwnedResource + @Operation(summary = "분석 작업 상태 조회") + public ApiResponse getJob( + @AuthenticationPrincipal UUID userId, + @PathVariable UUID jobId, + HttpServletRequest servletRequest + ) { + return ApiResponse.success(AnalysisJobResponse.from(getAnalysisJobUseCase.get( + new GetAnalysisJobUseCase.Query(userId, jobId))), + RequestIdFilter.get(servletRequest)); + } + + @PostMapping("/analysis-jobs/{jobId}/retry") + @ApiDocs.Accepted + @ApiDocs.Authenticated + @ApiDocs.OwnedResource + @Operation(summary = "실패한 분석 작업 재시도", + description = "FAILED 작업만 새 작업 ID로 재접수한다. UNAVAILABLE은 재촬영 대상이다.") + public ResponseEntity> retry( + @AuthenticationPrincipal UUID userId, + @PathVariable UUID jobId, + HttpServletRequest servletRequest + ) { + var job = retryAnalysisUseCase.retry(new RetryAnalysisUseCase.Command(userId, jobId)); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success( + AnalysisJobAcceptedResponse.from(job), RequestIdFilter.get(servletRequest))); + } + + @GetMapping("/analyses/{analysisId}") + @ApiDocs.Ok + @ApiDocs.Authenticated + @ApiDocs.OwnedResource + @Operation(summary = "분석 결과 조회") + public ApiResponse getAnalysis( + @AuthenticationPrincipal UUID userId, + @PathVariable UUID analysisId, + HttpServletRequest servletRequest + ) { + return ApiResponse.success(AnalysisResponse.from(getAnalysisUseCase.get( + new GetAnalysisUseCase.Query(userId, analysisId))), + RequestIdFilter.get(servletRequest)); + } @GetMapping("/treatment-records/{recordId}/analyses/latest") @ApiDocs.Ok diff --git a/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobAcceptedResponse.java b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobAcceptedResponse.java new file mode 100644 index 0000000..c180c5e --- /dev/null +++ b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobAcceptedResponse.java @@ -0,0 +1,22 @@ +package com.heddy.adapter.in.web.analysis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.heddy.domain.analysis.model.AnalysisJob; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.UUID; + +@Schema(description = "접수된 비동기 분석 작업") +public record AnalysisJobAcceptedResponse( + @JsonProperty("job_id") UUID jobId, + @JsonProperty("record_id") UUID recordId, + @JsonProperty("photo_id") UUID photoId, + String status, + @JsonProperty("created_at") Instant createdAt +) { + public static AnalysisJobAcceptedResponse from(AnalysisJob job) { + return new AnalysisJobAcceptedResponse(job.jobId(), job.recordId(), job.photoId(), + job.status().name(), job.createdAt()); + } +} diff --git a/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobResponse.java b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobResponse.java new file mode 100644 index 0000000..e4cf8af --- /dev/null +++ b/src/main/java/com/heddy/adapter/in/web/analysis/dto/AnalysisJobResponse.java @@ -0,0 +1,33 @@ +package com.heddy.adapter.in.web.analysis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.heddy.domain.analysis.port.in.GetAnalysisJobUseCase; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.UUID; + +@Schema(description = "비동기 분석 작업 상태") +public record AnalysisJobResponse( + @JsonProperty("job_id") UUID jobId, + String status, + int progress, + @JsonProperty("attempt_count") int attemptCount, + @JsonProperty("analysis_id") UUID analysisId, + Failure failure, + @JsonProperty("created_at") Instant createdAt, + @JsonProperty("updated_at") Instant updatedAt +) { + public record Failure(String code, String message) { + } + + public static AnalysisJobResponse from(GetAnalysisJobUseCase.Result result) { + var job = result.job(); + Failure failure = job.failureCode() == null + ? null : new Failure(job.failureCode(), job.failureMessage()); + Instant updatedAt = job.finishedAt() != null ? job.finishedAt() + : job.startedAt() != null ? job.startedAt() : job.createdAt(); + return new AnalysisJobResponse(job.jobId(), job.status().name(), job.progress(), + job.attemptCount(), result.analysisId(), failure, job.createdAt(), updatedAt); + } +} 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 137b076..f87dca0 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 @@ -4,6 +4,7 @@ import com.heddy.domain.analysis.model.AnalysisOverlay; import com.heddy.domain.analysis.model.MetricType; import com.heddy.domain.analysis.port.in.GetLatestAnalysisUseCase; +import com.heddy.domain.analysis.port.in.GetAnalysisUseCase; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; @@ -80,27 +81,42 @@ public record Overlay( public static AnalysisResponse from(GetLatestAnalysisUseCase.Result result) { var analysis = result.analysis(); - return new AnalysisResponse( - analysis.analysisId(), analysis.recordId(), analysis.photoId(), analysis.jobId(), - result.status().name(), - metrics(result), confidence(result), analysis.modelVersion(), analysis.summary(), - analysis.analyzedAt(), overlays(result.overlays())); + return from(analysis, result.status().name(), result.overlays()); } - private static List metrics(GetLatestAnalysisUseCase.Result result) { + public static AnalysisResponse from(GetAnalysisUseCase.Result result) { + return from(result.analysis(), result.status().name(), result.overlays()); + } + + private static AnalysisResponse from( + com.heddy.domain.analysis.model.AnalysisResult analysis, + String status, + List overlays + ) { + return new AnalysisResponse(analysis.analysisId(), analysis.recordId(), analysis.photoId(), + analysis.jobId(), status, metrics(analysis), confidence(analysis), + analysis.modelVersion(), analysis.summary(), analysis.analyzedAt(), + overlays(overlays)); + } + + private static List metrics( + com.heddy.domain.analysis.model.AnalysisResult analysis + ) { // 지표 순서를 열거형 선언 순서로 고정한다. map 순회 순서에 맡기면 화면의 목록 순서가 // 요청마다 달라진다. return java.util.Arrays.stream(MetricType.values()) .map(type -> { - var metric = result.analysis().metric(type); + var metric = analysis.metric(type); return new Metric(type.name(), metric.score(), metric.grade().name(), type.higherIsBetter()); }) .toList(); } - private static Metric confidence(GetLatestAnalysisUseCase.Result result) { - var confidence = result.analysis().confidence(); + private static Metric confidence( + com.heddy.domain.analysis.model.AnalysisResult analysis + ) { + var confidence = analysis.confidence(); return new Metric(null, confidence.score(), confidence.grade().name(), true); } diff --git a/src/main/java/com/heddy/adapter/in/web/analysis/dto/RequestAnalysisRequest.java b/src/main/java/com/heddy/adapter/in/web/analysis/dto/RequestAnalysisRequest.java new file mode 100644 index 0000000..838d6d7 --- /dev/null +++ b/src/main/java/com/heddy/adapter/in/web/analysis/dto/RequestAnalysisRequest.java @@ -0,0 +1,13 @@ +package com.heddy.adapter.in.web.analysis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +/** 분석할 사진을 선택한다. 비우면 기록의 첫 번째 AFTER 사진을 사용한다. */ +public record RequestAnalysisRequest( + @Schema(description = "분석할 시술 사진 식별자. 생략하면 대표 AFTER 사진") + @JsonProperty("photo_id") UUID photoId +) { +} diff --git a/src/main/java/com/heddy/adapter/out/ai/FaceParsingOutput.java b/src/main/java/com/heddy/adapter/out/ai/FaceParsingOutput.java new file mode 100644 index 0000000..19f8faa --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/FaceParsingOutput.java @@ -0,0 +1,64 @@ +package com.heddy.adapter.out.ai; + +import java.awt.image.BufferedImage; + +/** ONNX logits를 입력 해상도로 확대한 얼굴 파싱 결과. */ +record FaceParsingOutput( + int width, + int height, + int[] rgb, + byte[] labels, + float[] hairProbability +) { + static FaceParsingOutput from(BufferedImage image, float[][][] logits) { + int classes = logits.length; + int outputHeight = logits[0].length; + int outputWidth = logits[0][0].length; + int width = image.getWidth(); + int height = image.getHeight(); + int[] rgb = new int[width * height]; + byte[] labels = new byte[width * height]; + float[] hairProbability = new float[width * height]; + + for (int y = 0; y < height; y++) { + int outputY = Math.min(outputHeight - 1, y * outputHeight / height); + for (int x = 0; x < width; x++) { + int outputX = Math.min(outputWidth - 1, x * outputWidth / width); + int index = y * width + x; + rgb[index] = image.getRGB(x, y) & 0x00ffffff; + + int bestClass = 0; + float maximum = logits[0][outputY][outputX]; + for (int label = 1; label < classes; label++) { + float value = logits[label][outputY][outputX]; + if (value > maximum) { + maximum = value; + bestClass = label; + } + } + labels[index] = (byte) bestClass; + + double denominator = 0; + for (int label = 0; label < classes; label++) { + denominator += Math.exp(logits[label][outputY][outputX] - maximum); + } + hairProbability[index] = (float) (Math.exp( + logits[HairMetricCalculator.HAIR_LABEL][outputY][outputX] - maximum) + / denominator); + } + } + return new FaceParsingOutput(width, height, rgb, labels, hairProbability); + } + + int label(int x, int y) { + return Byte.toUnsignedInt(labels[y * width + x]); + } + + boolean isHair(int x, int y) { + return label(x, y) == HairMetricCalculator.HAIR_LABEL; + } + + int rgb(int x, int y) { + return rgb[y * width + x]; + } +} diff --git a/src/main/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfig.java b/src/main/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfig.java new file mode 100644 index 0000000..8c1e0d1 --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfig.java @@ -0,0 +1,34 @@ +package com.heddy.adapter.out.ai; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +@EnableAsync +class HairAnalysisAsyncConfig { + + @Bean(name = "hairAnalysisTaskExecutor") + Executor hairAnalysisTaskExecutor( + @Value("${app.ai.worker-threads}") int workerThreads + ) { + if (workerThreads < 1 || workerThreads > 8) { + throw new IllegalArgumentException("분석 워커 수는 1~8 이어야 합니다"); + } + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(workerThreads); + executor.setMaxPoolSize(workerThreads); + executor.setQueueCapacity(100); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setThreadNamePrefix("hair-analysis-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java b/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java new file mode 100644 index 0000000..1cee8ec --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/HairMetricCalculator.java @@ -0,0 +1,634 @@ +package com.heddy.adapter.out.ai; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.heddy.domain.analysis.model.ConfidenceGrade; +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.model.HairAnalysisPrediction; +import com.heddy.domain.analysis.model.MetricScore; +import com.heddy.domain.analysis.model.MetricType; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 파싱 모델의 실제 출력 위에서만 동작하는 지표 계산기. 모델 출력 없이 임의 점수를 만들지 않는다. */ +final class HairMetricCalculator { + + static final int SKIN_LABEL = 1; + static final int NOSE_LABEL = 2; + static final int LEFT_EYE_LABEL = 4; + static final int RIGHT_EYE_LABEL = 5; + static final int HAIR_LABEL = 13; + + private static final int MAX_COLOR_SAMPLES = 20_000; + + private final ObjectMapper objectMapper; + private final String modelVersion; + private final double minimumConfidenceScore; + + HairMetricCalculator( + ObjectMapper objectMapper, String modelVersion, double minimumConfidenceScore + ) { + if (modelVersion == null || modelVersion.isBlank()) { + throw new IllegalArgumentException("modelVersion 은 필수입니다"); + } + if (minimumConfidenceScore < 0 || minimumConfidenceScore > 100) { + throw new IllegalArgumentException("최소 신뢰도는 0~100 이어야 합니다"); + } + this.objectMapper = objectMapper; + this.modelVersion = modelVersion; + this.minimumConfidenceScore = minimumConfidenceScore; + } + + HairAnalysisOutcome calculate(FaceParsingOutput parsing) { + Region hair = region(parsing, HAIR_LABEL); + Region leftEye = region(parsing, LEFT_EYE_LABEL); + Region rightEye = region(parsing, RIGHT_EYE_LABEL); + Region nose = region(parsing, NOSE_LABEL); + + double imagePixels = (double) parsing.width() * parsing.height(); + double hairAreaRatio = hair.count / imagePixels; + if (hair.count < imagePixels * 0.01) { + return unavailable("HAIR_NOT_DETECTED", "사진에서 분석할 머리 영역을 찾지 못했습니다."); + } + if (!hasUsableEyeAxis(leftEye, rightEye)) { + return unavailable("FACE_NOT_FRONTAL", "두 눈이 보이도록 정면에서 촬영해 주세요."); + } + + Axis axis = Axis.from(leftEye, rightEye); + double colorUniformity = colorUniformity(parsing, hair.count); + double volumeBalance = volumeBalance(parsing, axis); + double shapeSymmetry = shapeSymmetry(parsing, axis); + Roughness roughness = roughness(parsing, hair.count); + + double meanHairProbability = meanHairProbability(parsing); + double rollDegrees = Math.toDegrees(Math.abs(Math.atan2(axis.eyeDy, axis.eyeDx))); + double frontalness = frontalness(axis, nose, rollDegrees); + double blurVariance = laplacianVariance(parsing); + double lightingUniformity = lightingUniformity(parsing); + double areaQuality = areaQuality(hairAreaRatio); + double sharpnessQuality = clamp((blurVariance - 15.0) / 160.0, 0, 1); + double segmentationQuality = clamp((meanHairProbability - 0.35) / 0.60, 0, 1); + double confidence = 100.0 * weightedHarmonicMean( + new double[]{segmentationQuality, areaQuality, frontalness, + sharpnessQuality, lightingUniformity}, + new double[]{0.28, 0.12, 0.25, 0.20, 0.15}); + + if (confidence < minimumConfidenceScore) { + return unavailable("LOW_CONFIDENCE", + lowConfidenceMessage(frontalness, sharpnessQuality, lightingUniformity)); + } + + Map metrics = new EnumMap<>(MetricType.class); + metrics.put(MetricType.COLOR_UNIFORMITY, metric(colorUniformity)); + metrics.put(MetricType.SHAPE_SYMMETRY, metric(shapeSymmetry)); + metrics.put(MetricType.VOLUME_BALANCE, metric(volumeBalance)); + metrics.put(MetricType.ROUGHNESS, metric(roughness.score)); + + Map raw = new LinkedHashMap<>(); + raw.put("pipeline", "segformer-plus-deterministic-metrics-v1"); + raw.put("hair_area_ratio", round(hairAreaRatio, 4)); + raw.put("mean_hair_probability", round(meanHairProbability, 4)); + raw.put("face_roll_degrees", round(rollDegrees, 2)); + raw.put("frontalness", round(frontalness, 4)); + raw.put("blur_laplacian_variance", round(blurVariance, 2)); + raw.put("lighting_uniformity", round(lightingUniformity, 4)); + raw.put("shape_mirrored_iou", round(shapeSymmetry / 100.0, 4)); + raw.put("roughness_contour_irregularity", round(roughness.contourIrregularity, 4)); + raw.put("roughness_glcm_contrast", round(roughness.glcmContrast, 4)); + + String summary = summary(colorUniformity, volumeBalance, shapeSymmetry, roughness.score); + HairAnalysisPrediction prediction = new HairAnalysisPrediction( + metrics, metric(confidence), modelVersion, summary, json(raw)); + return new HairAnalysisOutcome.Succeeded(prediction); + } + + private HairAnalysisOutcome unavailable(String code, String message) { + return new HairAnalysisOutcome.Unavailable(code, message); + } + + private static MetricScore metric(double score) { + BigDecimal rounded = BigDecimal.valueOf(clamp(score, 0, 100)) + .setScale(2, RoundingMode.HALF_UP); + ConfidenceGrade grade = rounded.compareTo(BigDecimal.valueOf(90)) >= 0 + ? ConfidenceGrade.HIGH + : rounded.compareTo(BigDecimal.valueOf(80)) >= 0 + ? ConfidenceGrade.MEDIUM : ConfidenceGrade.LOW; + return new MetricScore(rounded, grade); + } + + private static double colorUniformity(FaceParsingOutput parsing, int hairCount) { + int step = Math.max(1, hairCount / MAX_COLOR_SAMPLES); + List samples = new ArrayList<>(Math.min(hairCount, MAX_COLOR_SAMPLES)); + int seen = 0; + for (int y = 0; y < parsing.height(); y++) { + for (int x = 0; x < parsing.width(); x++) { + if (parsing.isHair(x, y) && seen++ % step == 0) { + samples.add(toLab(parsing.rgb(x, y))); + } + } + } + if (samples.size() < 3) { + return 0; + } + KMeans clusters = KMeans.fit(samples, 3, 12); + double dispersion = 0; + for (int first = 0; first < clusters.centers.length; first++) { + for (int second = first + 1; second < clusters.centers.length; second++) { + double weight = (double) clusters.counts[first] * clusters.counts[second] + / ((double) samples.size() * samples.size()); + dispersion += 2.0 * weight * deltaE2000( + clusters.centers[first], clusters.centers[second]); + } + } + return clamp(100.0 - dispersion * 3.0, 0, 100); + } + + private static double volumeBalance(FaceParsingOutput parsing, Axis axis) { + int left = 0; + int right = 0; + for (int y = 0; y < parsing.height(); y++) { + for (int x = 0; x < parsing.width(); x++) { + if (!parsing.isHair(x, y)) { + continue; + } + if (axis.horizontalComponent(x, y) < 0) { + left++; + } else { + right++; + } + } + } + int total = left + right; + return total == 0 ? 0 : 100.0 * (1.0 - Math.abs(left - right) / (double) total); + } + + private static double shapeSymmetry(FaceParsingOutput parsing, Axis axis) { + boolean[] mirrored = new boolean[parsing.width() * parsing.height()]; + for (int y = 0; y < parsing.height(); y++) { + for (int x = 0; x < parsing.width(); x++) { + if (!parsing.isHair(x, y)) { + continue; + } + Point reflected = axis.reflect(x, y); + int rx = (int) Math.round(reflected.x); + int ry = (int) Math.round(reflected.y); + if (rx >= 0 && rx < parsing.width() && ry >= 0 && ry < parsing.height()) { + mirrored[ry * parsing.width() + rx] = true; + } + } + } + int intersection = 0; + int union = 0; + for (int y = 0; y < parsing.height(); y++) { + for (int x = 0; x < parsing.width(); x++) { + boolean original = parsing.isHair(x, y); + boolean reflected = mirrored[y * parsing.width() + x]; + if (original || reflected) { + union++; + } + if (original && reflected) { + intersection++; + } + } + } + return union == 0 ? 0 : 100.0 * intersection / union; + } + + private static Roughness roughness(FaceParsingOutput parsing, int hairCount) { + int boundary = 0; + long[][] glcm = new long[16][16]; + long pairs = 0; + double edgeSum = 0; + int edgeCount = 0; + int[][] directions = {{1, 0}, {0, 1}}; + + for (int y = 1; y < parsing.height() - 1; y++) { + for (int x = 1; x < parsing.width() - 1; x++) { + if (!parsing.isHair(x, y)) { + continue; + } + if (!parsing.isHair(x - 1, y) || !parsing.isHair(x + 1, y) + || !parsing.isHair(x, y - 1) || !parsing.isHair(x, y + 1)) { + boundary++; + } + int center = gray(parsing.rgb(x, y)); + int gx = gray(parsing.rgb(x + 1, y)) - gray(parsing.rgb(x - 1, y)); + int gy = gray(parsing.rgb(x, y + 1)) - gray(parsing.rgb(x, y - 1)); + edgeSum += Math.hypot(gx, gy) / 360.0; + edgeCount++; + for (int[] direction : directions) { + int nx = x + direction[0]; + int ny = y + direction[1]; + if (parsing.isHair(nx, ny)) { + glcm[Math.min(15, center / 16)] + [Math.min(15, gray(parsing.rgb(nx, ny)) / 16)]++; + pairs++; + } + } + } + } + double idealPerimeter = 2.0 * Math.sqrt(Math.PI * hairCount); + double contourIrregularity = idealPerimeter == 0 ? 0 : boundary / idealPerimeter; + double edgeDensity = edgeCount == 0 ? 0 : edgeSum / edgeCount; + double contrast = 0; + if (pairs > 0) { + for (int first = 0; first < 16; first++) { + for (int second = 0; second < 16; second++) { + double probability = glcm[first][second] / (double) pairs; + contrast += probability * Math.pow(first - second, 2) / 225.0; + } + } + } + double score = 100.0 * clamp( + 0.42 * clamp((contourIrregularity - 1.0) / 2.5, 0, 1) + + 0.33 * clamp(edgeDensity / 0.45, 0, 1) + + 0.25 * clamp(contrast / 0.12, 0, 1), + 0, 1); + return new Roughness(score, contourIrregularity, contrast); + } + + private static double meanHairProbability(FaceParsingOutput parsing) { + double sum = 0; + int count = 0; + for (int index = 0; index < parsing.labels().length; index++) { + if (Byte.toUnsignedInt(parsing.labels()[index]) == HAIR_LABEL) { + sum += parsing.hairProbability()[index]; + count++; + } + } + return count == 0 ? 0 : sum / count; + } + + private static double frontalness(Axis axis, Region nose, double rollDegrees) { + if (nose.count == 0) { + return 0.45; + } + double noseOffset = Math.abs(axis.horizontalComponent(nose.centerX(), nose.centerY())); + double normalizedOffset = noseOffset / Math.max(1.0, axis.eyeDistance); + double yawQuality = clamp(1.0 - normalizedOffset / 0.35, 0, 1); + double rollQuality = clamp(1.0 - rollDegrees / 18.0, 0, 1); + return 0.75 * yawQuality + 0.25 * rollQuality; + } + + private static double laplacianVariance(FaceParsingOutput parsing) { + double sum = 0; + double squared = 0; + int count = 0; + for (int y = 1; y < parsing.height() - 1; y++) { + for (int x = 1; x < parsing.width() - 1; x++) { + int center = gray(parsing.rgb(x, y)); + double value = gray(parsing.rgb(x - 1, y)) + gray(parsing.rgb(x + 1, y)) + + gray(parsing.rgb(x, y - 1)) + gray(parsing.rgb(x, y + 1)) + - 4.0 * center; + sum += value; + squared += value * value; + count++; + } + } + if (count == 0) { + return 0; + } + double mean = sum / count; + return squared / count - mean * mean; + } + + private static double lightingUniformity(FaceParsingOutput parsing) { + double sum = 0; + double squared = 0; + int count = 0; + for (int index = 0; index < parsing.labels().length; index++) { + if (Byte.toUnsignedInt(parsing.labels()[index]) == SKIN_LABEL) { + int value = gray(parsing.rgb()[index]); + sum += value; + squared += (double) value * value; + count++; + } + } + if (count == 0) { + return 0.4; + } + double variance = Math.max(0, squared / count - Math.pow(sum / count, 2)); + return clamp(1.0 - Math.sqrt(variance) / 70.0, 0, 1); + } + + private static double areaQuality(double ratio) { + if (ratio < 0.08) { + return clamp(ratio / 0.08, 0, 1); + } + if (ratio > 0.60) { + return clamp((0.85 - ratio) / 0.25, 0, 1); + } + return 1; + } + + private static double weightedHarmonicMean(double[] values, double[] weights) { + double weightSum = 0; + double denominator = 0; + for (int index = 0; index < values.length; index++) { + weightSum += weights[index]; + denominator += weights[index] / Math.max(0.05, values[index]); + } + return denominator == 0 ? 0 : weightSum / denominator; + } + + private static String lowConfidenceMessage( + double frontalness, double sharpness, double lighting + ) { + if (frontalness <= sharpness && frontalness <= lighting) { + return "얼굴과 머리 전체가 보이도록 정면에서 촬영해 주세요."; + } + if (sharpness <= lighting) { + return "사진이 흔들리거나 흐립니다. 카메라를 고정하고 다시 촬영해 주세요."; + } + return "그림자와 역광을 피하고 밝기가 고른 곳에서 촬영해 주세요."; + } + + private static String summary( + double color, double volume, double symmetry, double roughness + ) { + String colorText = color >= 80 ? "색상 분포가 비교적 균일합니다" + : "사진에서 색상 편차가 일부 감지되었습니다"; + String balanceText = Math.min(volume, symmetry) >= 80 + ? "좌우 실루엣도 비교적 균형적입니다" + : "좌우 실루엣에 차이가 보입니다"; + String roughnessText = roughness >= 50 + ? "거칠게 보이는 경계와 텍스처가 일부 감지되었습니다" + : "거칠기 징후는 비교적 적게 감지되었습니다"; + return colorText + ". " + balanceText + ". " + roughnessText + "."; + } + + private String json(Map value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("분석 근거를 JSON으로 만들 수 없습니다", exception); + } + } + + private static Region region(FaceParsingOutput parsing, int label) { + long sumX = 0; + long sumY = 0; + int count = 0; + for (int y = 0; y < parsing.height(); y++) { + for (int x = 0; x < parsing.width(); x++) { + if (parsing.label(x, y) == label) { + sumX += x; + sumY += y; + count++; + } + } + } + return new Region(count, sumX, sumY); + } + + private static int gray(int rgb) { + int red = (rgb >>> 16) & 0xff; + int green = (rgb >>> 8) & 0xff; + int blue = rgb & 0xff; + return (int) Math.round(0.299 * red + 0.587 * green + 0.114 * blue); + } + + private static Lab toLab(int rgb) { + double red = linearize(((rgb >>> 16) & 0xff) / 255.0); + double green = linearize(((rgb >>> 8) & 0xff) / 255.0); + double blue = linearize((rgb & 0xff) / 255.0); + double x = (0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / 0.95047; + double y = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue; + double z = (0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / 1.08883; + double fx = labFunction(x); + double fy = labFunction(y); + double fz = labFunction(z); + return new Lab(116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)); + } + + private static double linearize(double value) { + return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + } + + private static double labFunction(double value) { + double delta = 6.0 / 29.0; + return value > delta * delta * delta + ? Math.cbrt(value) + : value / (3 * delta * delta) + 4.0 / 29.0; + } + + /** ISO/CIE 11664-6 CIEDE2000 색차. */ + private static double deltaE2000(Lab first, Lab second) { + double c1 = Math.hypot(first.a, first.b); + double c2 = Math.hypot(second.a, second.b); + double meanC = (c1 + c2) / 2.0; + double g = 0.5 * (1 - Math.sqrt(Math.pow(meanC, 7) + / (Math.pow(meanC, 7) + Math.pow(25.0, 7)))); + double a1 = (1 + g) * first.a; + double a2 = (1 + g) * second.a; + double adjustedC1 = Math.hypot(a1, first.b); + double adjustedC2 = Math.hypot(a2, second.b); + double h1 = hueDegrees(first.b, a1); + double h2 = hueDegrees(second.b, a2); + + double deltaL = second.l - first.l; + double deltaC = adjustedC2 - adjustedC1; + double deltaHAngle; + if (adjustedC1 * adjustedC2 == 0) { + deltaHAngle = 0; + } else if (Math.abs(h2 - h1) <= 180) { + deltaHAngle = h2 - h1; + } else if (h2 <= h1) { + deltaHAngle = h2 - h1 + 360; + } else { + deltaHAngle = h2 - h1 - 360; + } + double deltaH = 2 * Math.sqrt(adjustedC1 * adjustedC2) + * Math.sin(Math.toRadians(deltaHAngle / 2)); + double meanL = (first.l + second.l) / 2; + double meanAdjustedC = (adjustedC1 + adjustedC2) / 2; + double meanH; + if (adjustedC1 * adjustedC2 == 0) { + meanH = h1 + h2; + } else if (Math.abs(h1 - h2) <= 180) { + meanH = (h1 + h2) / 2; + } else if (h1 + h2 < 360) { + meanH = (h1 + h2 + 360) / 2; + } else { + meanH = (h1 + h2 - 360) / 2; + } + double t = 1 - 0.17 * Math.cos(Math.toRadians(meanH - 30)) + + 0.24 * Math.cos(Math.toRadians(2 * meanH)) + + 0.32 * Math.cos(Math.toRadians(3 * meanH + 6)) + - 0.20 * Math.cos(Math.toRadians(4 * meanH - 63)); + double sl = 1 + 0.015 * Math.pow(meanL - 50, 2) + / Math.sqrt(20 + Math.pow(meanL - 50, 2)); + double sc = 1 + 0.045 * meanAdjustedC; + double sh = 1 + 0.015 * meanAdjustedC * t; + double rotation = 30 * Math.exp(-Math.pow((meanH - 275) / 25, 2)); + double rc = 2 * Math.sqrt(Math.pow(meanAdjustedC, 7) + / (Math.pow(meanAdjustedC, 7) + Math.pow(25.0, 7))); + double rt = -rc * Math.sin(Math.toRadians(2 * rotation)); + double l = deltaL / sl; + double c = deltaC / sc; + double h = deltaH / sh; + return Math.sqrt(l * l + c * c + h * h + rt * c * h); + } + + private static double hueDegrees(double b, double a) { + double degrees = Math.toDegrees(Math.atan2(b, a)); + return degrees < 0 ? degrees + 360 : degrees; + } + + private static double round(double value, int scale) { + return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue(); + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static boolean hasUsableEyeAxis(Region leftEye, Region rightEye) { + if (leftEye.count == 0 || rightEye.count == 0) { + return false; + } + return Math.hypot( + rightEye.centerX() - leftEye.centerX(), + rightEye.centerY() - leftEye.centerY()) >= 2; + } + + private record Point(double x, double y) { + } + + private record Region(int count, long sumX, long sumY) { + double centerX() { + return count == 0 ? 0 : sumX / (double) count; + } + + double centerY() { + return count == 0 ? 0 : sumY / (double) count; + } + } + + private static final class Axis { + private final double centerX; + private final double centerY; + private final double horizontalX; + private final double horizontalY; + private final double eyeDx; + private final double eyeDy; + private final double eyeDistance; + + private Axis(double centerX, double centerY, double eyeDx, double eyeDy) { + this.centerX = centerX; + this.centerY = centerY; + this.eyeDx = eyeDx; + this.eyeDy = eyeDy; + this.eyeDistance = Math.hypot(eyeDx, eyeDy); + this.horizontalX = eyeDx / eyeDistance; + this.horizontalY = eyeDy / eyeDistance; + } + + static Axis from(Region firstEye, Region secondEye) { + double firstX = firstEye.centerX(); + double firstY = firstEye.centerY(); + double secondX = secondEye.centerX(); + double secondY = secondEye.centerY(); + double dx = secondX - firstX; + double dy = secondY - firstY; + if (Math.hypot(dx, dy) < 2) { + throw new IllegalArgumentException("눈 중심점을 구분할 수 없습니다"); + } + return new Axis((firstX + secondX) / 2, (firstY + secondY) / 2, dx, dy); + } + + double horizontalComponent(double x, double y) { + return (x - centerX) * horizontalX + (y - centerY) * horizontalY; + } + + Point reflect(double x, double y) { + double horizontal = horizontalComponent(x, y); + return new Point(x - 2 * horizontal * horizontalX, + y - 2 * horizontal * horizontalY); + } + } + + private record Lab(double l, double a, double b) { + Lab add(Lab other) { + return new Lab(l + other.l, a + other.a, b + other.b); + } + + Lab divide(double divisor) { + return new Lab(l / divisor, a / divisor, b / divisor); + } + } + + private record Roughness(double score, double contourIrregularity, double glcmContrast) { + } + + private record KMeans(Lab[] centers, int[] counts) { + static KMeans fit(List samples, int k, int iterations) { + Lab[] centers = initialize(samples, k); + int[] assignment = new int[samples.size()]; + int[] counts = new int[k]; + for (int iteration = 0; iteration < iterations; iteration++) { + java.util.Arrays.fill(counts, 0); + Lab[] sums = new Lab[k]; + java.util.Arrays.fill(sums, new Lab(0, 0, 0)); + for (int index = 0; index < samples.size(); index++) { + int cluster = closest(samples.get(index), centers); + assignment[index] = cluster; + counts[cluster]++; + sums[cluster] = sums[cluster].add(samples.get(index)); + } + for (int cluster = 0; cluster < k; cluster++) { + if (counts[cluster] > 0) { + centers[cluster] = sums[cluster].divide(counts[cluster]); + } + } + } + return new KMeans(centers, counts.clone()); + } + + private static Lab[] initialize(List samples, int k) { + Lab[] centers = new Lab[k]; + centers[0] = samples.get(0); + for (int index = 1; index < k; index++) { + Lab farthest = samples.get(0); + double farthestDistance = -1; + for (Lab sample : samples) { + double nearest = Double.MAX_VALUE; + for (int existing = 0; existing < index; existing++) { + nearest = Math.min(nearest, squaredDistance(sample, centers[existing])); + } + if (nearest > farthestDistance) { + farthestDistance = nearest; + farthest = sample; + } + } + centers[index] = farthest; + } + return centers; + } + + private static int closest(Lab sample, Lab[] centers) { + int best = 0; + double bestDistance = squaredDistance(sample, centers[0]); + for (int index = 1; index < centers.length; index++) { + double distance = squaredDistance(sample, centers[index]); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + } + return best; + } + + private static double squaredDistance(Lab first, Lab second) { + return Math.pow(first.l - second.l, 2) + + Math.pow(first.a - second.a, 2) + + Math.pow(first.b - second.b, 2); + } + } +} diff --git a/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java b/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java new file mode 100644 index 0000000..67a3d0d --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapter.java @@ -0,0 +1,176 @@ +package com.heddy.adapter.out.ai; + +import ai.onnxruntime.OnnxTensor; +import ai.onnxruntime.OrtEnvironment; +import ai.onnxruntime.OrtException; +import ai.onnxruntime.OrtSession; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.port.out.HairAnalysisEnginePort; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.FloatBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * SegFormer 얼굴 파싱 ONNX 모델을 JVM 안에서 실행한다. 사진은 외부 AI API 로 전송하지 않는다. + * 모델의 19개 클래스 중 hair(13), l_eye(4), r_eye(5), skin(1), nose(2)를 지표 계산에 사용한다. + */ +@Component +@ConditionalOnProperty(prefix = "app.ai", name = "enabled", havingValue = "true") +public class OnnxFaceParsingHairAnalysisAdapter implements HairAnalysisEnginePort { + + private static final float[] IMAGE_MEAN = {0.485f, 0.456f, 0.406f}; + private static final float[] IMAGE_STD = {0.229f, 0.224f, 0.225f}; + + private final OrtEnvironment environment; + private final OrtSession session; + private final String inputName; + private final int inputSize; + private final HairMetricCalculator metricCalculator; + + public OnnxFaceParsingHairAnalysisAdapter( + ObjectMapper objectMapper, + @Value("${app.ai.model-path}") String modelPath, + @Value("${app.ai.model-version}") String modelVersion, + @Value("${app.ai.input-size}") int inputSize, + @Value("${app.ai.minimum-confidence-score}") double minimumConfidenceScore + ) { + if (modelPath == null || modelPath.isBlank()) { + throw new IllegalStateException( + "HAIR_ANALYSIS_ENABLED=true 이면 HAIR_ANALYSIS_MODEL_PATH 가 필요합니다"); + } + Path path = Path.of(modelPath).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path) || !Files.isReadable(path)) { + throw new IllegalStateException("읽을 수 있는 ONNX 모델 파일이 없습니다: " + path); + } + if (inputSize < 128 || inputSize > 1024) { + throw new IllegalArgumentException("분석 입력 크기는 128~1024 여야 합니다"); + } + try { + environment = OrtEnvironment.getEnvironment(); + OrtSession.SessionOptions options = new OrtSession.SessionOptions(); + options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); + session = environment.createSession(path.toString(), options); + if (session.getInputNames().size() != 1) { + throw new IllegalStateException("얼굴 파싱 모델 입력은 하나여야 합니다"); + } + inputName = session.getInputNames().iterator().next(); + } catch (OrtException exception) { + throw new IllegalStateException("ONNX 얼굴 파싱 모델을 열 수 없습니다", exception); + } + this.inputSize = inputSize; + this.metricCalculator = new HairMetricCalculator( + objectMapper, modelVersion, minimumConfidenceScore); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public HairAnalysisOutcome analyze(byte[] imageBytes) { + final BufferedImage image; + try { + image = decode(imageBytes); + } catch (UnsupportedImageException unsupported) { + return new HairAnalysisOutcome.Unavailable( + "UNSUPPORTED_IMAGE_FORMAT", unsupported.getMessage()); + } + BufferedImage resized = resize(image, inputSize, inputSize); + float[] input = normalizedChannelsFirst(resized); + long[] shape = {1, 3, inputSize, inputSize}; + + try (OnnxTensor tensor = OnnxTensor.createTensor( + environment, FloatBuffer.wrap(input), shape); + OrtSession.Result result = session.run(Map.of(inputName, tensor))) { + Object value = result.get(0).getValue(); + if (!(value instanceof float[][][][] logits) + || logits.length != 1 || logits[0].length < 19) { + throw new IllegalStateException("얼굴 파싱 모델 출력 모양이 [1,19,H,W]가 아닙니다"); + } + FaceParsingOutput parsing = FaceParsingOutput.from(resized, logits[0]); + return metricCalculator.calculate(parsing); + } catch (OrtException exception) { + throw new IllegalStateException("ONNX 얼굴 파싱 추론에 실패했습니다", exception); + } + } + + private static BufferedImage decode(byte[] imageBytes) { + if (imageBytes == null || imageBytes.length == 0) { + throw new IllegalArgumentException("분석할 이미지가 비어 있습니다"); + } + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (image == null) { + throw new UnsupportedImageException(); + } + return image; + } catch (IOException exception) { + throw new UnsupportedImageException(exception); + } + } + + private static BufferedImage resize(BufferedImage source, int width, int height) { + BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = resized.createGraphics(); + try { + graphics.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(source, 0, 0, width, height, null); + } finally { + graphics.dispose(); + } + return resized; + } + + private static float[] normalizedChannelsFirst(BufferedImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + int plane = width * height; + float[] input = new float[3 * plane]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int rgb = image.getRGB(x, y); + int index = y * width + x; + input[index] = normalize((rgb >>> 16) & 0xff, 0); + input[plane + index] = normalize((rgb >>> 8) & 0xff, 1); + input[2 * plane + index] = normalize(rgb & 0xff, 2); + } + } + return input; + } + + private static float normalize(int channel, int index) { + return ((channel / 255.0f) - IMAGE_MEAN[index]) / IMAGE_STD[index]; + } + + @PreDestroy + void close() throws OrtException { + session.close(); + } + + /** ImageIO가 디코딩하지 못하는 HEIC 등은 작업 실패가 아니라 재촬영/변환 안내 대상이다. */ + static final class UnsupportedImageException extends RuntimeException { + UnsupportedImageException() { + super("JPEG 또는 PNG 이미지만 분석할 수 있습니다"); + } + + UnsupportedImageException(Throwable cause) { + super("JPEG 또는 PNG 이미지만 분석할 수 있습니다", cause); + } + } +} diff --git a/src/main/java/com/heddy/adapter/out/ai/UnavailableHairAnalysisAdapter.java b/src/main/java/com/heddy/adapter/out/ai/UnavailableHairAnalysisAdapter.java new file mode 100644 index 0000000..ba1077a --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/ai/UnavailableHairAnalysisAdapter.java @@ -0,0 +1,23 @@ +package com.heddy.adapter.out.ai; + +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.port.out.HairAnalysisEnginePort; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** 모델 기능을 끈 환경에서 더미 점수를 만들지 않고 분석 접수를 막는 구현. */ +@Component +@ConditionalOnProperty(prefix = "app.ai", name = "enabled", havingValue = "false", + matchIfMissing = true) +public class UnavailableHairAnalysisAdapter implements HairAnalysisEnginePort { + + @Override + public boolean isReady() { + return false; + } + + @Override + public HairAnalysisOutcome analyze(byte[] imageBytes) { + throw new IllegalStateException("로컬 헤어 분석 모델이 설정되지 않았습니다"); + } +} diff --git a/src/main/java/com/heddy/adapter/out/storage/S3FileStorageAdapter.java b/src/main/java/com/heddy/adapter/out/storage/S3FileStorageAdapter.java index 69cf16c..a56b99e 100644 --- a/src/main/java/com/heddy/adapter/out/storage/S3FileStorageAdapter.java +++ b/src/main/java/com/heddy/adapter/out/storage/S3FileStorageAdapter.java @@ -21,6 +21,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.net.URI; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.Map; @@ -100,6 +101,38 @@ public URI createDownloadUrl(StoredFile file) { .build()).url()); } + @Override + public byte[] readObject(StoredFile file, long maximumBytes) { + if (maximumBytes <= 0 || maximumBytes >= Integer.MAX_VALUE) { + throw new IllegalArgumentException("maximumBytes 는 1~2147483646 이어야 합니다"); + } + if (!file.isReady()) { + throw new IllegalStateException("READY 파일만 내부에서 읽을 수 있습니다"); + } + if (file.fileSize() > maximumBytes) { + throw new IllegalArgumentException("분석 입력 파일이 허용 크기를 초과했습니다"); + } + try (var input = s3Client.getObject(GetObjectRequest.builder() + .bucket(bucket) + .key(file.objectKey()) + .build())) { + Long contentLength = input.response().contentLength(); + if (contentLength != null && (contentLength == 0 || contentLength > maximumBytes)) { + throw new IllegalArgumentException( + "스토리지 객체 크기가 분석 허용 범위를 벗어났습니다"); + } + // 메타데이터가 틀리거나 없는 S3 호환 스토리지도 메모리를 무제한 사용하지 못하게 한다. + byte[] bytes = input.readNBytes(Math.toIntExact(maximumBytes + 1)); + if (bytes.length == 0 || bytes.length > maximumBytes) { + throw new IllegalArgumentException( + "스토리지 객체 크기가 분석 허용 범위를 벗어났습니다"); + } + return bytes; + } catch (IOException exception) { + throw new IllegalStateException("스토리지 객체를 읽을 수 없습니다", exception); + } + } + @Override public Optional findObject(String objectKey) { try { diff --git a/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java b/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java new file mode 100644 index 0000000..7ab9c5e --- /dev/null +++ b/src/main/java/com/heddy/application/analysis/service/AnalysisApplicationService.java @@ -0,0 +1,172 @@ +package com.heddy.application.analysis.service; + +import com.heddy.domain.analysis.model.AnalysisJob; +import com.heddy.domain.analysis.model.AnalysisJobStatus; +import com.heddy.domain.analysis.model.AnalysisResult; +import com.heddy.domain.analysis.port.in.GetAnalysisJobUseCase; +import com.heddy.domain.analysis.port.in.GetAnalysisUseCase; +import com.heddy.domain.analysis.port.in.RequestAnalysisUseCase; +import com.heddy.domain.analysis.port.in.RetryAnalysisUseCase; +import com.heddy.domain.analysis.port.out.AnalysisJobRepositoryPort; +import com.heddy.domain.analysis.port.out.AnalysisOverlayRepositoryPort; +import com.heddy.domain.analysis.port.out.AnalysisResultRepositoryPort; +import com.heddy.domain.analysis.port.out.HairAnalysisEnginePort; +import com.heddy.domain.file.model.FilePurpose; +import com.heddy.domain.file.model.StoredFile; +import com.heddy.domain.file.port.out.FileRepositoryPort; +import com.heddy.domain.treatment.model.ImageType; +import com.heddy.domain.treatment.model.TreatmentPhoto; +import com.heddy.domain.treatment.model.TreatmentRecord; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import com.heddy.global.error.ApplicationException; +import com.heddy.global.error.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Comparator; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class AnalysisApplicationService implements RequestAnalysisUseCase, GetAnalysisJobUseCase, + RetryAnalysisUseCase, GetAnalysisUseCase { + + private final TreatmentRecordRepositoryPort recordRepositoryPort; + private final FileRepositoryPort fileRepositoryPort; + private final AnalysisJobRepositoryPort jobRepositoryPort; + private final AnalysisResultRepositoryPort resultRepositoryPort; + private final AnalysisOverlayRepositoryPort overlayRepositoryPort; + private final HairAnalysisEnginePort enginePort; + private final ApplicationEventPublisher eventPublisher; + private final int maximumAttempts; + + public AnalysisApplicationService( + TreatmentRecordRepositoryPort recordRepositoryPort, + FileRepositoryPort fileRepositoryPort, + AnalysisJobRepositoryPort jobRepositoryPort, + AnalysisResultRepositoryPort resultRepositoryPort, + AnalysisOverlayRepositoryPort overlayRepositoryPort, + HairAnalysisEnginePort enginePort, + ApplicationEventPublisher eventPublisher, + @Value("${app.ai.max-attempts}") int maximumAttempts + ) { + this.recordRepositoryPort = recordRepositoryPort; + this.fileRepositoryPort = fileRepositoryPort; + this.jobRepositoryPort = jobRepositoryPort; + this.resultRepositoryPort = resultRepositoryPort; + this.overlayRepositoryPort = overlayRepositoryPort; + this.enginePort = enginePort; + this.eventPublisher = eventPublisher; + this.maximumAttempts = maximumAttempts; + } + + @Override + @Transactional + public AnalysisJob request(RequestAnalysisUseCase.Command command) { + requireEngine(); + TreatmentRecord record = ownedRecord(command.requesterId(), command.recordId()); + TreatmentPhoto photo = selectPhoto(record, command.photoId()); + requireReadyInput(command.requesterId(), photo); + if (jobRepositoryPort.findInProgressByPhotoId(photo.photoId()).isPresent()) { + throw new ApplicationException(ErrorCode.ANALYSIS_ALREADY_IN_PROGRESS); + } + try { + AnalysisJob saved = jobRepositoryPort.insert(AnalysisJob.create( + command.requesterId(), record.recordId(), photo.photoId(), Instant.now())); + eventPublisher.publishEvent(new AnalysisJobAcceptedEvent( + saved.jobId(), saved.userId())); + return saved; + } catch (DataIntegrityViolationException concurrentRequest) { + throw new ApplicationException(ErrorCode.ANALYSIS_ALREADY_IN_PROGRESS); + } + } + + @Override + public GetAnalysisJobUseCase.Result get(GetAnalysisJobUseCase.Query query) { + AnalysisJob job = jobRepositoryPort.findByIdAndUserId(query.jobId(), query.requesterId()) + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + UUID analysisId = resultRepositoryPort.findByJobId(job.jobId()) + .map(AnalysisResult::analysisId) + .orElse(null); + return new GetAnalysisJobUseCase.Result(job, analysisId); + } + + @Override + @Transactional + public AnalysisJob retry(RetryAnalysisUseCase.Command command) { + requireEngine(); + AnalysisJob failed = jobRepositoryPort.findByIdAndUserId( + command.jobId(), command.requesterId()) + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + if (failed.status() != AnalysisJobStatus.FAILED) { + throw new ApplicationException(ErrorCode.ANALYSIS_RETRY_NOT_ALLOWED); + } + if (failed.attemptCount() >= maximumAttempts) { + throw new ApplicationException(ErrorCode.ANALYSIS_RETRY_LIMIT_EXCEEDED); + } + if (failed.photoId() == null) { + throw new ApplicationException(ErrorCode.ANALYSIS_PHOTO_REQUIRED); + } + TreatmentRecord record = ownedRecord(command.requesterId(), failed.recordId()); + TreatmentPhoto photo = selectPhoto(record, failed.photoId()); + requireReadyInput(command.requesterId(), photo); + AnalysisJob retried = jobRepositoryPort.insert(failed.retry(Instant.now())); + eventPublisher.publishEvent(new AnalysisJobAcceptedEvent( + retried.jobId(), retried.userId())); + return retried; + } + + @Override + public GetAnalysisUseCase.Result get(GetAnalysisUseCase.Query query) { + AnalysisResult result = resultRepositoryPort.findByIdAndUserId( + query.analysisId(), query.requesterId()) + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + AnalysisJobStatus status = jobRepositoryPort.findByIdAndUserId( + result.jobId(), query.requesterId()) + .map(AnalysisJob::status) + .orElseThrow(() -> new IllegalStateException( + "분석 결과에 연결된 작업이 없습니다: " + result.analysisId())); + return new GetAnalysisUseCase.Result(result, status, + overlayRepositoryPort.findByAnalysisId(result.analysisId())); + } + + private void requireEngine() { + if (!enginePort.isReady()) { + throw new ApplicationException(ErrorCode.ANALYSIS_ENGINE_UNAVAILABLE); + } + } + + private TreatmentRecord ownedRecord(UUID userId, UUID recordId) { + return recordRepositoryPort.findByIdAndUserId(recordId, userId) + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + } + + private TreatmentPhoto selectPhoto(TreatmentRecord record, UUID requestedPhotoId) { + if (requestedPhotoId != null) { + return record.photos().stream() + .filter(photo -> photo.photoId().equals(requestedPhotoId)) + .findFirst() + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + } + return record.photos().stream() + .filter(photo -> photo.imageType() == ImageType.AFTER) + .min(Comparator.comparingInt(TreatmentPhoto::sortOrder)) + .orElseThrow(() -> new ApplicationException(ErrorCode.ANALYSIS_PHOTO_REQUIRED)); + } + + private StoredFile requireReadyInput(UUID userId, TreatmentPhoto photo) { + StoredFile file = fileRepositoryPort.findById(photo.fileId()) + .orElseThrow(() -> new ApplicationException(ErrorCode.RESOURCE_NOT_FOUND)); + if (!userId.equals(file.userId())) { + throw new ApplicationException(ErrorCode.FORBIDDEN_RESOURCE); + } + if (!file.isReady() || file.purpose() != FilePurpose.TREATMENT_PHOTO) { + throw new ApplicationException(ErrorCode.ANALYSIS_INPUT_NOT_READY); + } + return file; + } +} diff --git a/src/main/java/com/heddy/application/analysis/service/AnalysisJobAcceptedEvent.java b/src/main/java/com/heddy/application/analysis/service/AnalysisJobAcceptedEvent.java new file mode 100644 index 0000000..4680834 --- /dev/null +++ b/src/main/java/com/heddy/application/analysis/service/AnalysisJobAcceptedEvent.java @@ -0,0 +1,7 @@ +package com.heddy.application.analysis.service; + +import java.util.UUID; + +/** 작업 행 커밋 뒤 로컬 모델 워커를 깨우는 내부 사건. */ +public record AnalysisJobAcceptedEvent(UUID jobId, UUID userId) { +} diff --git a/src/main/java/com/heddy/application/analysis/service/AnalysisLifecycleService.java b/src/main/java/com/heddy/application/analysis/service/AnalysisLifecycleService.java new file mode 100644 index 0000000..f47c9f9 --- /dev/null +++ b/src/main/java/com/heddy/application/analysis/service/AnalysisLifecycleService.java @@ -0,0 +1,103 @@ +package com.heddy.application.analysis.service; + +import com.heddy.domain.analysis.model.AnalysisJob; +import com.heddy.domain.analysis.model.AnalysisJobStatus; +import com.heddy.domain.analysis.model.AnalysisResult; +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.port.out.AnalysisJobRepositoryPort; +import com.heddy.domain.analysis.port.out.AnalysisResultRepositoryPort; +import com.heddy.domain.file.model.StoredFile; +import com.heddy.domain.file.port.out.FileRepositoryPort; +import com.heddy.domain.treatment.model.TreatmentPhoto; +import com.heddy.domain.treatment.model.TreatmentRecord; +import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.UUID; + +/** 느린 모델 추론 바깥의 짧은 DB 트랜잭션들을 담당한다. */ +@Service +public class AnalysisLifecycleService { + + private final AnalysisJobRepositoryPort jobRepositoryPort; + private final AnalysisResultRepositoryPort resultRepositoryPort; + private final TreatmentRecordRepositoryPort recordRepositoryPort; + private final FileRepositoryPort fileRepositoryPort; + + public AnalysisLifecycleService( + AnalysisJobRepositoryPort jobRepositoryPort, + AnalysisResultRepositoryPort resultRepositoryPort, + TreatmentRecordRepositoryPort recordRepositoryPort, + FileRepositoryPort fileRepositoryPort + ) { + this.jobRepositoryPort = jobRepositoryPort; + this.resultRepositoryPort = resultRepositoryPort; + this.recordRepositoryPort = recordRepositoryPort; + this.fileRepositoryPort = fileRepositoryPort; + } + + @Transactional + public WorkItem start(UUID jobId, UUID userId) { + AnalysisJob job = jobRepositoryPort.findByIdAndUserId(jobId, userId) + .orElseThrow(() -> new IllegalStateException("분석 작업이 없습니다: " + jobId)); + if (job.photoId() == null) { + throw new IllegalStateException("분석 작업의 사진이 없습니다"); + } + TreatmentRecord record = recordRepositoryPort.findByIdAndUserId(job.recordId(), userId) + .orElseThrow(() -> new IllegalStateException("분석 대상 기록이 없습니다")); + TreatmentPhoto photo = record.photos().stream() + .filter(candidate -> candidate.photoId().equals(job.photoId())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("분석 대상 사진이 없습니다")); + StoredFile file = fileRepositoryPort.findById(photo.fileId()) + .filter(StoredFile::isReady) + .orElseThrow(() -> new IllegalStateException("분석 대상 파일이 READY 상태가 아닙니다")); + AnalysisJob processing = jobRepositoryPort.update(job.start(Instant.now()).progressTo(10)); + return new WorkItem(processing, file); + } + + @Transactional + public void finish(UUID jobId, UUID userId, HairAnalysisOutcome outcome) { + AnalysisJob current = jobRepositoryPort.findByIdAndUserId(jobId, userId) + .orElseThrow(() -> new IllegalStateException("분석 작업이 없습니다: " + jobId)); + // 분석 도중 사진이 교체되면 staleness 서비스가 STALE 로 만든다. 옛 사진 결과를 저장하지 않는다. + if (current.status() == AnalysisJobStatus.STALE) { + return; + } + Instant now = Instant.now(); + if (outcome instanceof HairAnalysisOutcome.Unavailable unavailable) { + jobRepositoryPort.update(current.markUnavailable( + unavailable.code(), unavailable.message(), now)); + return; + } + HairAnalysisOutcome.Succeeded succeeded = (HairAnalysisOutcome.Succeeded) outcome; + // 워커가 같은 사건을 중복 전달받아도 작업 하나에 결과를 두 번 만들지 않는다. + if (resultRepositoryPort.findByJobId(current.jobId()).isEmpty()) { + var prediction = succeeded.prediction(); + resultRepositoryPort.insert(AnalysisResult.create( + current, prediction.metrics(), prediction.confidence(), + prediction.modelVersion(), prediction.summary(), prediction.evidence(), now)); + } + jobRepositoryPort.update(current.succeed(now)); + } + + @Transactional + public void fail(UUID jobId, UUID userId, Throwable failure) { + jobRepositoryPort.findByIdAndUserId(jobId, userId) + .filter(job -> job.status() == AnalysisJobStatus.PENDING + || job.status() == AnalysisJobStatus.PROCESSING) + .map(job -> job.fail("ANALYSIS_EXECUTION_FAILED", + safeMessage(failure), Instant.now())) + .ifPresent(jobRepositoryPort::update); + } + + private static String safeMessage(Throwable failure) { + // 실제 예외는 워커 로그에만 남긴다. 파일 경로·S3 키·모델 경로가 API로 노출되면 안 된다. + return "헤어 분석 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."; + } + + public record WorkItem(AnalysisJob job, StoredFile file) { + } +} diff --git a/src/main/java/com/heddy/application/analysis/service/LocalAnalysisWorker.java b/src/main/java/com/heddy/application/analysis/service/LocalAnalysisWorker.java new file mode 100644 index 0000000..8e781fc --- /dev/null +++ b/src/main/java/com/heddy/application/analysis/service/LocalAnalysisWorker.java @@ -0,0 +1,51 @@ +package com.heddy.application.analysis.service; + +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.port.out.HairAnalysisEnginePort; +import com.heddy.domain.file.port.out.FileStoragePort; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 작업 커밋 이후 S3 원본을 읽고 로컬 ONNX 모델을 실행한다. */ +@Component +public class LocalAnalysisWorker { + + private static final Logger log = LoggerFactory.getLogger(LocalAnalysisWorker.class); + + private final AnalysisLifecycleService lifecycleService; + private final FileStoragePort fileStoragePort; + private final HairAnalysisEnginePort enginePort; + private final long maximumImageBytes; + + public LocalAnalysisWorker( + AnalysisLifecycleService lifecycleService, + FileStoragePort fileStoragePort, + HairAnalysisEnginePort enginePort, + @Value("${app.ai.maximum-image-bytes}") long maximumImageBytes + ) { + this.lifecycleService = lifecycleService; + this.fileStoragePort = fileStoragePort; + this.enginePort = enginePort; + this.maximumImageBytes = maximumImageBytes; + } + + @Async("hairAnalysisTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(AnalysisJobAcceptedEvent event) { + try { + AnalysisLifecycleService.WorkItem work = lifecycleService.start( + event.jobId(), event.userId()); + byte[] image = fileStoragePort.readObject(work.file(), maximumImageBytes); + HairAnalysisOutcome outcome = enginePort.analyze(image); + lifecycleService.finish(event.jobId(), event.userId(), outcome); + } catch (Exception failure) { + log.error("로컬 헤어 분석 실패. jobId={}", event.jobId(), failure); + lifecycleService.fail(event.jobId(), event.userId(), failure); + } + } +} diff --git a/src/main/java/com/heddy/domain/analysis/model/HairAnalysisOutcome.java b/src/main/java/com/heddy/domain/analysis/model/HairAnalysisOutcome.java new file mode 100644 index 0000000..538d9b0 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/model/HairAnalysisOutcome.java @@ -0,0 +1,25 @@ +package com.heddy.domain.analysis.model; + +import java.util.Objects; + +/** 모델 실행의 정상 종료 결과. 촬영 부적합은 시스템 장애와 분리한다. */ +public sealed interface HairAnalysisOutcome + permits HairAnalysisOutcome.Succeeded, HairAnalysisOutcome.Unavailable { + + record Succeeded(HairAnalysisPrediction prediction) implements HairAnalysisOutcome { + public Succeeded { + Objects.requireNonNull(prediction, "prediction"); + } + } + + record Unavailable(String code, String message) implements HairAnalysisOutcome { + public Unavailable { + if (code == null || code.isBlank()) { + throw new IllegalArgumentException("분석 불가 코드가 필요합니다"); + } + if (message == null || message.isBlank()) { + throw new IllegalArgumentException("분석 불가 안내가 필요합니다"); + } + } + } +} diff --git a/src/main/java/com/heddy/domain/analysis/model/HairAnalysisPrediction.java b/src/main/java/com/heddy/domain/analysis/model/HairAnalysisPrediction.java new file mode 100644 index 0000000..ec0e793 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/model/HairAnalysisPrediction.java @@ -0,0 +1,24 @@ +package com.heddy.domain.analysis.model; + +import java.util.Map; +import java.util.Objects; + +/** 로컬 모델이 사진 한 장에서 산출한 성공 결과. DB 식별자는 애플리케이션 계층이 붙인다. */ +public record HairAnalysisPrediction( + Map metrics, + MetricScore confidence, + String modelVersion, + String summary, + String evidence +) { + public HairAnalysisPrediction { + metrics = metrics == null ? Map.of() : Map.copyOf(metrics); + for (MetricType type : MetricType.values()) { + Objects.requireNonNull(metrics.get(type), "빠진 분석 지표: " + type); + } + Objects.requireNonNull(confidence, "confidence"); + if (modelVersion == null || modelVersion.isBlank()) { + throw new IllegalArgumentException("modelVersion 은 필수입니다"); + } + } +} diff --git a/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisJobUseCase.java b/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisJobUseCase.java new file mode 100644 index 0000000..71b4270 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisJobUseCase.java @@ -0,0 +1,17 @@ +package com.heddy.domain.analysis.port.in; + +import com.heddy.domain.analysis.model.AnalysisJob; + +import java.util.UUID; + +/** 비동기 분석 작업 상태를 조회한다. */ +public interface GetAnalysisJobUseCase { + + Result get(Query query); + + record Query(UUID requesterId, UUID jobId) { + } + + record Result(AnalysisJob job, UUID analysisId) { + } +} diff --git a/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisUseCase.java b/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisUseCase.java new file mode 100644 index 0000000..ea98598 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/port/in/GetAnalysisUseCase.java @@ -0,0 +1,24 @@ +package com.heddy.domain.analysis.port.in; + +import com.heddy.domain.analysis.model.AnalysisJobStatus; +import com.heddy.domain.analysis.model.AnalysisOverlay; +import com.heddy.domain.analysis.model.AnalysisResult; + +import java.util.List; +import java.util.UUID; + +/** 분석 결과 식별자로 결과를 조회한다. */ +public interface GetAnalysisUseCase { + + Result get(Query query); + + record Query(UUID requesterId, UUID analysisId) { + } + + record Result(AnalysisResult analysis, AnalysisJobStatus status, + List overlays) { + public Result { + overlays = List.copyOf(overlays); + } + } +} diff --git a/src/main/java/com/heddy/domain/analysis/port/in/RequestAnalysisUseCase.java b/src/main/java/com/heddy/domain/analysis/port/in/RequestAnalysisUseCase.java new file mode 100644 index 0000000..8a95a6e --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/port/in/RequestAnalysisUseCase.java @@ -0,0 +1,14 @@ +package com.heddy.domain.analysis.port.in; + +import com.heddy.domain.analysis.model.AnalysisJob; + +import java.util.UUID; + +/** 시술기록 사진의 로컬 모델 분석 작업을 접수한다. */ +public interface RequestAnalysisUseCase { + + AnalysisJob request(Command command); + + record Command(UUID requesterId, UUID recordId, UUID photoId) { + } +} diff --git a/src/main/java/com/heddy/domain/analysis/port/in/RetryAnalysisUseCase.java b/src/main/java/com/heddy/domain/analysis/port/in/RetryAnalysisUseCase.java new file mode 100644 index 0000000..98d5395 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/port/in/RetryAnalysisUseCase.java @@ -0,0 +1,14 @@ +package com.heddy.domain.analysis.port.in; + +import com.heddy.domain.analysis.model.AnalysisJob; + +import java.util.UUID; + +/** 시스템 실패로 끝난 작업을 새 작업으로 재접수한다. */ +public interface RetryAnalysisUseCase { + + AnalysisJob retry(Command command); + + record Command(UUID requesterId, UUID jobId) { + } +} diff --git a/src/main/java/com/heddy/domain/analysis/port/out/HairAnalysisEnginePort.java b/src/main/java/com/heddy/domain/analysis/port/out/HairAnalysisEnginePort.java new file mode 100644 index 0000000..a1d9aa7 --- /dev/null +++ b/src/main/java/com/heddy/domain/analysis/port/out/HairAnalysisEnginePort.java @@ -0,0 +1,13 @@ +package com.heddy.domain.analysis.port.out; + +import com.heddy.domain.analysis.model.HairAnalysisOutcome; + +/** 외부 API 없이 애플리케이션 프로세스 안의 로컬 모델을 실행하는 경계. */ +public interface HairAnalysisEnginePort { + + /** 모델 파일을 읽고 추론 세션을 만들 수 있는 상태인지 확인한다. */ + boolean isReady(); + + /** 이미지 바이트를 로컬 모델로 분석한다. 시스템 오류는 예외로, 촬영 부적합은 결과로 반환한다. */ + HairAnalysisOutcome analyze(byte[] imageBytes); +} diff --git a/src/main/java/com/heddy/domain/file/port/out/FileStoragePort.java b/src/main/java/com/heddy/domain/file/port/out/FileStoragePort.java index 05803ca..9cf1312 100644 --- a/src/main/java/com/heddy/domain/file/port/out/FileStoragePort.java +++ b/src/main/java/com/heddy/domain/file/port/out/FileStoragePort.java @@ -22,6 +22,12 @@ public interface FileStoragePort { /** 조회용 GET URL. 저장하지 않고 볼 때마다 발급한다. */ URI createDownloadUrl(StoredFile file); + /** + * 서버 내부 처리용 객체 읽기. 사용자 응답에는 바이트나 object key 를 노출하지 않는다. + * 분석기는 검증이 끝난 READY 파일만 넘기며, 구현은 메모리 고갈을 막기 위해 상한을 다시 본다. + */ + byte[] readObject(StoredFile file, long maximumBytes); + /** 객체의 실제 상태. 올라온 적이 없으면 비어 있다. */ Optional findObject(String objectKey); diff --git a/src/main/java/com/heddy/global/error/ErrorCode.java b/src/main/java/com/heddy/global/error/ErrorCode.java index 929eea6..2dea01a 100644 --- a/src/main/java/com/heddy/global/error/ErrorCode.java +++ b/src/main/java/com/heddy/global/error/ErrorCode.java @@ -18,7 +18,13 @@ public enum ErrorCode { FILE_OBJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_OBJECT_NOT_FOUND", "업로드된 객체를 찾을 수 없습니다."), FILE_UPLOAD_EXPIRED(HttpStatus.UNPROCESSABLE_ENTITY, "FILE_UPLOAD_EXPIRED", "만료된 업로드 세션입니다."), FILE_INVALID_STATE(HttpStatus.CONFLICT, "FILE_INVALID_STATE", "현재 상태에서는 요청한 처리를 할 수 없습니다."), - FILE_CONCURRENT_MODIFICATION(HttpStatus.CONFLICT, "FILE_CONCURRENT_MODIFICATION", "다른 요청이 파일 상태를 먼저 변경했습니다."); + FILE_CONCURRENT_MODIFICATION(HttpStatus.CONFLICT, "FILE_CONCURRENT_MODIFICATION", "다른 요청이 파일 상태를 먼저 변경했습니다."), + ANALYSIS_ENGINE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "ANALYSIS_ENGINE_UNAVAILABLE", "헤어 분석 모델을 사용할 수 없습니다."), + ANALYSIS_ALREADY_IN_PROGRESS(HttpStatus.CONFLICT, "ANALYSIS_ALREADY_IN_PROGRESS", "같은 사진의 분석이 이미 진행 중입니다."), + ANALYSIS_PHOTO_REQUIRED(HttpStatus.UNPROCESSABLE_ENTITY, "ANALYSIS_PHOTO_REQUIRED", "분석할 시술 후 사진이 필요합니다."), + ANALYSIS_INPUT_NOT_READY(HttpStatus.UNPROCESSABLE_ENTITY, "ANALYSIS_INPUT_NOT_READY", "분석할 사진의 업로드가 완료되지 않았습니다."), + ANALYSIS_RETRY_NOT_ALLOWED(HttpStatus.CONFLICT, "ANALYSIS_RETRY_NOT_ALLOWED", "실패한 분석만 재시도할 수 있습니다."), + ANALYSIS_RETRY_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY, "ANALYSIS_RETRY_LIMIT_EXCEEDED", "분석 재시도 횟수를 초과했습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b112396..dabfa0e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,9 +81,16 @@ app: token-purge-interval-ms: ${USED_REAUTHENTICATION_TOKEN_PURGE_INTERVAL_MS:3600000} token-purge-initial-delay-ms: ${USED_REAUTHENTICATION_TOKEN_PURGE_INITIAL_DELAY_MS:60000} ai: - base-url: ${AI_SERVER_URL:http://localhost:8000} - connect-timeout-seconds: 3 - read-timeout-seconds: 30 + # 사용자 사진은 외부 생성형 AI API 로 전송하지 않는다. 로컬 ONNX 모델이 없으면 + # 분석 API 는 임의 점수를 만들지 않고 503 으로 접수를 거부한다. + enabled: ${HAIR_ANALYSIS_ENABLED:false} + model-path: ${HAIR_ANALYSIS_MODEL_PATH:} + model-version: ${HAIR_ANALYSIS_MODEL_VERSION:segformer-face-parsing-cv-v1} + input-size: ${HAIR_ANALYSIS_INPUT_SIZE:512} + minimum-confidence-score: ${HAIR_ANALYSIS_MIN_CONFIDENCE_SCORE:50} + worker-threads: ${HAIR_ANALYSIS_WORKER_THREADS:1} + maximum-image-bytes: ${HAIR_ANALYSIS_MAX_IMAGE_BYTES:10485760} + max-attempts: ${HAIR_ANALYSIS_MAX_ATTEMPTS:3} auth: jwt-secret: ${JWT_SECRET} access-token-seconds: 3600 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 d1b1b8f..8338faa 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 @@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.nullValue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -119,6 +120,40 @@ void requiresAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + void refusesToCreateDummyScoresWhenTheLocalModelIsDisabled() throws Exception { + mockMvc.perform(post("/treatment-records/{recordId}/analyses", recordId) + .with(authentication(userAuthentication(USER_ID))) + .contentType("application/json") + .content("{}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error.code").value("ANALYSIS_ENGINE_UNAVAILABLE")); + + assertThat(jdbcTemplate.queryForObject( + "SELECT count(*) FROM analysis_jobs WHERE record_id = ?", Integer.class, recordId)) + .isZero(); + } + + @Test + void readsAJobAndItsResultThroughTheNewEndpoints() throws Exception { + UUID jobId = insertJob("SUCCEEDED"); + UUID analysisId = insertResult(jobId); + + mockMvc.perform(get("/analysis-jobs/{jobId}", jobId) + .with(authentication(userAuthentication(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.job_id").value(jobId.toString())) + .andExpect(jsonPath("$.data.status").value("SUCCEEDED")) + .andExpect(jsonPath("$.data.analysis_id").value(analysisId.toString())) + .andExpect(jsonPath("$.data.failure", nullValue())); + + mockMvc.perform(get("/analyses/{analysisId}", analysisId) + .with(authentication(userAuthentication(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.analysis_id").value(analysisId.toString())) + .andExpect(jsonPath("$.data.metrics", hasSize(4))); + } + // ------------------------------------------------------------------ 헬퍼 private UsernamePasswordAuthenticationToken userAuthentication(UUID userId) { @@ -135,7 +170,8 @@ INSERT INTO analysis_jobs ( return jobId; } - private void insertResult(UUID jobId) { + private UUID insertResult(UUID jobId) { + UUID analysisId = UUID.randomUUID(); jdbcTemplate.update(""" INSERT INTO analysis_results ( analysis_id, job_id, user_id, record_id, photo_id, @@ -147,8 +183,9 @@ INSERT INTO analysis_results ( 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, + """, analysisId, jobId, USER_ID, recordId, photoId, "사진에서 거칠게 보이는 영역이 감지되었습니다", Timestamp.from(NOW)); + return analysisId; } private void insertUser(UUID userId, String email) { diff --git a/src/test/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfigTest.java b/src/test/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfigTest.java new file mode 100644 index 0000000..10f972b --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/ai/HairAnalysisAsyncConfigTest.java @@ -0,0 +1,23 @@ +package com.heddy.adapter.out.ai; + +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +class HairAnalysisAsyncConfigTest { + + @Test + void appliesBackpressureOnTheCallingThreadWhenTheQueueIsFull() { + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) + new HairAnalysisAsyncConfig().hairAnalysisTaskExecutor(1); + try { + assertThat(executor.getThreadPoolExecutor().getRejectedExecutionHandler()) + .isInstanceOf(ThreadPoolExecutor.CallerRunsPolicy.class); + } finally { + executor.shutdown(); + } + } +} diff --git a/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java b/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java new file mode 100644 index 0000000..1aedf6d --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/ai/HairMetricCalculatorTest.java @@ -0,0 +1,97 @@ +package com.heddy.adapter.out.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import com.heddy.domain.analysis.model.MetricType; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class HairMetricCalculatorTest { + + private final HairMetricCalculator calculator = new HairMetricCalculator( + new ObjectMapper(), "test-segformer-v1", 0); + + @Test + void neverProducesScoresWithoutHairFromTheSegmentationModel() { + int size = 64; + FaceParsingOutput output = new FaceParsingOutput(size, size, new int[size * size], + new byte[size * size], new float[size * size]); + + assertThat(calculator.calculate(output)) + .isInstanceOfSatisfying(HairAnalysisOutcome.Unavailable.class, + unavailable -> assertThat(unavailable.code()).isEqualTo("HAIR_NOT_DETECTED")); + } + + @Test + void returnsUnavailableWhenEyeCentersAreTooCloseToFormAnAxis() { + int size = 32; + int[] rgb = new int[size * size]; + byte[] labels = new byte[size * size]; + float[] hairProbability = new float[size * size]; + for (int y = 0; y < 4; y++) { + for (int x = 0; x < size; x++) { + int index = y * size + x; + labels[index] = HairMetricCalculator.HAIR_LABEL; + hairProbability[index] = 0.99f; + } + } + labels[16 * size + 16] = HairMetricCalculator.LEFT_EYE_LABEL; + labels[16 * size + 17] = HairMetricCalculator.RIGHT_EYE_LABEL; + + assertThat(calculator.calculate( + new FaceParsingOutput(size, size, rgb, labels, hairProbability))) + .isInstanceOfSatisfying(HairAnalysisOutcome.Unavailable.class, + unavailable -> assertThat(unavailable.code()).isEqualTo("FACE_NOT_FRONTAL")); + } + + @Test + void computesAllFourMetricsOnlyFromAValidModelSegmentation() { + int width = 96; + int height = 96; + int[] rgb = new int[width * height]; + byte[] labels = new byte[width * height]; + float[] hairProbability = new float[width * height]; + Arrays.fill(rgb, 0x00d2aa8a); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int index = y * width + x; + // 선명도 신호가 생기도록 미세한 체크 패턴을 둔다. + rgb[index] = ((x + y) & 1) == 0 ? 0x00604030 : 0x00806040; + if (y < 42 && x >= 12 && x < 84) { + labels[index] = HairMetricCalculator.HAIR_LABEL; + hairProbability[index] = 0.99f; + } else if (x >= 28 && x < 68 && y >= 42 && y < 90) { + labels[index] = HairMetricCalculator.SKIN_LABEL; + } + } + } + mark(labels, width, 36, 52, HairMetricCalculator.LEFT_EYE_LABEL); + mark(labels, width, 60, 52, HairMetricCalculator.RIGHT_EYE_LABEL); + mark(labels, width, 48, 65, HairMetricCalculator.NOSE_LABEL); + + HairAnalysisOutcome outcome = calculator.calculate( + new FaceParsingOutput(width, height, rgb, labels, hairProbability)); + + assertThat(outcome).isInstanceOfSatisfying(HairAnalysisOutcome.Succeeded.class, + succeeded -> { + assertThat(succeeded.prediction().modelVersion()) + .isEqualTo("test-segformer-v1"); + assertThat(succeeded.prediction().metrics()) + .containsOnlyKeys(MetricType.values()); + assertThat(succeeded.prediction().evidence()) + .contains("mean_hair_probability", "shape_mirrored_iou"); + }); + } + + private static void mark(byte[] labels, int width, int centerX, int centerY, int label) { + for (int y = centerY - 2; y <= centerY + 2; y++) { + for (int x = centerX - 3; x <= centerX + 3; x++) { + labels[y * width + x] = (byte) label; + } + } + } +} diff --git a/src/test/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapterModelTest.java b/src/test/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapterModelTest.java new file mode 100644 index 0000000..1dd8e8c --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/ai/OnnxFaceParsingHairAnalysisAdapterModelTest.java @@ -0,0 +1,36 @@ +package com.heddy.adapter.out.ai; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.heddy.domain.analysis.model.HairAnalysisOutcome; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 실제 가중치는 저장소에 넣지 않으므로 경로가 주어진 환경에서만 도는 모델 스모크 테스트. + */ +@EnabledIfEnvironmentVariable(named = "HAIR_ANALYSIS_TEST_MODEL", matches = ".+") +class OnnxFaceParsingHairAnalysisAdapterModelTest { + + @Test + void runsARealOnnxModelOnARealImage() throws Exception { + String modelPath = System.getenv("HAIR_ANALYSIS_TEST_MODEL"); + String imagePath = System.getenv("HAIR_ANALYSIS_TEST_IMAGE"); + assertThat(imagePath).as("HAIR_ANALYSIS_TEST_IMAGE").isNotBlank(); + + OnnxFaceParsingHairAnalysisAdapter adapter = + new OnnxFaceParsingHairAnalysisAdapter( + new ObjectMapper(), modelPath, "real-model-smoke-test", 512, 0); + try { + HairAnalysisOutcome outcome = adapter.analyze( + Files.readAllBytes(Path.of(imagePath))); + assertThat(outcome).isInstanceOf(HairAnalysisOutcome.Succeeded.class); + } finally { + adapter.close(); + } + } +} diff --git a/src/test/java/com/heddy/adapter/out/storage/S3FileStorageAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/storage/S3FileStorageAdapterIntegrationTest.java index 4ff2aac..c3b5a37 100644 --- a/src/test/java/com/heddy/adapter/out/storage/S3FileStorageAdapterIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/out/storage/S3FileStorageAdapterIntegrationTest.java @@ -137,6 +137,24 @@ void servesUploadedObjectThroughPresignedDownloadUrl() throws Exception { assertThat(response.body()).isEqualTo(CONTENT); } + @Test + void readsReadyObjectForInternalAnalysisWithoutExposingAUrl() throws Exception { + StoredFile pending = pendingPhoto(); + put(adapter.createUploadUrl(pending), pending.contentType(), CONTENT); + StoredFile ready = pending.markReady(new StorageObject(pending.contentType(), CONTENT.length)); + + assertThat(adapter.readObject(ready, 1024)).isEqualTo(CONTENT); + } + + @Test + void refusesToReadInternalObjectPastTheMemoryLimit() { + StoredFile pending = pendingPhoto(); + StoredFile ready = pending.markReady(new StorageObject(pending.contentType(), CONTENT.length)); + + assertThatThrownBy(() -> adapter.readObject(ready, CONTENT.length - 1L)) + .isInstanceOf(IllegalArgumentException.class); + } + // ------------------------------------------------------------------ 만료 /** diff --git a/src/test/java/com/heddy/application/file/service/UploadCancellationRaceIntegrationTest.java b/src/test/java/com/heddy/application/file/service/UploadCancellationRaceIntegrationTest.java index 2c1bd1c..c0b8281 100644 --- a/src/test/java/com/heddy/application/file/service/UploadCancellationRaceIntegrationTest.java +++ b/src/test/java/com/heddy/application/file/service/UploadCancellationRaceIntegrationTest.java @@ -215,6 +215,11 @@ public URI createDownloadUrl(StoredFile file) { throw new UnsupportedOperationException(); } + @Override + public byte[] readObject(StoredFile file, long maximumBytes) { + throw new UnsupportedOperationException(); + } + @Override public Optional findObject(String objectKey) { return Optional.of(UPLOADED);