diff --git a/.env.example b/.env.example
index 37e8206..f36299b 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,19 @@ S3_ENDPOINT=
# ── AI 서비스 ───────────────────────────────────────────────
AI_SERVICE_BASE_URL=http://localhost:8000
+AI_SERVICE_CONNECT_TIMEOUT=500ms
+AI_OCR_READ_TIMEOUT=18s
+AI_RULE_ENGINE_READ_TIMEOUT=2s
+AI_RESULT_READ_TIMEOUT=7s
+# 로컬 AI가 S3 IAM 조회를 사용하지 않을 때만 true.
+AI_OCR_PRESIGNED_FALLBACK_ENABLED=true
+
+# OCR/RuleEngine/Result 전체 작업을 수행하는 전용 비동기 풀.
+SCAN_ASYNC_CORE_POOL_SIZE=2
+SCAN_ASYNC_MAX_POOL_SIZE=2
+SCAN_ASYNC_QUEUE_CAPACITY=0
+SCAN_STALE_AFTER=2m
+SCAN_RECOVERY_INTERVAL_MS=60000
# ── CORS ────────────────────────────────────────────────────
CORS_ALLOWED_ORIGINS="http://localhost:5173,https://han-spoon.site"
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
index 4aee530..cc43dc8 100644
--- a/.github/workflows/deploy-prod.yml
+++ b/.github/workflows/deploy-prod.yml
@@ -29,7 +29,7 @@ env:
ECR_REPOSITORY: hanspoon-prod-backend
ECS_CLUSTER: hanspoon-prod-cluster
ECS_SERVICE: hanspoon-prod-app
- TASK_FAMILY: hanspoon-prod-app
+ DEPLOY_LOCK_TABLE: hanspoon-prod-deploy-lock
CONTAINER_NAME: backend
HEALTH_URL: https://api.han-spoon.site/actuator/health
@@ -50,8 +50,9 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- - name: Build jar
- run: ./gradlew bootJar -x test
+ # main CI와 배포가 동시에 시작될 수 있으므로 배포 워크플로도 품질 게이트를 통과해야 한다.
+ - name: Verify and build jar
+ run: ./gradlew spotlessCheck test bootJar
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
@@ -80,30 +81,75 @@ jobs:
fi
echo "uri=$IMAGE" >> "$GITHUB_OUTPUT"
+ # backend/ai 저장소가 같은 ECS 태스크 정의를 동시에 덮어쓰지 않도록 AWS에서 직렬화.
+ - name: Acquire ECS deploy lock
+ id: deploy-lock
+ env:
+ LOCK_OWNER: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }}
+ run: |
+ for i in $(seq 1 60); do
+ NOW="$(date +%s)"
+ EXPIRES_AT="$((NOW + 900))"
+ ITEM="$(jq -nc \
+ --arg owner "$LOCK_OWNER" \
+ --argjson expires "$EXPIRES_AT" \
+ '{lock_name:{S:"prod-ecs"},owner:{S:$owner},expires_at:{N:($expires|tostring)}}')"
+ VALUES="$(jq -nc --argjson now "$NOW" '{":now":{N:($now|tostring)}}')"
+
+ if aws dynamodb put-item \
+ --table-name "$DEPLOY_LOCK_TABLE" \
+ --item "$ITEM" \
+ --condition-expression 'attribute_not_exists(#lock) OR #expires < :now' \
+ --expression-attribute-names '{"#lock":"lock_name","#expires":"expires_at"}' \
+ --expression-attribute-values "$VALUES"; then
+ echo "owner=$LOCK_OWNER" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "Another ECS deployment is running... ($i/60)"
+ sleep 5
+ done
+ echo "Timed out waiting for ECS deploy lock"
+ exit 1
+
# Deploy to ECS
- - name: Fetch current task definition
+ # 패밀리의 최신 리비전이 아니라, 현재 서비스에 지정된 정확한 리비전을 기준으로 배포.
+ - name: Resolve deployed task definition
+ id: current-task
run: |
- aws ecs describe-task-definition \
- --task-definition "$TASK_FAMILY" \
- --query 'taskDefinition | {
- family: family,
- taskRoleArn: taskRoleArn,
- executionRoleArn: executionRoleArn,
- networkMode: networkMode,
- containerDefinitions: containerDefinitions,
- requiresCompatibilities: requiresCompatibilities,
- cpu: cpu,
- memory: memory,
- runtimePlatform: runtimePlatform
- }' > task-definition.json
+ TASK_DEFINITION_ARN="$(
+ aws ecs describe-services \
+ --cluster "$ECS_CLUSTER" \
+ --services "$ECS_SERVICE" \
+ --query 'services[0].taskDefinition' \
+ --output text
+ )"
+
+ if [ -z "$TASK_DEFINITION_ARN" ] || [ "$TASK_DEFINITION_ARN" = "None" ]; then
+ echo "Failed to resolve the task definition used by ECS service: $ECS_SERVICE"
+ exit 1
+ fi
+
+ echo "Using deployed task definition: $TASK_DEFINITION_ARN"
+ echo "arn=$TASK_DEFINITION_ARN" >> "$GITHUB_OUTPUT"
- name: Render task definition
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
- task-definition: task-definition.json
+ task-definition-arn: ${{ steps.current-task.outputs.arn }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.image.outputs.uri }}
+ environment-variables: |
+ AI_SERVICE_CONNECT_TIMEOUT=500ms
+ AI_OCR_READ_TIMEOUT=18s
+ AI_RULE_ENGINE_READ_TIMEOUT=2s
+ AI_RESULT_READ_TIMEOUT=7s
+ AI_OCR_PRESIGNED_FALLBACK_ENABLED=false
+ SCAN_ASYNC_CORE_POOL_SIZE=2
+ SCAN_ASYNC_MAX_POOL_SIZE=2
+ SCAN_ASYNC_QUEUE_CAPACITY=0
+ SCAN_STALE_AFTER=2m
+ SCAN_RECOVERY_INTERVAL_MS=60000
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
@@ -126,3 +172,16 @@ jobs:
done
echo "health check failed"
exit 1
+
+ - name: Release ECS deploy lock
+ if: ${{ always() && steps.deploy-lock.outcome == 'success' }}
+ env:
+ LOCK_OWNER: ${{ steps.deploy-lock.outputs.owner }}
+ run: |
+ VALUES="$(jq -nc --arg owner "$LOCK_OWNER" '{":owner":{S:$owner}}')"
+ aws dynamodb delete-item \
+ --table-name "$DEPLOY_LOCK_TABLE" \
+ --key '{"lock_name":{"S":"prod-ecs"}}' \
+ --condition-expression '#owner = :owner' \
+ --expression-attribute-names '{"#owner":"owner"}' \
+ --expression-attribute-values "$VALUES" || true
diff --git a/OCR_PRODUCTION_RUNBOOK.md b/OCR_PRODUCTION_RUNBOOK.md
new file mode 100644
index 0000000..5731d3a
--- /dev/null
+++ b/OCR_PRODUCTION_RUNBOOK.md
@@ -0,0 +1,54 @@
+# OCR 운영 배포·장애 대응 런북
+
+## 최초 배포 순서
+
+1. `infra/terraform`에서 `terraform plan`을 검토한 뒤 `terraform apply`한다.
+ - ECS task role의 `s3:GetObjectVersion` 권한
+ - GitHub Actions용 DynamoDB 배포 락과 IAM 권한
+ - S3 버전 및 수명주기 정책이 먼저 준비되어야 한다.
+2. AI 저장소의 `Deploy (prod)`를 실행한다.
+ - `storage_key + version_id + expected_etag` 기반 S3 IAM 조회를 지원하는 AI가 먼저 올라가야 한다.
+3. backend 저장소의 `Deploy (prod)`를 실행한다.
+ - 운영에서는 Presigned GET fallback을 끄므로 구버전 AI보다 먼저 배포하면 OCR 요청이 실패한다.
+4. 실제 메뉴판 한 장으로 업로드 → 스캔 → 결과 폴링까지 smoke test한다.
+
+최초 배포 이후에는 두 저장소 워크플로가 DynamoDB 락으로 ECS 태스크 정의 갱신을 직렬화한다. 각 워크플로는 패밀리의 최신 리비전이 아니라 ECS 서비스가 실제 사용 중인 리비전에서 시작하므로 반대쪽 컨테이너 이미지가 되돌아가지 않는다.
+
+## 프론트 업로드 계약
+
+`POST /api/v1/uploads/sas` 응답의 `uploadHeaders`를 임의로 재구성하지 말고 그대로 Presigned PUT 요청에 포함한다.
+
+```javascript
+await fetch(ticket.uploadUrl, {
+ method: "PUT",
+ headers: ticket.uploadHeaders,
+ body: imageFile,
+});
+```
+
+현재 필수 헤더는 다음과 같다.
+
+- `Content-Type`: 티켓 발급 요청에서 검증한 이미지 MIME 타입
+- `If-None-Match: *`: 같은 URL을 재사용해 객체를 덮어쓰지 못하게 하는 조건
+
+업로드 성공 후 `storageKey`로 `POST /api/v1/scans`를 호출한다. 같은 사용자와 `storageKey`의 중복 요청은 같은 `scanId`를 반환한다.
+
+## 상태 및 재시도
+
+- `processing`: 클라이언트가 짧은 간격으로 폴링한다.
+- `completed`: 메뉴 결과를 표시한다.
+- `needs_retake`: `retakeReasons`를 이용해 재촬영을 안내한다.
+- `failed`: `failureCode`로 안내 문구와 재시도 가능 여부를 결정한다.
+- 스캔 시작이 HTTP 503 `SCAN_CAPACITY_EXCEEDED`이면 `Retry-After` 이후 같은 `storageKey`로 다시 요청할 수 있다. 거절된 세션은 서버가 제거한다.
+- 처리 중 실패한 세션을 자동으로 다시 실행하지 않는다. 외부 OCR의 성공 여부가 불명확한 타임아웃에서 자동 재시도하면 중복 과금될 수 있기 때문이다. 사용자가 명시적으로 재촬영·재업로드하도록 안내한다.
+
+## 확인할 로그
+
+- `OCR completed`: 백엔드/AI 처리 시간, OCR 호출 횟수, 전처리 선택, 이미지 조회 경로, AI 큐 대기 시간
+- `AI stage completed`: rule engine/result 단계별 지연
+- `Scan failed`: `failureCode`와 전체 처리 시간
+- `Recovered ... stale scan sessions`: 서버 재시작 등으로 유실된 인메모리 작업 회수
+
+## 롤백
+
+ECS 서비스의 이전 정상 **태스크 정의 리비전 전체**로 롤백한다. backend와 AI는 같은 태스크 정의에 있으므로 컨테이너 하나의 태그만 수동 교체하면 API 계약이 어긋날 수 있다. DB 마이그레이션 V2는 새 컬럼과 인덱스를 추가하는 방식이라 구버전 애플리케이션과 호환된다.
diff --git a/infra/images/architecture.svg b/infra/images/architecture.svg
new file mode 100644
index 0000000..59e2d24
--- /dev/null
+++ b/infra/images/architecture.svg
@@ -0,0 +1,255 @@
+
diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf
index f3ca7d7..542a05b 100644
--- a/infra/terraform/compute.tf
+++ b/infra/terraform/compute.tf
@@ -85,9 +85,26 @@ resource "aws_iam_role" "task" {
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
+# backend/ai 두 저장소가 하나의 ECS 태스크 정의를 수정하므로 교차 저장소 배포를 직렬화한다.
+resource "aws_dynamodb_table" "deploy_lock" {
+ name = "${local.name_prefix}-deploy-lock"
+ billing_mode = "PAY_PER_REQUEST"
+ hash_key = "lock_name"
+
+ attribute {
+ name = "lock_name"
+ type = "S"
+ }
+
+ ttl {
+ attribute_name = "expires_at"
+ enabled = true
+ }
+}
+
data "aws_iam_policy_document" "task_s3" {
statement {
- actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ actions = ["s3:GetObject", "s3:GetObjectVersion", "s3:PutObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.images.arn}/*"]
}
statement {
@@ -144,6 +161,11 @@ data "aws_iam_policy_document" "github_deploy" {
resources = ["*"]
}
+ statement {
+ actions = ["dynamodb:PutItem", "dynamodb:DeleteItem"]
+ resources = [aws_dynamodb_table.deploy_lock.arn]
+ }
+
# 나머지 ECR 액션은 우리 저장소로 한정
statement {
actions = [
@@ -350,6 +372,19 @@ resource "aws_ecs_task_definition" "app" {
environment = [
{ name = "PORT", value = "8000" },
+
+ # AI 컨테이너가 Presigned URL 없이 S3에서 직접 이미지를 읽음.
+ { name = "OCR_S3_FETCH_ENABLED", value = "true" },
+ { name = "OCR_S3_BUCKET", value = aws_s3_bucket.images.bucket },
+ { name = "AWS_REGION", value = var.region },
+
+ # 운영 SLA 명시.
+ { name = "OCR_REQUEST_BUDGET_SECONDS", value = "16" },
+ { name = "OCR_TOTAL_BUDGET_SECONDS", value = "14" },
+ { name = "OCR_MAX_CONCURRENT_SCANS", value = "2" },
+ { name = "OCR_QUEUE_WAIT_SECONDS", value = "1" },
+ { name = "OCR_ENABLE_GPT_POST_PROCESS", value = "false" },
+ { name = "OCR_ENABLE_GPT_JUDGMENT", value = "false" },
]
secrets = [
@@ -387,6 +422,16 @@ resource "aws_ecs_task_definition" "app" {
{ name = "SPRING_PROFILES_ACTIVE", value = "prod" },
{ name = "SERVER_PORT", value = "8080" },
{ name = "AI_SERVICE_BASE_URL", value = "http://localhost:8000" },
+ { name = "AI_SERVICE_CONNECT_TIMEOUT", value = "500ms" },
+ { name = "AI_OCR_READ_TIMEOUT", value = "18s" },
+ { name = "AI_RULE_ENGINE_READ_TIMEOUT", value = "2s" },
+ { name = "AI_RESULT_READ_TIMEOUT", value = "7s" },
+ { name = "AI_OCR_PRESIGNED_FALLBACK_ENABLED", value = "false" },
+ { name = "SCAN_ASYNC_CORE_POOL_SIZE", value = "2" },
+ { name = "SCAN_ASYNC_MAX_POOL_SIZE", value = "2" },
+ { name = "SCAN_ASYNC_QUEUE_CAPACITY", value = "0" },
+ { name = "SCAN_STALE_AFTER", value = "2m" },
+ { name = "SCAN_RECOVERY_INTERVAL_MS", value = "60000" },
{ name = "S3_BUCKET", value = aws_s3_bucket.images.bucket },
{ name = "AWS_REGION", value = var.region },
{ name = "JAVA_TOOL_OPTIONS", value = "-XX:MaxRAMPercentage=60" },
@@ -468,4 +513,4 @@ resource "aws_budgets_budget" "monthly" {
notification_type = "FORECASTED"
subscriber_email_addresses = [var.alert_email]
}
-}
\ No newline at end of file
+}
diff --git a/infra/terraform/storage.tf b/infra/terraform/storage.tf
index 6fc7f4a..04e9ff9 100644
--- a/infra/terraform/storage.tf
+++ b/infra/terraform/storage.tf
@@ -34,6 +34,8 @@ resource "aws_s3_bucket_lifecycle_configuration" "images" {
status = "Enabled"
filter { prefix = "scans/" }
expiration { days = 90 }
+ # Versioning 버킷에서 expiration은 삭제 마커만 만들므로 실제 원본 버전도 정리한다.
+ noncurrent_version_expiration { noncurrent_days = 1 }
}
# 대표 메뉴 이미지는 영구 보관, 옛 버전은 정리
@@ -134,4 +136,4 @@ resource "aws_ssm_parameter" "db_password_seed" {
name = "/${local.name_prefix}/db_password_seed"
type = "SecureString"
value = random_password.db.result
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/client/AiClient.java b/src/main/java/com/hanspoon/backend_api/domain/ai/client/AiClient.java
index e939642..a8a400d 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/client/AiClient.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/client/AiClient.java
@@ -15,9 +15,10 @@
/**
* Han-Spoon AI 서비스(OCR + Rule Engine) HTTP 클라이언트.
- *
- *
HTTP 4xx/5xx 응답은 {@code onStatus} 에서 {@link BusinessException} 으로 변환하고, 연결 실패/타임아웃
- * ({@link ResourceAccessException})은 {@link ErrorCode#AI_SERVICE_UNAVAILABLE} 로 변환한다.
+ * 내부 AI 서비스의
+ * /v1/ocr를 호출하여 OCR 결과를 가져오고,
+ * /v1/ruleengine을 호출하여 룰엔진 판정을 수행하며,
+ * /v1/result를 호출하여 최종 결과를 가져오는 역할을 합니다.
*/
@Component
public class AiClient {
@@ -26,24 +27,30 @@ public class AiClient {
private static final String RULE_ENGINE_PATH = "/v1/ruleengine";
private static final String RESULT_PATH = "/v1/result";
- private final RestClient aiServiceRestClient;
+ private final RestClient aiOcrRestClient;
+ private final RestClient aiRuleEngineRestClient;
+ private final RestClient aiResultRestClient;
- public AiClient(@Qualifier("aiServiceRestClient") RestClient aiServiceRestClient) {
- this.aiServiceRestClient = aiServiceRestClient;
+ public AiClient(
+ @Qualifier("aiOcrRestClient") RestClient aiOcrRestClient,
+ @Qualifier("aiRuleEngineRestClient") RestClient aiRuleEngineRestClient,
+ @Qualifier("aiResultRestClient") RestClient aiResultRestClient) {
+ this.aiOcrRestClient = aiOcrRestClient;
+ this.aiRuleEngineRestClient = aiRuleEngineRestClient;
+ this.aiResultRestClient = aiResultRestClient;
}
/** OCR 호출: 이미지 → 메뉴 추출 + 품질 평가. */
public OcrResponse requestOcr(OcrRequest request) {
try {
- return aiServiceRestClient
+ return aiOcrRestClient
.post()
.uri(OCR_PATH)
.body(request)
.retrieve()
- .onStatus(HttpStatusCode::isError, (req, res) -> {
- throw new BusinessException(
- ErrorCode.OCR_SERVICE_ERROR, "OCR service responded with " + res.getStatusCode());
- })
+ .onStatus(
+ HttpStatusCode::isError,
+ (req, res) -> throwForStatus(res.getStatusCode(), ErrorCode.OCR_SERVICE_ERROR, "OCR"))
.body(OcrResponse.class);
} catch (ResourceAccessException exception) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE, "OCR service unreachable.", exception);
@@ -53,15 +60,15 @@ public OcrResponse requestOcr(OcrRequest request) {
/** Rule Engine 호출: OCR 결과 + 프로필 → 메뉴별 위험도 판정. */
public RuleEngineResponse judge(RuleEngineRequest request) {
try {
- return aiServiceRestClient
+ return aiRuleEngineRestClient
.post()
.uri(RULE_ENGINE_PATH)
.body(request)
.retrieve()
- .onStatus(HttpStatusCode::isError, (req, res) -> {
- throw new BusinessException(
- ErrorCode.RULE_ENGINE_ERROR, "Rule engine responded with " + res.getStatusCode());
- })
+ .onStatus(
+ HttpStatusCode::isError,
+ (req, res) ->
+ throwForStatus(res.getStatusCode(), ErrorCode.RULE_ENGINE_ERROR, "Rule engine"))
.body(RuleEngineResponse.class);
} catch (ResourceAccessException exception) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE, "Rule engine unreachable.", exception);
@@ -71,18 +78,26 @@ public RuleEngineResponse judge(RuleEngineRequest request) {
/** Result 호출: 룰엔진 판정(judged) → 최종 표시 결과(message/owner_card). body 는 judged 를 그대로 전송. */
public FinalResultResponse result(RuleEngineResponse judged) {
try {
- return aiServiceRestClient
+ return aiResultRestClient
.post()
.uri(RESULT_PATH)
.body(judged)
.retrieve()
- .onStatus(HttpStatusCode::isError, (req, res) -> {
- throw new BusinessException(
- ErrorCode.RESULT_SERVICE_ERROR, "Result service responded with " + res.getStatusCode());
- })
+ .onStatus(
+ HttpStatusCode::isError,
+ (req, res) -> throwForStatus(
+ res.getStatusCode(), ErrorCode.RESULT_SERVICE_ERROR, "Result service"))
.body(FinalResultResponse.class);
} catch (ResourceAccessException exception) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE, "Result service unreachable.", exception);
}
}
+
+ private void throwForStatus(HttpStatusCode status, ErrorCode fallback, String serviceName) {
+ if (status.value() == 429 || status.value() == 503) {
+ throw new BusinessException(
+ ErrorCode.AI_SERVICE_OVERLOADED, serviceName + " is temporarily overloaded (" + status + ").");
+ }
+ throw new BusinessException(fallback, serviceName + " responded with " + status + ".");
+ }
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java
index 7239a6e..4724a93 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/OcrRequest.java
@@ -4,11 +4,18 @@
import tools.jackson.databind.annotation.JsonNaming;
/**
- * OCR 요청 (백엔드 → AI 서비스). 이미지는 미리 스토리지에 저장된 뒤 URL 로 전달한다.
+ * OCR 요청 (백엔드 → AI 서비스). 검증된 S3 객체 식별 정보를 전달한다.
*
* @param source 이미지 소스 ("camera" | "upload")
* @param storageKey 스토리지 키 (예: "scans/menu_003.jpg")
- * @param imageUrl 이미지 접근 URL
+ * @param imageUrl 레거시 호환 필드. 운영 S3 IAM 경로에서는 null
+ * @param versionId S3 버전 관리가 활성화된 버킷에서 객체가 생성될 때 S3가 부여하는 버전 식별자
+ * @param expectedEtag 검증 시점의 S3 객체 ETag
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
-public record OcrRequest(String source, String storageKey, String imageUrl) {}
+public record OcrRequest(String source, String storageKey, String imageUrl, String versionId, String expectedEtag) {
+ public static OcrRequest forS3(
+ String source, String storageKey, String fallbackImageUrl, String versionId, String expectedEtag) {
+ return new OcrRequest(source, storageKey, fallbackImageUrl, versionId, expectedEtag);
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
index 8835d46..016adce 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/ai/dto/ocr/ScanQuality.java
@@ -18,6 +18,15 @@
* @param imageQuality 이미지 품질 세부 분석
* @param retakeSuggestions 재촬영 가이드 문구
* @param reasons 품질 저하 사유
+ * @param preprocessingAttempted 전처리 이미지 생성 시도 여부
+ * @param preprocessingApplied 최종 결과에 전처리 이미지가 사용됐는지 여부
+ * @param selectedOcrAttempt 선택된 OCR 시도(original | preprocessed)
+ * @param ocrAttemptCount CLOVA OCR 호출 횟수
+ * @param retrySkippedReason 재시도를 생략한 이유
+ * @param ocrProcessingTimeMs AI OCR 처리 시간(ms)
+ * @param ocrBudgetMs AI OCR 시간 예산(ms)
+ * @param imageFetchSource 이미지 조회 경로(s3_iam | presigned_url)
+ * @param queueWaitMs AI 내부 처리 슬롯 대기 시간(ms)
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -31,4 +40,13 @@ public record ScanQuality(
Integer imageHeight,
ImageQuality imageQuality,
List retakeSuggestions,
- List reasons) {}
+ List reasons,
+ Boolean preprocessingAttempted,
+ Boolean preprocessingApplied,
+ String selectedOcrAttempt,
+ Integer ocrAttemptCount,
+ String retrySkippedReason,
+ Long ocrProcessingTimeMs,
+ Long ocrBudgetMs,
+ String imageFetchSource,
+ Long queueWaitMs) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
index fe85484..851b4eb 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
@@ -7,7 +7,7 @@
import java.util.UUID;
/**
- * 스캔 결과 조회 응답. status 가 COMPLETED 일 때 menus 가 채워진다. NEEDS_RETAKE 면 menus 는 비어 있다.
+ * 스캔 결과 조회 응답. status 가 COMPLETED 일 때 menus 가 채워짐.
*
* @param scanId 스캔 세션 id
* @param status 스캔 상태 (processing | completed | failed | needs_retake)
@@ -17,6 +17,7 @@
* @param scannedAt 스캔 시각
* @param menus 메뉴별 분석 결과
* @param retakeReasons 재촬영 사유 (status 가 NEEDS_RETAKE 일 때만 채워짐, 그 외 null). OCR 이 제공한 문자열 그대로(언어 혼재 가능, i18n 키 아님)
+ * @param failureCode 실패 원인 코드 (status 가 FAILED 일 때만 채워짐)
*/
@Schema(description = "스캔 결과 조회 응답")
public record ScanResultResponse(
@@ -27,4 +28,5 @@ public record ScanResultResponse(
Integer riskyMenuCount,
Instant scannedAt,
List menus,
- List retakeReasons) {}
+ List retakeReasons,
+ String failureCode) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java
index b3e8d5c..040c07b 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/StartScanRequest.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
/**
* 스캔 시작 요청. 이미지는 presigned URL 로 S3 에 업로드된 상태이고, 그 객체 키를 넘긴다.
@@ -14,4 +15,5 @@
@Schema(description = "스캔 시작 요청")
public record StartScanRequest(
@Schema(description = "업로드된 S3 객체 키", example = "scans/3f2a.../9f3c....jpg") @NotBlank String storageKey,
- @Schema(description = "이미지 소스", example = "upload") String source) {}
+ @Schema(description = "이미지 소스", example = "upload")
+ @Pattern(regexp = "camera|upload", message = "source must be camera or upload") String source) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
index 7a4e31a..a3f73b7 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanSession.java
@@ -5,6 +5,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
+import jakarta.persistence.Version;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@@ -31,6 +32,10 @@ public class ScanSession extends BaseEntity {
@Column(name = "user_id", columnDefinition = "uuid", nullable = false)
private UUID userId;
+ /** 업로드 객체 하나당 스캔 세션 하나만 생성하기 위한 멱등 키. 기존 데이터는 null일 수 있다. */
+ @Column(name = "storage_key", length = 512)
+ private String storageKey;
+
@Column(name = "title", length = 255)
private String title;
@@ -43,6 +48,14 @@ public class ScanSession extends BaseEntity {
@Column(name = "scan_status", length = 20, nullable = false)
private ScanStatus scanStatus;
+ /** 비동기 처리 실패 원인. 사용자에게 내부 예외 메시지를 노출하지 않고 재시도 판단에 사용한다. */
+ @Column(name = "failure_code", length = 64)
+ private String failureCode;
+
+ @Version
+ @Column(name = "lock_version", nullable = false)
+ private long lockVersion;
+
@Column(name = "scanned_at")
private Instant scannedAt;
@@ -52,6 +65,7 @@ public class ScanSession extends BaseEntity {
private ScanSession(
UUID userId,
+ String storageKey,
String title,
Integer menuCount,
Integer riskyMenuCount,
@@ -59,6 +73,7 @@ private ScanSession(
Instant scannedAt) {
this.id = UUID.randomUUID();
this.userId = userId;
+ this.storageKey = storageKey;
this.title = title;
this.menuCount = menuCount;
this.riskyMenuCount = riskyMenuCount;
@@ -73,23 +88,35 @@ public static ScanSession create(
Integer riskyMenuCount,
ScanStatus scanStatus,
Instant scannedAt) {
- return new ScanSession(userId, title, menuCount, riskyMenuCount, scanStatus, scannedAt);
+ return new ScanSession(userId, null, title, menuCount, riskyMenuCount, scanStatus, scannedAt);
+ }
+
+ public static ScanSession start(UUID userId, String storageKey) {
+ return new ScanSession(userId, storageKey, null, null, null, ScanStatus.PROCESSING, null);
}
/** OCR 완료 후 메뉴 수/스캔 시각 반영. */
public void applyOcrResult(Integer menuCount, Instant scannedAt) {
+ ensureProcessing();
this.menuCount = menuCount;
this.scannedAt = scannedAt;
+ this.failureCode = null;
}
/** 룰엔진 판정 후 위험 메뉴 수/상태 갱신. */
public void applyRuleEngineResult(Integer riskyMenuCount, ScanStatus scanStatus) {
+ ensureProcessing();
this.riskyMenuCount = riskyMenuCount;
this.scanStatus = scanStatus;
+ this.failureCode = null;
}
- public void changeStatus(ScanStatus scanStatus) {
- this.scanStatus = scanStatus;
+ public void markFailed(String failureCode) {
+ if (this.scanStatus != ScanStatus.PROCESSING) {
+ return;
+ }
+ this.scanStatus = ScanStatus.FAILED;
+ this.failureCode = failureCode;
}
/** 유저가 이력 제목을 수정. */
@@ -99,7 +126,15 @@ public void changeTitle(String title) {
/** 재촬영 필요 시 상태 + OCR 이 제공한 사유를 반영. */
public void applyNeedsRetake(List retakeReasons) {
+ ensureProcessing();
this.scanStatus = ScanStatus.NEEDS_RETAKE;
this.retakeReasons = retakeReasons;
+ this.failureCode = null;
+ }
+
+ private void ensureProcessing() {
+ if (scanStatus != ScanStatus.PROCESSING) {
+ throw new IllegalStateException("Scan session is already terminal: " + scanStatus);
+ }
}
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
index e738987..ab838f4 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
@@ -2,11 +2,15 @@
import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface ScanSessionRepository extends JpaRepository {
@@ -14,4 +18,24 @@ public interface ScanSessionRepository extends JpaRepository
Page findByUserIdAndScanStatus(UUID userId, ScanStatus scanStatus, Pageable pageable);
Optional findByIdAndUserId(UUID id, UUID userId);
+
+ Optional findByUserIdAndStorageKey(UUID userId, String storageKey);
+
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query(
+ """
+ update ScanSession scan
+ set scan.scanStatus = :failedStatus,
+ scan.failureCode = :failureCode,
+ scan.updatedAt = :recoveredAt,
+ scan.lockVersion = scan.lockVersion + 1
+ where scan.scanStatus = :processingStatus
+ and scan.updatedAt < :cutoff
+ """)
+ int markStaleProcessingAsFailed(
+ @Param("processingStatus") ScanStatus processingStatus,
+ @Param("failedStatus") ScanStatus failedStatus,
+ @Param("failureCode") String failureCode,
+ @Param("cutoff") Instant cutoff,
+ @Param("recoveredAt") Instant recoveredAt);
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
index 537a295..91549f9 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessor.java
@@ -11,11 +11,8 @@
import com.hanspoon.backend_api.domain.ai.mapper.AiProfileMapper;
import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis;
import com.hanspoon.backend_api.domain.scan.entity.MenuImage;
-import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
-import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
-import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
-import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository;
import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.domain.upload.service.S3StorageService;
import com.hanspoon.backend_api.domain.user.entity.UserAllergy;
import com.hanspoon.backend_api.domain.user.entity.UserProfile;
@@ -25,14 +22,25 @@
import com.hanspoon.backend_api.global.exception.ErrorCode;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Objects;
+import java.util.Set;
import java.util.UUID;
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.annotation.Transactional;
+/**
+ * 검증된 S3 객체 정보 전달 → AI 서비스 호출 → 메뉴 분석 저장을 수행하는 비동기 스캔 처리기.
+ * ScanProcessor는 전체 작업의 순서를 지휘함.
+ *
+ * @Transactional 추가 금지.
+ * DB 커넥션이 점유되어 비동기 워커 수만큼 커넥션 풀이 고갈되는 것 방지하기 위함.
+ * 상태 전이는 {@link ScanStateWriter} 담당.
+ */
@Component
public class ScanProcessor {
@@ -44,8 +52,8 @@ public class ScanProcessor {
private final UserProfileRepository userProfileRepository;
private final UserAllergyRepository userAllergyRepository;
private final ScanSessionRepository scanSessionRepository;
- private final MenuImageRepository menuImageRepository;
- private final MenuAnalysisRepository menuAnalysisRepository;
+ private final ScanStateWriter scanStateWriter;
+ private final boolean presignedFallbackEnabled;
public ScanProcessor(
AiClient aiClient,
@@ -53,71 +61,95 @@ public ScanProcessor(
UserProfileRepository userProfileRepository,
UserAllergyRepository userAllergyRepository,
ScanSessionRepository scanSessionRepository,
- MenuImageRepository menuImageRepository,
- MenuAnalysisRepository menuAnalysisRepository) {
+ ScanStateWriter scanStateWriter,
+ @Value("${app.ai-service.presigned-url-fallback-enabled:true}") boolean presignedFallbackEnabled) {
this.aiClient = aiClient;
this.s3StorageService = s3StorageService;
this.userProfileRepository = userProfileRepository;
this.userAllergyRepository = userAllergyRepository;
this.scanSessionRepository = scanSessionRepository;
- this.menuImageRepository = menuImageRepository;
- this.menuAnalysisRepository = menuAnalysisRepository;
+ this.scanStateWriter = scanStateWriter;
+ this.presignedFallbackEnabled = presignedFallbackEnabled;
}
- @Async("applicationTaskExecutor")
- @Transactional
- public void process(UUID scanId, UUID userId, String storageKey, String source) {
- ScanSession session = scanSessionRepository.findById(scanId).orElse(null);
- if (session == null) {
- log.warn("Scan session not found, skip processing: {}", scanId);
- return;
- }
+ @Async("scanTaskExecutor")
+ public void process(UUID scanId, UUID userId, VerifiedUpload upload, String source) {
+ long processingStartedAt = System.nanoTime();
try {
- // 1) presigned GET URL → OCR
- String imageUrl = s3StorageService.createReadUrl(storageKey);
- OcrResponse ocr = aiClient.requestOcr(new OcrRequest(source, storageKey, imageUrl));
+ if (!scanSessionRepository.existsById(scanId)) {
+ log.warn("Scan session not found, skip processing: {}", scanId);
+ return;
+ }
+
+ String storageKey = upload.storageKey();
+ String fallbackImageUrl =
+ presignedFallbackEnabled ? s3StorageService.createReadUrl(storageKey, upload.versionId()) : null;
+ long ocrStartedAt = System.nanoTime();
+ OcrResponse ocr = aiClient.requestOcr(
+ OcrRequest.forS3(source, storageKey, fallbackImageUrl, upload.versionId(), upload.eTag()));
+ if (ocr == null) {
+ throw new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "OCR service returned an empty response.");
+ }
+ logOcrCompleted(scanId, elapsedMillis(ocrStartedAt), ocr);
- // 2) menu_image 저장 + 세션에 OCR 메타 반영
- persistMenuImage(scanId, source, storageKey, ocr);
- session.applyOcrResult(
+ scanStateWriter.applyOcrResult(
+ scanId,
+ toMenuImage(scanId, source, storageKey, ocr),
ocr.scanSession() != null ? ocr.scanSession().menuCount() : null,
parseScannedAt(ocr.scanSession() != null ? ocr.scanSession().scannedAt() : null));
- // 3) needs_retake → 종료 (OCR 이 준 사유 저장)
if (isNeedsRetake(ocr)) {
- session.applyNeedsRetake(
- ocr.scanQuality() != null ? ocr.scanQuality().reasons() : null);
+ scanStateWriter.applyNeedsRetake(
+ scanId, ocr.scanQuality() != null ? ocr.scanQuality().reasons() : null);
log.info("Scan needs retake: {}", scanId);
return;
}
- // 4) 프로필 매핑 → RuleEngine
- UserProfile profile = userProfileRepository
- .findByUserId(userId)
- .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
- List allergies = userAllergyRepository.findByUserProfileId(profile.getId());
- RuleProfile ruleProfile = AiProfileMapper.toRuleProfile(profile, allergies);
-
+ RuleProfile ruleProfile = loadRuleProfile(userId);
+ long ruleEngineStartedAt = System.nanoTime();
RuleEngineResponse judged = aiClient.judge(new RuleEngineRequest(ruleProfile, ocr));
+ if (judged == null) {
+ throw new BusinessException(ErrorCode.RULE_ENGINE_ERROR, "Rule engine returned an empty response.");
+ }
+ logStageCompleted(scanId, "rule_engine", ruleEngineStartedAt);
- // 5) 최종 결과(ai_result) — message/owner_card 생성(GPT는 AI 내부)
+ long resultStartedAt = System.nanoTime();
FinalResultResponse finalResult = aiClient.result(judged);
+ logStageCompleted(scanId, "result", resultStartedAt);
- // 6) OCR ↔ Final index 머지 → 저장 (표시 출처는 ai_result FinalOutput)
- menuAnalysisRepository.saveAll(merge(scanId, ocr, finalResult));
-
- // 7) 세션 완료
Integer riskyCount =
judged.scanSession() != null ? judged.scanSession().riskyMenuCount() : null;
- session.applyRuleEngineResult(riskyCount, ScanStatus.COMPLETED);
- log.info("Scan completed: {} ({} menus)", scanId, session.getMenuCount());
+ scanStateWriter.complete(scanId, riskyCount, merge(scanId, ocr, finalResult));
+ log.info("Scan completed: {} (totalMs={})", scanId, elapsedMillis(processingStartedAt));
} catch (Exception exception) {
- log.error("Scan failed: {}", scanId, exception);
- session.changeStatus(ScanStatus.FAILED);
+ log.error(
+ "Scan failed: {} (failureCode={}, totalMs={})",
+ scanId,
+ failureCode(exception),
+ elapsedMillis(processingStartedAt),
+ exception);
+ markFailedQuietly(scanId, exception);
}
}
- private void persistMenuImage(UUID scanId, String source, String storageKey, OcrResponse ocr) {
+ /** 단건 읽기 2회. */
+ private RuleProfile loadRuleProfile(UUID userId) {
+ UserProfile profile = userProfileRepository
+ .findByUserId(userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
+ List allergies = userAllergyRepository.findByUserProfileId(profile.getId());
+ return AiProfileMapper.toRuleProfile(profile, allergies);
+ }
+
+ private void markFailedQuietly(UUID scanId, Exception cause) {
+ try {
+ scanStateWriter.markFailed(scanId, failureCode(cause));
+ } catch (Exception exception) {
+ log.error("Failed to mark scan as FAILED: {} (cause: {})", scanId, cause.toString(), exception);
+ }
+ }
+
+ private MenuImage toMenuImage(UUID scanId, String source, String storageKey, OcrResponse ocr) {
String resolvedSource = source;
String mimeType = null;
Long fileSize = null;
@@ -128,8 +160,8 @@ private void persistMenuImage(UUID scanId, String source, String storageKey, Ocr
mimeType = ocr.menuImage().mimeType();
fileSize = ocr.menuImage().fileSize();
}
- menuImageRepository.save(MenuImage.create(
- scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize));
+ return MenuImage.create(
+ scanId, resolvedSource, storageKey, s3StorageService.objectUri(storageKey), mimeType, fileSize);
}
private boolean isNeedsRetake(OcrResponse ocr) {
@@ -137,22 +169,43 @@ private boolean isNeedsRetake(OcrResponse ocr) {
&& NEEDS_RETAKE.equals(ocr.scanQuality().status());
}
+ /** menu_item_id 도입 전까지 개수·메뉴명·표시 순서를 검증한 뒤 배열 순서로 머지한다. */
private List merge(UUID scanId, OcrResponse ocr, FinalResultResponse finalResult) {
List ocrMenus =
ocr.menuAnalyses() != null ? ocr.menuAnalyses() : List.of();
List finalMenus =
finalResult != null && finalResult.menuAnalyses() != null ? finalResult.menuAnalyses() : List.of();
if (ocrMenus.size() != finalMenus.size()) {
- log.warn("menu size mismatch: ocr={}, final={}", ocrMenus.size(), finalMenus.size());
+ throw resultMismatch("menu count", ocrMenus.size(), finalMenus.size());
+ }
+ if (ocrMenus.isEmpty()) {
+ throw new BusinessException(
+ ErrorCode.AI_RESULT_MISMATCH, "Usable OCR response must contain at least one menu.");
}
- int n = Math.min(ocrMenus.size(), finalMenus.size());
- List merged = new ArrayList<>(n);
- for (int i = 0; i < n; i++) {
+
+ Set displayOrders = new HashSet<>();
+ List merged = new ArrayList<>(ocrMenus.size());
+ for (int i = 0; i < ocrMenus.size(); i++) {
var o = ocrMenus.get(i);
FinalMenu f = finalMenus.get(i);
+ String ocrName = o == null ? "" : normalizeName(o.menuNameKo());
+ String finalName = f == null ? "" : normalizeName(f.menuName());
+ if (ocrName.isBlank() || finalName.isBlank() || !Objects.equals(ocrName, finalName)) {
+ throw resultMismatch(
+ "menu name at index " + i, o == null ? null : o.menuNameKo(), f == null ? null : f.menuName());
+ }
+ if (f.riskLevel() == null) {
+ throw new BusinessException(
+ ErrorCode.AI_RESULT_MISMATCH, "Final risk_level is missing at index " + i + ".");
+ }
+ int displayOrder = o.displayOrder() != null ? o.displayOrder() : i + 1;
+ if (displayOrder <= 0 || !displayOrders.add(displayOrder)) {
+ throw new BusinessException(
+ ErrorCode.AI_RESULT_MISMATCH, "Invalid or duplicate display_order: " + displayOrder);
+ }
merged.add(MenuAnalysis.create(
scanId,
- o.displayOrder() != null ? o.displayOrder() : i + 1,
+ displayOrder,
o.menuNameKo(),
o.menuNameEn(),
o.descriptionKo(),
@@ -168,6 +221,47 @@ private List merge(UUID scanId, OcrResponse ocr, FinalResultRespon
return merged;
}
+ private BusinessException resultMismatch(String field, Object ocrValue, Object finalValue) {
+ return new BusinessException(
+ ErrorCode.AI_RESULT_MISMATCH,
+ "AI result mismatch: " + field + " (ocr=" + ocrValue + ", final=" + finalValue + ").");
+ }
+
+ private String normalizeName(String value) {
+ return value == null ? "" : value.trim();
+ }
+
+ private String failureCode(Exception exception) {
+ if (exception instanceof BusinessException businessException) {
+ return businessException.getErrorCode().getCode();
+ }
+ return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
+ }
+
+ private void logOcrCompleted(UUID scanId, long backendDurationMs, OcrResponse ocr) {
+ var quality = ocr.scanQuality();
+ log.info(
+ "OCR completed: {} (backendMs={}, aiMs={}, attempts={}, preprocessingApplied={}, selectedAttempt={}, "
+ + "retrySkippedReason={}, fetchSource={}, aiQueueMs={})",
+ scanId,
+ backendDurationMs,
+ quality != null ? quality.ocrProcessingTimeMs() : null,
+ quality != null ? quality.ocrAttemptCount() : null,
+ quality != null ? quality.preprocessingApplied() : null,
+ quality != null ? quality.selectedOcrAttempt() : null,
+ quality != null ? quality.retrySkippedReason() : null,
+ quality != null ? quality.imageFetchSource() : null,
+ quality != null ? quality.queueWaitMs() : null);
+ }
+
+ private void logStageCompleted(UUID scanId, String stage, long startedAt) {
+ log.info("AI stage completed: {} (stage={}, durationMs={})", scanId, stage, elapsedMillis(startedAt));
+ }
+
+ private long elapsedMillis(long startedAt) {
+ return (System.nanoTime() - startedAt) / 1_000_000L;
+ }
+
private Instant parseScannedAt(String value) {
if (value == null || value.isBlank()) {
return Instant.now();
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
index 6c5ac14..8d7e9bf 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
@@ -11,12 +11,17 @@
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.domain.upload.service.S3StorageService;
import com.hanspoon.backend_api.global.common.PageResponse;
import com.hanspoon.backend_api.global.exception.BusinessException;
import com.hanspoon.backend_api.global.exception.ErrorCode;
import java.util.List;
import java.util.UUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.task.TaskRejectedException;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -24,35 +29,62 @@
@Service
public class ScanService {
+ private static final Logger log = LoggerFactory.getLogger(ScanService.class);
+
private final S3StorageService s3StorageService;
private final ScanSessionRepository scanSessionRepository;
private final MenuAnalysisRepository menuAnalysisRepository;
private final ScanProcessor scanProcessor;
+ private final ScanStateWriter scanStateWriter;
public ScanService(
S3StorageService s3StorageService,
ScanSessionRepository scanSessionRepository,
MenuAnalysisRepository menuAnalysisRepository,
- ScanProcessor scanProcessor) {
+ ScanProcessor scanProcessor,
+ ScanStateWriter scanStateWriter) {
this.s3StorageService = s3StorageService;
this.scanSessionRepository = scanSessionRepository;
this.menuAnalysisRepository = menuAnalysisRepository;
this.scanProcessor = scanProcessor;
+ this.scanStateWriter = scanStateWriter;
}
public ScanCreatedResponse startScan(UUID userId, StartScanRequest request) {
- // 형식 · 소유권 검증 (외부 입력을 받는 유일한 지점)
+ // 형식, 소유권 검증 (외부 입력)
String storageKey = s3StorageService.resolveKey(userId, request.storageKey());
+ // storageKey는 서버가 발급하고 덮어쓰기가 금지된 객체 키이므로 스캔 멱등 키로 사용할 수 있다.
+ var existing = scanSessionRepository.findByUserIdAndStorageKey(userId, storageKey);
+ if (existing.isPresent()) {
+ return toCreatedResponse(existing.get());
+ }
+
// presigned PUT 은 서버가 내용을 모르므로 실제 업로드 여부·크기·타입을 여기서 확인한다.
// 비동기로 넘긴 뒤 실패하면 사용자는 폴링만 하다 FAILED 를 받게 된다.
- s3StorageService.verifyUploadObject(storageKey);
+ VerifiedUpload verifiedUpload = s3StorageService.verifyUploadObject(storageKey);
// title 은 생성 시 null — 조회 때 기본값(스캔 시각)으로 보이고, 수정은 마이페이지 API 담당
- ScanSession session =
- scanSessionRepository.save(ScanSession.create(userId, null, null, null, ScanStatus.PROCESSING, null));
- scanProcessor.process(session.getId(), userId, storageKey, request.source());
- return new ScanCreatedResponse(session.getId(), session.getScanStatus());
+ ScanSession session;
+ try {
+ // DB 고유 인덱스가 동시에 들어온 동일 요청까지 방어한다.
+ session = scanSessionRepository.saveAndFlush(ScanSession.start(userId, storageKey));
+ } catch (DataIntegrityViolationException exception) {
+ return scanSessionRepository
+ .findByUserIdAndStorageKey(userId, storageKey)
+ .map(ScanService::toCreatedResponse)
+ .orElseThrow(() -> exception);
+ }
+
+ try {
+ scanProcessor.process(session.getId(), userId, verifiedUpload, request.source());
+ } catch (TaskRejectedException exception) {
+ // 비동기 본문이 시작되지 않았으므로 재시도 가능한 상태로 만들기 위해 세션을 제거한다.
+ scanStateWriter.deleteRejected(session.getId());
+ log.warn("Scan rejected before processing: {}", session.getId());
+ throw new BusinessException(ErrorCode.SCAN_CAPACITY_EXCEEDED, exception);
+ }
+ return toCreatedResponse(session);
}
@Transactional(readOnly = true)
@@ -72,7 +104,8 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) {
session.getRiskyMenuCount(),
session.getScannedAt(),
menus,
- session.getRetakeReasons());
+ session.getRetakeReasons(),
+ session.getFailureCode());
}
/** 본인 스캔 이력 목록(최신순). 분석이 끝난 completed 만 노출, menus 는 미포함. */
@@ -114,4 +147,8 @@ private static MenuResult toMenuResult(MenuAnalysis m) {
m.getMessage(),
m.getOwnerCard());
}
+
+ private static ScanCreatedResponse toCreatedResponse(ScanSession session) {
+ return new ScanCreatedResponse(session.getId(), session.getScanStatus());
+ }
}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java
new file mode 100644
index 0000000..1811ca5
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriter.java
@@ -0,0 +1,78 @@
+package com.hanspoon.backend_api.domain.scan.service;
+
+import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis;
+import com.hanspoon.backend_api.domain.scan.entity.MenuImage;
+import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
+import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 스캔 상태 전이를 짧은 트랜잭션 단위로 커밋하는 쓰기 전담 컴포넌트.
+ *
+ * 외부 I/O 추가 금지. 트랜젝션의 DB 커넥션이 점유 방지.
+ *
세션은 트랜잭션마다 재조회.
+ */
+@Component
+public class ScanStateWriter {
+
+ private static final Logger log = LoggerFactory.getLogger(ScanStateWriter.class);
+
+ private final ScanSessionRepository scanSessionRepository;
+ private final MenuImageRepository menuImageRepository;
+ private final MenuAnalysisRepository menuAnalysisRepository;
+
+ public ScanStateWriter(
+ ScanSessionRepository scanSessionRepository,
+ MenuImageRepository menuImageRepository,
+ MenuAnalysisRepository menuAnalysisRepository) {
+ this.scanSessionRepository = scanSessionRepository;
+ this.menuImageRepository = menuImageRepository;
+ this.menuAnalysisRepository = menuAnalysisRepository;
+ }
+
+ @Transactional
+ public void applyOcrResult(UUID scanId, MenuImage menuImage, Integer menuCount, Instant scannedAt) {
+ menuImageRepository.save(menuImage);
+ session(scanId).ifPresent(session -> session.applyOcrResult(menuCount, scannedAt));
+ }
+
+ @Transactional
+ public void applyNeedsRetake(UUID scanId, List retakeReasons) {
+ session(scanId).ifPresent(session -> session.applyNeedsRetake(retakeReasons));
+ }
+
+ @Transactional
+ public void complete(UUID scanId, Integer riskyMenuCount, List analyses) {
+ menuAnalysisRepository.saveAll(analyses);
+ session(scanId).ifPresent(session -> session.applyRuleEngineResult(riskyMenuCount, ScanStatus.COMPLETED));
+ }
+
+ @Transactional
+ public void markFailed(UUID scanId, String failureCode) {
+ session(scanId).ifPresent(session -> session.markFailed(failureCode));
+ }
+
+ /** 실행 풀에서 수락되지 않은 작업은 사용자에게 scanId를 반환하기 전에 제거한다. */
+ @Transactional
+ public void deleteRejected(UUID scanId) {
+ scanSessionRepository.deleteById(scanId);
+ }
+
+ private Optional session(UUID scanId) {
+ Optional found = scanSessionRepository.findById(scanId);
+ if (found.isEmpty()) {
+ log.warn("Scan session not found while writing state: {}", scanId);
+ }
+ return found;
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/StaleScanRecovery.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/StaleScanRecovery.java
new file mode 100644
index 0000000..ec01289
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/StaleScanRecovery.java
@@ -0,0 +1,54 @@
+package com.hanspoon.backend_api.domain.scan.service;
+
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.global.exception.ErrorCode;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/** 서버 재시작 등으로 인메모리 비동기 작업이 유실된 PROCESSING 세션을 RDB 기준으로 회수한다. */
+@Component
+public class StaleScanRecovery {
+
+ private static final Logger log = LoggerFactory.getLogger(StaleScanRecovery.class);
+
+ private final ScanSessionRepository scanSessionRepository;
+ private final Duration staleAfter;
+ private final Clock clock;
+
+ @Autowired
+ public StaleScanRecovery(
+ ScanSessionRepository scanSessionRepository,
+ @Value("${app.scan-recovery.stale-after:2m}") Duration staleAfter) {
+ this(scanSessionRepository, staleAfter, Clock.systemUTC());
+ }
+
+ StaleScanRecovery(ScanSessionRepository scanSessionRepository, Duration staleAfter, Clock clock) {
+ this.scanSessionRepository = scanSessionRepository;
+ this.staleAfter = staleAfter;
+ this.clock = clock;
+ }
+
+ @Scheduled(fixedDelayString = "${app.scan-recovery.interval-ms:60000}")
+ @Transactional
+ public void recover() {
+ Instant now = clock.instant();
+ int recovered = scanSessionRepository.markStaleProcessingAsFailed(
+ ScanStatus.PROCESSING,
+ ScanStatus.FAILED,
+ ErrorCode.SCAN_PROCESSING_TIMEOUT.getCode(),
+ now.minus(staleAfter),
+ now);
+ if (recovered > 0) {
+ log.warn("Recovered {} stale scan sessions", recovered);
+ }
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java
index 339720d..2c6971e 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/UploadTicketResponse.java
@@ -2,6 +2,11 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.Map;
@Schema(description = "업로드 티켓 발급 응답")
-public record UploadTicketResponse(String storageKey, String uploadUrl, Instant expiresAt) {}
+public record UploadTicketResponse(
+ String storageKey,
+ String uploadUrl,
+ Instant expiresAt,
+ @Schema(description = "Presigned PUT 요청에 그대로 포함해야 하는 서명된 헤더") Map uploadHeaders) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/dto/VerifiedUpload.java b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/VerifiedUpload.java
new file mode 100644
index 0000000..8f1972a
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/upload/dto/VerifiedUpload.java
@@ -0,0 +1,13 @@
+package com.hanspoon.backend_api.domain.upload.dto;
+
+/**
+ * S3 HeadObject 검증을 통과한 업로드 객체 메타데이터.
+ *
+ * @param storageKey 검증한 S3 객체 키
+ * @param versionId 검증 시점의 S3 객체 버전 ID
+ * @param eTag 검증 시점의 S3 객체 ETag
+ * @param contentLength 객체 크기(byte)
+ * @param contentType 객체 MIME 타입
+ */
+public record VerifiedUpload(
+ String storageKey, String versionId, String eTag, Long contentLength, String contentType) {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java
index 38c86dc..59ed2d6 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/upload/service/S3StorageService.java
@@ -1,6 +1,7 @@
package com.hanspoon.backend_api.domain.upload.service;
import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.global.config.S3Properties;
import com.hanspoon.backend_api.global.exception.BusinessException;
import com.hanspoon.backend_api.global.exception.ErrorCode;
@@ -64,7 +65,10 @@ public UploadTicketResponse createUploadUrl(UUID userId, String contentType) {
builder.signatureDuration(properties.uploadUrlTtl()).putObjectRequest(putRequest));
return new UploadTicketResponse(
- key, signedRequest.url().toString(), Instant.now().plus(properties.uploadUrlTtl()));
+ key,
+ signedRequest.url().toString(),
+ Instant.now().plus(properties.uploadUrlTtl()),
+ Map.of("Content-Type", normalized, "If-None-Match", "*"));
} catch (RuntimeException exception) {
throw new BusinessException(ErrorCode.STORAGE_PRESIGN_ERROR, "Failed to presign upload URL.", exception);
}
@@ -95,10 +99,13 @@ public String resolveKey(UUID userId, String storageKey) {
}
// 객체가 실제로 올라왔는지, 크기·타입이 정책에 맞는지 확인.
- public HeadObjectResponse verifyUploadObject(String key) {
+ public VerifiedUpload verifyUploadObject(String key) {
try {
- HeadObjectResponse object = s3Client.headObject(
- builder -> builder.bucket(properties.bucket()).key(key));
+ HeadObjectRequest request = HeadObjectRequest.builder()
+ .bucket(properties.bucket())
+ .key(key)
+ .build();
+ HeadObjectResponse object = s3Client.headObject(request);
if (object.contentLength() > properties.maxFileSize()) {
throw new BusinessException(ErrorCode.FILE_TOO_LARGE);
@@ -108,7 +115,8 @@ public HeadObjectResponse verifyUploadObject(String key) {
throw new BusinessException(ErrorCode.INVALID_CONTENT_TYPE);
}
- return object;
+ return new VerifiedUpload(
+ key, object.versionId(), object.eTag(), object.contentLength(), object.contentType());
} catch (S3Exception exception) {
if (exception.statusCode() == 404) {
throw new BusinessException(ErrorCode.UPLOAD_NOT_FOUND, "Object not found: " + key, exception);
@@ -117,10 +125,14 @@ public HeadObjectResponse verifyUploadObject(String key) {
}
}
- // presigned GET URL 발급 (객체 조회)
- public String createReadUrl(String key) {
- GetObjectRequest getRequest =
- GetObjectRequest.builder().bucket(properties.bucket()).key(key).build();
+ /** 로컬 개발과 단계적 롤백을 위한 URL fallback. 운영 기본 경로는 S3 IAM 직접 조회다. */
+ public String createReadUrl(String key, String versionId) {
+ GetObjectRequest.Builder requestBuilder =
+ GetObjectRequest.builder().bucket(properties.bucket()).key(key);
+ if (versionId != null && !versionId.isBlank()) {
+ requestBuilder.versionId(versionId);
+ }
+ GetObjectRequest getRequest = requestBuilder.build();
try {
return presigner
diff --git a/src/main/java/com/hanspoon/backend_api/global/config/AsyncConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/AsyncConfig.java
index 25bbc05..ca62f41 100644
--- a/src/main/java/com/hanspoon/backend_api/global/config/AsyncConfig.java
+++ b/src/main/java/com/hanspoon/backend_api/global/config/AsyncConfig.java
@@ -11,17 +11,17 @@
@EnableAsync
public class AsyncConfig {
- @Bean(name = "applicationTaskExecutor")
- public Executor applicationTaskExecutor(
- @Value("${app.async.core-pool-size:4}") int corePoolSize,
- @Value("${app.async.max-pool-size:16}") int maxPoolSize,
- @Value("${app.async.queue-capacity:100}") int queueCapacity) {
+ @Bean(name = "scanTaskExecutor")
+ public Executor scanTaskExecutor(
+ @Value("${app.scan-async.core-pool-size:2}") int corePoolSize,
+ @Value("${app.scan-async.max-pool-size:2}") int maxPoolSize,
+ @Value("${app.scan-async.queue-capacity:0}") int queueCapacity) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
- executor.setThreadNamePrefix("han-spoon-async-");
+ executor.setThreadNamePrefix("han-spoon-scan-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
executor.initialize();
diff --git a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java
index 2900d7f..db68f14 100644
--- a/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java
+++ b/src/main/java/com/hanspoon/backend_api/global/config/RestClientConfig.java
@@ -17,18 +17,37 @@ public RestClient.Builder restClientBuilder() {
return RestClient.builder();
}
- /**
- * AI 서비스(OCR + Rule Engine) 호출용 RestClient.
- * OCR + 룰엔진 + 결과생성 파이프라인이 길어 read-timeout 을 넉넉히 둔다.
- */
- @Bean
- public RestClient aiServiceRestClient(
+ @Bean("aiOcrRestClient")
+ public RestClient aiOcrRestClient(
+ RestClient.Builder builder,
+ @Value("${app.ai-service.base-url:http://localhost:8000}") String aiServiceBaseUrl,
+ @Value("${app.ai-service.connect-timeout:500ms}") Duration connectTimeout,
+ @Value("${app.ai-service.ocr-read-timeout:18s}") Duration readTimeout) {
+ return aiClient(builder, aiServiceBaseUrl, connectTimeout, readTimeout);
+ }
+
+ @Bean("aiRuleEngineRestClient")
+ public RestClient aiRuleEngineRestClient(
RestClient.Builder builder,
@Value("${app.ai-service.base-url:http://localhost:8000}") String aiServiceBaseUrl,
- @Value("${app.ai-service.connect-timeout:3s}") Duration connectTimeout,
- @Value("${app.ai-service.read-timeout:30s}") Duration readTimeout) {
+ @Value("${app.ai-service.connect-timeout:500ms}") Duration connectTimeout,
+ @Value("${app.ai-service.rule-engine-read-timeout:2s}") Duration readTimeout) {
+ return aiClient(builder, aiServiceBaseUrl, connectTimeout, readTimeout);
+ }
+
+ @Bean("aiResultRestClient")
+ public RestClient aiResultRestClient(
+ RestClient.Builder builder,
+ @Value("${app.ai-service.base-url:http://localhost:8000}") String aiServiceBaseUrl,
+ @Value("${app.ai-service.connect-timeout:500ms}") Duration connectTimeout,
+ @Value("${app.ai-service.result-read-timeout:7s}") Duration readTimeout) {
+ return aiClient(builder, aiServiceBaseUrl, connectTimeout, readTimeout);
+ }
- return builder.baseUrl(aiServiceBaseUrl)
+ private RestClient aiClient(
+ RestClient.Builder builder, String baseUrl, Duration connectTimeout, Duration readTimeout) {
+ return builder.clone()
+ .baseUrl(baseUrl)
.requestFactory(clientHttpRequestFactory(connectTimeout, readTimeout))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SchedulingConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SchedulingConfig.java
new file mode 100644
index 0000000..2097468
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/global/config/SchedulingConfig.java
@@ -0,0 +1,8 @@
+package com.hanspoon.backend_api.global.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {}
diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
index 97ef2ca..5811dba 100644
--- a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
+++ b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
@@ -37,6 +37,9 @@ public enum ErrorCode {
// ── 스캔 ────────────────────────────────────────────────────────────
SCAN_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_NOT_FOUND", "Scan not found."),
+ SCAN_CAPACITY_EXCEEDED(
+ HttpStatus.SERVICE_UNAVAILABLE, "SCAN_CAPACITY_EXCEEDED", "Scan capacity is temporarily exhausted."),
+ SCAN_PROCESSING_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SCAN_PROCESSING_TIMEOUT", "Scan processing timed out."),
// ── 소통 카드 ───────────────────────────────────────────────────────
CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_NOT_FOUND", "Saved card not found."),
@@ -46,6 +49,9 @@ public enum ErrorCode {
RULE_ENGINE_ERROR(HttpStatus.BAD_GATEWAY, "RULE_ENGINE_ERROR", "Rule engine evaluation failed."),
RESULT_SERVICE_ERROR(HttpStatus.BAD_GATEWAY, "RESULT_SERVICE_ERROR", "Result generation failed."),
AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_UNAVAILABLE", "AI service is unavailable."),
+ AI_SERVICE_OVERLOADED(
+ HttpStatus.SERVICE_UNAVAILABLE, "AI_SERVICE_OVERLOADED", "AI service is temporarily overloaded."),
+ AI_RESULT_MISMATCH(HttpStatus.BAD_GATEWAY, "AI_RESULT_MISMATCH", "AI menu results are inconsistent."),
;
private final HttpStatus status;
diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/GlobalExceptionHandler.java b/src/main/java/com/hanspoon/backend_api/global/exception/GlobalExceptionHandler.java
index 6488ca6..1ba6a09 100644
--- a/src/main/java/com/hanspoon/backend_api/global/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/hanspoon/backend_api/global/exception/GlobalExceptionHandler.java
@@ -29,7 +29,12 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
public ResponseEntity handleBusinessException(BusinessException exception, WebRequest request) {
ProblemDetail problemDetail = exception.getBody();
enrich(problemDetail, exception.getErrorCode().getCode(), request);
- return ResponseEntity.status(exception.getStatusCode()).body(problemDetail);
+ ResponseEntity.BodyBuilder response = ResponseEntity.status(exception.getStatusCode());
+ if (exception.getErrorCode() == ErrorCode.SCAN_CAPACITY_EXCEEDED
+ || exception.getErrorCode() == ErrorCode.AI_SERVICE_OVERLOADED) {
+ response.header(HttpHeaders.RETRY_AFTER, "2");
+ }
+ return response.body(problemDetail);
}
@ExceptionHandler(Exception.class)
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 4a07827..59d1b6d 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -36,6 +36,8 @@ server:
forward-headers-strategy: framework
app:
+ ai-service:
+ presigned-url-fallback-enabled: ${AI_OCR_PRESIGNED_FALLBACK_ENABLED:false}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:https://han-spoon.site,https://www.han-spoon.site}
s3:
@@ -63,4 +65,4 @@ springdoc:
swagger-ui:
enabled: ${SWAGGER_UI_ENABLED:false}
api-docs:
- enabled: ${OPENAPI_DOCS_ENABLED:false}
\ No newline at end of file
+ enabled: ${OPENAPI_DOCS_ENABLED:false}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 3543f90..ef2c1cd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -21,10 +21,13 @@ management:
enabled: true
app:
- async:
- core-pool-size: ${ASYNC_CORE_POOL_SIZE:4}
- max-pool-size: ${ASYNC_MAX_POOL_SIZE:16}
- queue-capacity: ${ASYNC_QUEUE_CAPACITY:100}
+ scan-async:
+ core-pool-size: ${SCAN_ASYNC_CORE_POOL_SIZE:2}
+ max-pool-size: ${SCAN_ASYNC_MAX_POOL_SIZE:2}
+ queue-capacity: ${SCAN_ASYNC_QUEUE_CAPACITY:0}
+ scan-recovery:
+ stale-after: ${SCAN_STALE_AFTER:2m}
+ interval-ms: ${SCAN_RECOVERY_INTERVAL_MS:60000}
cache:
maximum-size: ${CACHE_MAXIMUM_SIZE:10000}
expire-after-write: ${CACHE_EXPIRE_AFTER_WRITE:10m}
@@ -39,8 +42,12 @@ app:
refill-period: ${OPENAI_RATE_LIMIT_REFILL_PERIOD:1m}
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
- connect-timeout: ${AI_SERVICE_CONNECT_TIMEOUT:3s}
- read-timeout: ${AI_SERVICE_READ_TIMEOUT:30s}
+ connect-timeout: ${AI_SERVICE_CONNECT_TIMEOUT:500ms}
+ ocr-read-timeout: ${AI_OCR_READ_TIMEOUT:18s}
+ rule-engine-read-timeout: ${AI_RULE_ENGINE_READ_TIMEOUT:2s}
+ result-read-timeout: ${AI_RESULT_READ_TIMEOUT:7s}
+ # 로컬/전환 환경만 URL fallback을 사용하고 운영 프로필에서는 끈다.
+ presigned-url-fallback-enabled: ${AI_OCR_PRESIGNED_FALLBACK_ENABLED:true}
s3:
bucket: ${S3_BUCKET:hanspoon-local-images}
region: ${AWS_REGION:ap-northeast-2}
diff --git a/src/main/resources/db/migration/V2__scan_idempotency_and_failure_code.sql b/src/main/resources/db/migration/V2__scan_idempotency_and_failure_code.sql
new file mode 100644
index 0000000..82b5972
--- /dev/null
+++ b/src/main/resources/db/migration/V2__scan_idempotency_and_failure_code.sql
@@ -0,0 +1,10 @@
+ALTER TABLE scan_sessions
+ ADD COLUMN storage_key VARCHAR(512) NULL,
+ ADD COLUMN failure_code VARCHAR(64) NULL,
+ ADD COLUMN lock_version BIGINT NOT NULL DEFAULT 0;
+
+-- Presigned PUT으로 생성한 불변 S3 객체는 한 번만 분석.
+-- 기존 데이터는 storage_key가 null이므로 마이그레이션 시 충돌하지 않음.
+CREATE UNIQUE INDEX uq_scan_sessions_user_storage_key
+ ON scan_sessions (user_id, storage_key)
+ WHERE storage_key IS NOT NULL;
diff --git a/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java b/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java
index b297079..1af4c83 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/ai/client/AiClientTest.java
@@ -38,7 +38,8 @@ class AiClientTest {
void setUp() {
builder = RestClient.builder().baseUrl(BASE_URL);
server = MockRestServiceServer.bindTo(builder).build();
- aiClient = new AiClient(builder.build());
+ RestClient client = builder.build();
+ aiClient = new AiClient(client, client, client);
}
@Test
@@ -56,7 +57,7 @@ void requestOcrParsesSuccessResponse() {
.andExpect(method(HttpMethod.POST))
.andRespond(withSuccess(body, MediaType.APPLICATION_JSON));
- OcrResponse response = aiClient.requestOcr(new OcrRequest("upload", "k", "u"));
+ OcrResponse response = aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1"));
assertThat(response.scanSession().menuCount()).isEqualTo(1);
assertThat(response.menuAnalyses()).hasSize(1);
@@ -67,20 +68,31 @@ void requestOcrParsesSuccessResponse() {
void requestOcrMapsErrorStatusToBusinessException() {
server.expect(requestTo(BASE_URL + "/v1/ocr")).andRespond(withStatus(HttpStatus.BAD_GATEWAY));
- assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u")))
+ assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1")))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.OCR_SERVICE_ERROR);
server.verify();
}
+ @Test
+ void requestOcrMapsCapacityResponseToOverloaded() {
+ server.expect(requestTo(BASE_URL + "/v1/ocr")).andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE));
+
+ assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1")))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.AI_SERVICE_OVERLOADED);
+ server.verify();
+ }
+
@Test
void requestOcrMapsConnectionFailureToUnavailable() {
server.expect(requestTo(BASE_URL + "/v1/ocr")).andRespond(request -> {
throw new IOException("connection refused");
});
- assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u")))
+ assertThatThrownBy(() -> aiClient.requestOcr(new OcrRequest("upload", "k", "u", "v1", "etag-1")))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCode())
.isEqualTo(ErrorCode.AI_SERVICE_UNAVAILABLE);
diff --git a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
index 883112c..2ba8044 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/ai/dto/AiDtoSerializationTest.java
@@ -157,7 +157,16 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception {
"future_unknown_field": "ignored"
},
"retake_suggestions": [],
- "reasons": []
+ "reasons": [],
+ "preprocessing_attempted": true,
+ "preprocessing_applied": true,
+ "selected_ocr_attempt": "preprocessed",
+ "ocr_attempt_count": 2,
+ "retry_skipped_reason": null,
+ "ocr_processing_time_ms": 842,
+ "ocr_budget_ms": 16000,
+ "image_fetch_source": "s3_iam",
+ "queue_wait_ms": 4
},
"menu_analyses": [
{
@@ -183,6 +192,11 @@ void deserializesOcrResponseIgnoringUnknownFields() throws Exception {
assertThat(result.menuImage().storageKey()).isEqualTo("scans/menu_001.jpg");
assertThat(result.scanQuality().status()).isEqualTo("usable");
assertThat(result.scanQuality().imageQuality().glareRatio()).isEqualTo(0.02);
+ assertThat(result.scanQuality().preprocessingApplied()).isTrue();
+ assertThat(result.scanQuality().ocrAttemptCount()).isEqualTo(2);
+ assertThat(result.scanQuality().ocrProcessingTimeMs()).isEqualTo(842L);
+ assertThat(result.scanQuality().imageFetchSource()).isEqualTo("s3_iam");
+ assertThat(result.scanQuality().queueWaitMs()).isEqualTo(4L);
assertThat(result.menuAnalyses()).hasSize(1);
assertThat(result.menuAnalyses().get(0).priceText()).isEqualTo("9000");
assertThat(result.menuAnalyses().get(0).riskLevel()).isNull();
@@ -207,10 +221,14 @@ void serializesRuleProfileInSnakeCase() throws Exception {
@Test
void serializesOcrRequestInSnakeCase() throws Exception {
- OcrRequest request = new OcrRequest("camera", "scans/menu_003.jpg", "https://example.com/scans/menu_003.jpg");
+ OcrRequest request = OcrRequest.forS3("camera", "scans/menu_003.jpg", null, "version-1", "\"etag-1\"");
String json = objectMapper.writeValueAsString(request);
- assertThat(json).contains("\"storage_key\":\"scans/menu_003.jpg\"").contains("\"image_url\":");
+ assertThat(json)
+ .contains("\"storage_key\":\"scans/menu_003.jpg\"")
+ .contains("\"image_url\":null")
+ .contains("\"version_id\":\"version-1\"")
+ .contains("\"expected_etag\":\"\\\"etag-1\\\"\"");
}
}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
index 3b7f8f9..df7048d 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
@@ -9,6 +9,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -18,6 +19,9 @@
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
import com.hanspoon.backend_api.domain.scan.service.ScanService;
import com.hanspoon.backend_api.global.common.PageResponse;
+import com.hanspoon.backend_api.global.exception.BusinessException;
+import com.hanspoon.backend_api.global.exception.ErrorCode;
+import com.hanspoon.backend_api.global.exception.GlobalExceptionHandler;
import com.hanspoon.backend_api.global.security.CurrentUser;
import java.util.List;
import java.util.UUID;
@@ -58,6 +62,7 @@ public Object resolveArgument(
}
};
mockMvc = MockMvcBuilders.standaloneSetup(new ScanController(scanService))
+ .setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(currentUserResolver, new PageableHandlerMethodArgumentResolver())
.build();
}
@@ -82,11 +87,34 @@ void startScanRejectsBlankStorageKey() throws Exception {
.andExpect(status().isBadRequest());
}
+ @Test
+ void startScanRejectsUnknownSource() throws Exception {
+ mockMvc.perform(post("/api/v1/scans")
+ .contentType("application/json")
+ .content("{\"storageKey\":\"menu-x.jpg\",\"source\":\"external-url\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
+ }
+
+ @Test
+ void startScanReturns503WhenScanCapacityIsExhausted() throws Exception {
+ when(scanService.startScan(eq(USER_ID), any()))
+ .thenThrow(new BusinessException(ErrorCode.SCAN_CAPACITY_EXCEEDED));
+
+ mockMvc.perform(post("/api/v1/scans")
+ .contentType("application/json")
+ .content("{\"storageKey\":\"menu-x.jpg\",\"source\":\"upload\"}"))
+ .andExpect(status().isServiceUnavailable())
+ .andExpect(header().string("Retry-After", "2"))
+ .andExpect(jsonPath("$.code").value("SCAN_CAPACITY_EXCEEDED"));
+ }
+
@Test
void getScanReturnsResult() throws Exception {
UUID scanId = UUID.randomUUID();
when(scanService.getScan(eq(USER_ID), eq(scanId)))
- .thenReturn(new ScanResultResponse(scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null));
+ .thenReturn(
+ new ScanResultResponse(scanId, ScanStatus.COMPLETED, null, 2, 1, null, List.of(), null, null));
mockMvc.perform(get("/api/v1/scans/{scanId}", scanId))
.andExpect(status().isOk())
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java
new file mode 100644
index 0000000..9887cec
--- /dev/null
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/entity/ScanSessionTest.java
@@ -0,0 +1,23 @@
+package com.hanspoon.backend_api.domain.scan.entity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class ScanSessionTest {
+
+ @Test
+ void terminalFailureCannotBeOverwrittenByALateWorker() {
+ ScanSession session = ScanSession.start(UUID.randomUUID(), "scans/user/menu.jpg");
+ session.markFailed("SCAN_PROCESSING_TIMEOUT");
+
+ assertThatThrownBy(() -> session.applyRuleEngineResult(1, ScanStatus.COMPLETED))
+ .isInstanceOf(IllegalStateException.class);
+ session.markFailed("INTERNAL_SERVER_ERROR");
+
+ assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ assertThat(session.getFailureCode()).isEqualTo("SCAN_PROCESSING_TIMEOUT");
+ }
+}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
index 96821c6..7328565 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanProcessorTest.java
@@ -2,6 +2,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -9,6 +10,7 @@
import com.hanspoon.backend_api.domain.ai.client.AiClient;
import com.hanspoon.backend_api.domain.ai.dto.common.EscalationCase;
import com.hanspoon.backend_api.domain.ai.dto.common.RiskLevel;
+import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrRequest;
import com.hanspoon.backend_api.domain.ai.dto.ocr.OcrResponse;
import com.hanspoon.backend_api.domain.ai.dto.result.FinalMenu;
import com.hanspoon.backend_api.domain.ai.dto.result.FinalMessage;
@@ -24,6 +26,7 @@
import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository;
import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.domain.upload.service.S3StorageService;
import com.hanspoon.backend_api.domain.user.entity.ReligionType;
import com.hanspoon.backend_api.domain.user.entity.UserProfile;
@@ -34,10 +37,11 @@
import java.util.List;
import java.util.Optional;
import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
+import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -45,6 +49,10 @@
class ScanProcessorTest {
private static final String STORAGE_KEY = "scans/11111111-1111-1111-1111-111111111111/abc.jpg";
+ private static final String VERSION_ID = "version-1";
+ private static final String ETAG = "\"etag-1\"";
+ private static final VerifiedUpload VERIFIED_UPLOAD =
+ new VerifiedUpload(STORAGE_KEY, VERSION_ID, ETAG, 123L, "image/jpeg");
@Mock
private AiClient aiClient;
@@ -67,9 +75,23 @@ class ScanProcessorTest {
@Mock
private MenuAnalysisRepository menuAnalysisRepository;
- @InjectMocks
private ScanProcessor scanProcessor;
+ // ScanStateWriter 는 실제 객체 사용. 상태 전이 위임 지점까지 함께 검증.
+ @BeforeEach
+ void setUp() {
+ ScanStateWriter scanStateWriter =
+ new ScanStateWriter(scanSessionRepository, menuImageRepository, menuAnalysisRepository);
+ scanProcessor = new ScanProcessor(
+ aiClient,
+ s3StorageService,
+ userProfileRepository,
+ userAllergyRepository,
+ scanSessionRepository,
+ scanStateWriter,
+ false);
+ }
+
private static com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis ocrMenu(
String name, String price, boolean spicy, int order) {
return new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuAnalysis(
@@ -83,7 +105,25 @@ private OcrResponse usableOcr() {
new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage(
"upload", STORAGE_KEY, "https://s3/presigned", "image/jpeg", 123L),
new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality(
- "usable", 80, 20, 2, 1.0, 1280, 960, null, List.of(), List.of()),
+ "usable",
+ 80,
+ 20,
+ 2,
+ 1.0,
+ 1280,
+ 960,
+ null,
+ List.of(),
+ List.of(),
+ false,
+ false,
+ "original",
+ 1,
+ null,
+ 100L,
+ 16_000L,
+ "s3_iam",
+ 0L),
List.of(ocrMenu("samgyeopsal", "9000", false, 1), ocrMenu("doenjang", "8000", true, 2)),
null);
}
@@ -95,8 +135,8 @@ void completesScanAndMergesOcrWithRuleEngine() {
UUID scanId = session.getId();
OcrResponse ocr = usableOcr();
+ when(scanSessionRepository.existsById(scanId)).thenReturn(true);
when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session));
- when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r");
when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY);
when(aiClient.requestOcr(any())).thenReturn(ocr);
UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true);
@@ -146,7 +186,14 @@ void completesScanAndMergesOcrWithRuleEngine() {
"doenjang", "has_unclear_broth", new OwnerQuestion("use anchovy?", null, null)))));
when(aiClient.result(any())).thenReturn(finalResult);
- scanProcessor.process(scanId, userId, STORAGE_KEY, "upload");
+ scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload");
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OcrRequest.class);
+ verify(aiClient).requestOcr(requestCaptor.capture());
+ assertThat(requestCaptor.getValue().storageKey()).isEqualTo(STORAGE_KEY);
+ assertThat(requestCaptor.getValue().imageUrl()).isNull();
+ assertThat(requestCaptor.getValue().versionId()).isEqualTo(VERSION_ID);
+ assertThat(requestCaptor.getValue().expectedEtag()).isEqualTo(ETAG);
assertThat(session.getScanStatus()).isEqualTo(ScanStatus.COMPLETED);
assertThat(session.getMenuCount()).isEqualTo(2);
@@ -158,18 +205,46 @@ void completesScanAndMergesOcrWithRuleEngine() {
verify(menuAnalysisRepository).saveAll(captor.capture());
List saved = captor.getValue();
assertThat(saved).hasSize(2);
- // OCR 가격 + FinalOutput 위험도/태그가 같은 행에 머지됐는지
+ // OCR 가격 + FinalOutput 위험도·태그의 동일 행 머지 확인
assertThat(saved.get(0).getMenuNameKo()).isEqualTo("samgyeopsal");
assertThat(saved.get(0).getPriceText()).isEqualTo("9000");
assertThat(saved.get(0).getRiskLevel()).isEqualTo(RiskLevel.DANGER);
assertThat(saved.get(0).getHitTags()).containsExactly("is_pork");
assertThat(saved.get(0).getDisplayOrder()).isEqualTo(1);
assertThat(saved.get(1).getRiskLevel()).isEqualTo(RiskLevel.CAUTION);
- // ai_result 최종 표시 필드 머지
+ // ai_result 최종 표시 필드 머지 확인
assertThat(saved.get(0).getMessage().ko()).isEqualTo("pork included");
assertThat(saved.get(1).getOwnerCard().question().ko()).isEqualTo("use anchovy?");
}
+ @Test
+ void persistsOcrStateBeforeCallingRuleEngine() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null);
+ UUID scanId = session.getId();
+ OcrResponse ocr = usableOcr();
+
+ when(scanSessionRepository.existsById(scanId)).thenReturn(true);
+ when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session));
+ when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY);
+ when(aiClient.requestOcr(any())).thenReturn(ocr);
+ UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true);
+ when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile));
+ when(userAllergyRepository.findByUserProfileId(profile.getId())).thenReturn(List.of());
+ when(aiClient.judge(any())).thenThrow(new BusinessException(ErrorCode.RULE_ENGINE_ERROR, "boom"));
+
+ scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload");
+
+ // OCR 결과는 룰엔진 호출 전 커밋. 뒤 단계 실패해도 menu_image · menu_count 잔존.
+ InOrder order = inOrder(aiClient, menuImageRepository);
+ order.verify(aiClient).requestOcr(any());
+ order.verify(menuImageRepository).save(any());
+ order.verify(aiClient).judge(any());
+
+ assertThat(session.getMenuCount()).isEqualTo(2);
+ assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ }
+
@Test
void marksNeedsRetakeAndSkipsRuleEngine() {
UUID userId = UUID.randomUUID();
@@ -179,16 +254,34 @@ void marksNeedsRetakeAndSkipsRuleEngine() {
new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanSession("menu.jpg", 0, null, "completed", null),
new com.hanspoon.backend_api.domain.ai.dto.ocr.MenuImage("upload", STORAGE_KEY, "u", "image/jpeg", 1L),
new com.hanspoon.backend_api.domain.ai.dto.ocr.ScanQuality(
- "needs_retake", 20, 1, 0, 0.0, 100, 100, null, List.of(), List.of("too blurry")),
+ "needs_retake",
+ 20,
+ 1,
+ 0,
+ 0.0,
+ 100,
+ 100,
+ null,
+ List.of(),
+ List.of("too blurry"),
+ true,
+ true,
+ "preprocessed",
+ 2,
+ null,
+ 200L,
+ 16_000L,
+ "s3_iam",
+ 0L),
List.of(),
null);
+ when(scanSessionRepository.existsById(scanId)).thenReturn(true);
when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session));
- when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r");
when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY);
when(aiClient.requestOcr(any())).thenReturn(ocr);
- scanProcessor.process(scanId, userId, STORAGE_KEY, "upload");
+ scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload");
assertThat(session.getScanStatus()).isEqualTo(ScanStatus.NEEDS_RETAKE);
assertThat(session.getRetakeReasons()).containsExactly("too blurry");
@@ -202,13 +295,38 @@ void marksFailedWhenOcrThrows() {
ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null);
UUID scanId = session.getId();
+ when(scanSessionRepository.existsById(scanId)).thenReturn(true);
when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session));
- when(s3StorageService.createReadUrl(STORAGE_KEY)).thenReturn("https://s3/presigned?X-Amz-Signature=r");
when(aiClient.requestOcr(any())).thenThrow(new BusinessException(ErrorCode.OCR_SERVICE_ERROR, "boom"));
- scanProcessor.process(scanId, userId, STORAGE_KEY, "upload");
+ scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload");
+
+ assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ verify(menuAnalysisRepository, never()).saveAll(any());
+ }
+
+ @Test
+ void marksFailedWithoutSavingWhenFinalMenuCountDoesNotMatchOcr() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.create(userId, "menu.jpg", null, null, ScanStatus.PROCESSING, null);
+ UUID scanId = session.getId();
+
+ when(scanSessionRepository.existsById(scanId)).thenReturn(true);
+ when(scanSessionRepository.findById(scanId)).thenReturn(Optional.of(session));
+ when(s3StorageService.objectUri(STORAGE_KEY)).thenReturn("s3://test-bucket/" + STORAGE_KEY);
+ when(aiClient.requestOcr(any())).thenReturn(usableOcr());
+ UserProfile profile = UserProfile.create(userId, "KR", false, false, null, ReligionType.HALAL, true, true);
+ when(userProfileRepository.findByUserId(userId)).thenReturn(Optional.of(profile));
+ when(userAllergyRepository.findByUserProfileId(profile.getId())).thenReturn(List.of());
+ when(aiClient.judge(any())).thenReturn(new RuleEngineResponse(null, null, null, List.of()));
+ when(aiClient.result(any()))
+ .thenReturn(new FinalResultResponse(
+ List.of(new FinalMenu("samgyeopsal", RiskLevel.DANGER, List.of(), null, null))));
+
+ scanProcessor.process(scanId, userId, VERIFIED_UPLOAD, "upload");
assertThat(session.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ assertThat(session.getFailureCode()).isEqualTo(ErrorCode.AI_RESULT_MISMATCH.getCode());
verify(menuAnalysisRepository, never()).saveAll(any());
}
}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
index 3b5a430..7cebe91 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
@@ -4,6 +4,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -16,6 +17,7 @@
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.domain.upload.service.S3StorageService;
import com.hanspoon.backend_api.global.common.PageResponse;
import com.hanspoon.backend_api.global.exception.BusinessException;
@@ -29,6 +31,8 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.core.task.TaskRejectedException;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -48,6 +52,9 @@ class ScanServiceTest {
@Mock
private ScanProcessor scanProcessor;
+ @Mock
+ private ScanStateWriter scanStateWriter;
+
@InjectMocks
private ScanService scanService;
@@ -55,14 +62,70 @@ class ScanServiceTest {
void startScanSavesSessionTriggersProcessorAndReturnsProcessing() {
UUID userId = UUID.randomUUID();
String key = "scans/" + userId + "/2f1c9d3e-0000-4000-8000-000000000001.jpg";
+ VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg");
when(s3StorageService.resolveKey(userId, key)).thenReturn(key);
- when(scanSessionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ when(s3StorageService.verifyUploadObject(key)).thenReturn(upload);
+ when(scanSessionRepository.saveAndFlush(any(ScanSession.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0, ScanSession.class));
ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload"));
assertThat(response.status()).isEqualTo(ScanStatus.PROCESSING);
assertThat(response.scanId()).isNotNull();
- verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(key), eq("upload"));
+ verify(scanProcessor).process(eq(response.scanId()), eq(userId), eq(upload), eq("upload"));
+ }
+
+ @Test
+ void startScanReturnsExistingSessionForTheSameStorageKey() {
+ UUID userId = UUID.randomUUID();
+ String key = "scans/" + userId + "/same.jpg";
+ ScanSession existing = ScanSession.start(userId, key);
+ when(s3StorageService.resolveKey(userId, key)).thenReturn(key);
+ when(scanSessionRepository.findByUserIdAndStorageKey(userId, key)).thenReturn(Optional.of(existing));
+
+ ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload"));
+
+ assertThat(response.scanId()).isEqualTo(existing.getId());
+ verify(s3StorageService, never()).verifyUploadObject(any());
+ verify(scanProcessor, never()).process(any(), any(), any(), any());
+ }
+
+ @Test
+ void concurrentDuplicateStartReturnsTheSessionCreatedByTheWinner() {
+ UUID userId = UUID.randomUUID();
+ String key = "scans/" + userId + "/race.jpg";
+ VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg");
+ ScanSession winner = ScanSession.start(userId, key);
+ when(s3StorageService.resolveKey(userId, key)).thenReturn(key);
+ when(scanSessionRepository.findByUserIdAndStorageKey(userId, key))
+ .thenReturn(Optional.empty(), Optional.of(winner));
+ when(s3StorageService.verifyUploadObject(key)).thenReturn(upload);
+ when(scanSessionRepository.saveAndFlush(any()))
+ .thenThrow(new DataIntegrityViolationException("duplicate storage key"));
+
+ ScanCreatedResponse response = scanService.startScan(userId, new StartScanRequest(key, "upload"));
+
+ assertThat(response.scanId()).isEqualTo(winner.getId());
+ verify(scanProcessor, never()).process(any(), any(), any(), any());
+ }
+
+ @Test
+ void startScanDeletesSessionAndReturnsCapacityErrorWhenExecutorRejects() {
+ UUID userId = UUID.randomUUID();
+ String key = "scans/" + userId + "/full.jpg";
+ VerifiedUpload upload = new VerifiedUpload(key, "version-1", "\"etag-1\"", 123L, "image/jpeg");
+ when(s3StorageService.resolveKey(userId, key)).thenReturn(key);
+ when(s3StorageService.verifyUploadObject(key)).thenReturn(upload);
+ when(scanSessionRepository.saveAndFlush(any(ScanSession.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0, ScanSession.class));
+ doThrow(new TaskRejectedException("full")).when(scanProcessor).process(any(), any(), any(), any());
+
+ assertThatThrownBy(() -> scanService.startScan(userId, new StartScanRequest(key, "upload")))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.SCAN_CAPACITY_EXCEEDED);
+
+ verify(scanStateWriter).deleteRejected(any());
}
@Test
@@ -96,6 +159,21 @@ void getScanReturnsResultForOwner() {
assertThat(response.menus()).isEmpty();
}
+ @Test
+ void getScanReturnsFailureCodeWithoutInternalExceptionDetails() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.start(userId, "scans/" + userId + "/failed.jpg");
+ session.markFailed(ErrorCode.AI_SERVICE_OVERLOADED.getCode());
+ when(scanSessionRepository.findByIdAndUserId(session.getId(), userId)).thenReturn(Optional.of(session));
+ when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(session.getId()))
+ .thenReturn(List.of());
+
+ var response = scanService.getScan(userId, session.getId());
+
+ assertThat(response.status()).isEqualTo(ScanStatus.FAILED);
+ assertThat(response.failureCode()).isEqualTo("AI_SERVICE_OVERLOADED");
+ }
+
@Test
void getScanReturnsNullTitleWhenNotEditedAndRawScannedAt() {
UUID userId = UUID.randomUUID();
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java
new file mode 100644
index 0000000..aeba410
--- /dev/null
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanStateWriterIntegrationTest.java
@@ -0,0 +1,99 @@
+package com.hanspoon.backend_api.domain.scan.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.hanspoon.backend_api.TestcontainersConfiguration;
+import com.hanspoon.backend_api.domain.scan.entity.MenuImage;
+import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.MenuImageRepository;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.user.entity.User;
+import com.hanspoon.backend_api.domain.user.repository.UserRepository;
+import java.sql.Timestamp;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+@SpringBootTest
+@Import(TestcontainersConfiguration.class)
+class ScanStateWriterIntegrationTest {
+
+ @Autowired
+ private ScanStateWriter scanStateWriter;
+
+ @Autowired
+ private ScanSessionRepository scanSessionRepository;
+
+ @Autowired
+ private MenuImageRepository menuImageRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private StaleScanRecovery staleScanRecovery;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Test
+ void commitsOcrResultAndFailureInIndependentTransactions() {
+ User user = userRepository.save(User.create(uniqueEmail("tx"), "tx-user", "ko"));
+ String storageKey = "scans/" + user.getId() + "/tx.jpg";
+ ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey));
+
+ scanStateWriter.applyOcrResult(
+ session.getId(),
+ MenuImage.create(session.getId(), "upload", storageKey, "s3://test/" + storageKey, "image/jpeg", 123L),
+ 2,
+ Instant.parse("2026-09-11T00:00:00Z"));
+
+ // 외부 후속 단계가 실패해도 앞선 OCR 트랜잭션은 유지되고 실패 상태는 별도 커밋된다.
+ scanStateWriter.markFailed(session.getId(), "RULE_ENGINE_ERROR");
+
+ ScanSession reloaded = scanSessionRepository.findById(session.getId()).orElseThrow();
+ assertThat(reloaded.getMenuCount()).isEqualTo(2);
+ assertThat(reloaded.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ assertThat(reloaded.getFailureCode()).isEqualTo("RULE_ENGINE_ERROR");
+ assertThat(menuImageRepository.findByScanSessionId(session.getId())).isPresent();
+ }
+
+ @Test
+ void databaseConstraintRejectsDuplicateStorageKeyForTheSameUser() {
+ User user = userRepository.save(User.create(uniqueEmail("idempotency"), "idempotent-user", "ko"));
+ String storageKey = "scans/" + user.getId() + "/same.jpg";
+ scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey));
+
+ assertThatThrownBy(() -> scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey)))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ void recoversProcessingSessionLeftBehindByAStoppedWorker() {
+ User user = userRepository.save(User.create(uniqueEmail("recovery"), "recovery-user", "ko"));
+ String storageKey = "scans/" + user.getId() + "/stale.jpg";
+ ScanSession session = scanSessionRepository.saveAndFlush(ScanSession.start(user.getId(), storageKey));
+ jdbcTemplate.update(
+ "update scan_sessions set updated_at = ? where id = ?",
+ Timestamp.from(Instant.now().minus(Duration.ofMinutes(3))),
+ session.getId());
+
+ staleScanRecovery.recover();
+
+ ScanSession reloaded = scanSessionRepository.findById(session.getId()).orElseThrow();
+ assertThat(reloaded.getScanStatus()).isEqualTo(ScanStatus.FAILED);
+ assertThat(reloaded.getFailureCode()).isEqualTo("SCAN_PROCESSING_TIMEOUT");
+ }
+
+ private String uniqueEmail(String prefix) {
+ return prefix + "+" + UUID.randomUUID() + "@example.com";
+ }
+}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java
index bc1267f..c41ddfa 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/upload/controller/UploadControllerTest.java
@@ -12,6 +12,7 @@
import com.hanspoon.backend_api.domain.upload.service.S3StorageService;
import com.hanspoon.backend_api.global.security.CurrentUser;
import java.time.Instant;
+import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -59,7 +60,8 @@ void issuesUploadTicket() throws Exception {
.thenReturn(new UploadTicketResponse(
key,
"https://bucket.s3.ap-northeast-2.amazonaws.com/" + key + "?X-Amz-Signature=xyz",
- Instant.parse("2026-06-06T00:10:00Z")));
+ Instant.parse("2026-06-06T00:10:00Z"),
+ Map.of("Content-Type", "image/jpeg", "If-None-Match", "*")));
mockMvc.perform(post("/api/v1/uploads/sas")
.contentType("application/json")
@@ -67,7 +69,9 @@ void issuesUploadTicket() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.storageKey").value(key))
.andExpect(jsonPath("$.uploadUrl").value(containsString("X-Amz-Signature=")))
- .andExpect(jsonPath("$.expiresAt").exists());
+ .andExpect(jsonPath("$.expiresAt").exists())
+ .andExpect(jsonPath("$.uploadHeaders['Content-Type']").value("image/jpeg"))
+ .andExpect(jsonPath("$.uploadHeaders['If-None-Match']").value("*"));
}
@Test
diff --git a/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java
index 97a1785..d05ec07 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/upload/service/S3StorageServiceTest.java
@@ -2,9 +2,12 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import com.hanspoon.backend_api.domain.upload.dto.UploadTicketResponse;
+import com.hanspoon.backend_api.domain.upload.dto.VerifiedUpload;
import com.hanspoon.backend_api.global.config.S3Properties;
import com.hanspoon.backend_api.global.exception.BusinessException;
import com.hanspoon.backend_api.global.exception.ErrorCode;
@@ -15,6 +18,9 @@
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
class S3StorageServiceTest {
@@ -45,6 +51,9 @@ void createUploadUrlIssuesPresignedPut() {
.contains("X-Amz-Signature=")
.contains("X-Amz-Expires=");
assertThat(ticket.expiresAt()).isNotNull();
+ assertThat(ticket.uploadHeaders())
+ .containsEntry("Content-Type", "image/jpeg")
+ .containsEntry("If-None-Match", "*");
}
@Test
@@ -84,6 +93,74 @@ void resolveKeyRejectsMalformedKeys() {
assertThatThrownBy(() -> service.resolveKey(USER, "")).isInstanceOf(BusinessException.class);
}
+ @Test
+ void verifyUploadObjectReturnsVersionAndEtagFromHeadObject() {
+ S3Client s3Client = mock(S3Client.class);
+ S3StorageService storageService = new S3StorageService(presigner(), s3Client, properties);
+ String key = "scans/" + USER + "/abc.jpg";
+ when(s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenReturn(HeadObjectResponse.builder()
+ .versionId("version-1")
+ .eTag("\"etag-1\"")
+ .contentLength(123L)
+ .contentType("image/jpeg")
+ .build());
+
+ VerifiedUpload upload = storageService.verifyUploadObject(key);
+
+ assertThat(upload.storageKey()).isEqualTo(key);
+ assertThat(upload.versionId()).isEqualTo("version-1");
+ assertThat(upload.eTag()).isEqualTo("\"etag-1\"");
+ assertThat(upload.contentLength()).isEqualTo(123L);
+ assertThat(upload.contentType()).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void verifyUploadObjectRejectsOversizedObject() {
+ S3Client s3Client = mock(S3Client.class);
+ S3StorageService storageService = new S3StorageService(presigner(), s3Client, properties);
+ when(s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenReturn(HeadObjectResponse.builder()
+ .contentLength(properties.maxFileSize() + 1)
+ .contentType("image/jpeg")
+ .build());
+
+ assertThatThrownBy(() -> storageService.verifyUploadObject("scans/" + USER + "/large.jpg"))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.FILE_TOO_LARGE);
+ }
+
+ @Test
+ void verifyUploadObjectRejectsUnexpectedContentType() {
+ S3Client s3Client = mock(S3Client.class);
+ S3StorageService storageService = new S3StorageService(presigner(), s3Client, properties);
+ when(s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenReturn(HeadObjectResponse.builder()
+ .contentLength(123L)
+ .contentType("image/gif")
+ .build());
+
+ assertThatThrownBy(() -> storageService.verifyUploadObject("scans/" + USER + "/fake.jpg"))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.INVALID_CONTENT_TYPE);
+ }
+
+ @Test
+ void verifyUploadObjectMapsMissingObject() {
+ S3Client s3Client = mock(S3Client.class);
+ S3StorageService storageService = new S3StorageService(presigner(), s3Client, properties);
+ when(s3Client.headObject(any(HeadObjectRequest.class)))
+ .thenThrow(
+ S3Exception.builder().statusCode(404).message("missing").build());
+
+ assertThatThrownBy(() -> storageService.verifyUploadObject("scans/" + USER + "/missing.jpg"))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.UPLOAD_NOT_FOUND);
+ }
+
@Test
void objectUriReturnsS3Scheme() {
assertThat(service.objectUri("scans/" + USER + "/abc.jpg"))
@@ -91,9 +168,12 @@ void objectUriReturnsS3Scheme() {
}
@Test
- void createReadUrlIssuesPresignedGet() {
+ void createReadUrlPinsFallbackToTheVerifiedVersion() {
String key = "scans/" + USER + "/abc.jpg";
- assertThat(service.createReadUrl(key)).contains(key).contains("X-Amz-Signature=");
+ assertThat(service.createReadUrl(key, "version-1"))
+ .contains(key)
+ .contains("versionId=version-1")
+ .contains("X-Amz-Signature=");
}
}
diff --git a/src/test/java/com/hanspoon/backend_api/global/config/AsyncConfigTest.java b/src/test/java/com/hanspoon/backend_api/global/config/AsyncConfigTest.java
new file mode 100644
index 0000000..f920fe9
--- /dev/null
+++ b/src/test/java/com/hanspoon/backend_api/global/config/AsyncConfigTest.java
@@ -0,0 +1,43 @@
+package com.hanspoon.backend_api.global.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.task.TaskRejectedException;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+class AsyncConfigTest {
+
+ @Test
+ void scanExecutorRunsOnlyTwoTasksAndRejectsInsteadOfQueueing() throws Exception {
+ ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) new AsyncConfig().scanTaskExecutor(2, 2, 0);
+ CountDownLatch started = new CountDownLatch(2);
+ CountDownLatch release = new CountDownLatch(1);
+ Runnable blockingTask = () -> {
+ started.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ }
+ };
+
+ try {
+ executor.execute(blockingTask);
+ executor.execute(blockingTask);
+ assertThat(started.await(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS))
+ .isTrue();
+
+ assertThat(executor.getActiveCount()).isEqualTo(2);
+ assertThat(executor.getThreadPoolExecutor().getQueue()).isEmpty();
+ assertThatThrownBy(() -> executor.execute(() -> {})).isInstanceOf(TaskRejectedException.class);
+ } finally {
+ release.countDown();
+ executor.shutdown();
+ }
+ }
+}