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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 한스푼 · AWS 아키텍처 + ap-northeast-2 · 단일 EC2 · NAT/ALB 없음 + + + + 사용자 + + 모바일 · 웹 + + + Vercel + han-spoon.site · SPA + /api/* rewrite + + + GitHub Actions + backend · ai 저장소 + + + + CloudFront + + api.han-spoon.site + 오리진 2개 · PriceClass_200 + + + ACM 인증서 + us-east-1 (CloudFront 전용) + + + + AWS 리전 · ap-northeast-2 + + + + VPC + 10.0.0.0/16 + + + + 퍼블릭 서브넷 · AZ-a + 10.0.0.0/24 + + + EIP (고정) + + + + EC2 t4g.small · ASG 1대 · ECS + + + ECS Task · network_mode: host + + + backend + + :8080 · 640MB + + ai + + + backend → ai · POST /v1/ocr · /v1/ruleengine · /v1/result + http://localhost:8000 — host 네트워크 공유라 프로세스 간 호출 + :8000 · 768MB + + + + 퍼블릭 서브넷 · AZ-b + 10.0.1.0/24 + 비어 있음 + ASG 다중 AZ 확장용 + + + + S3 Gateway Endpoint + + 라우팅 테이블 2개 연결 · 무료 + + + 인터넷 게이트웨이 + + 아웃바운드 · NAT 없음 + + + + 프라이빗 서브넷 · AZ-a + 10.0.10.0/24 + + RDS PostgreSQL 16 + + db.t4g.micro · Single-AZ · gp3 20GB + 퍼블릭 접근 불가 · 백업 7일 + + + 프라이빗 서브넷 · AZ-b + 10.0.11.0/24 + DB 서브넷 그룹 소속 + Multi-AZ 승격용 + + + 보안 그룹 2개 · instance ← CloudFront / rds ← instance + + + + 리전 서비스 (VPC 외부) + + + Amazon S3 + + hanspoon-prod-images-* · 퍼블릭 차단 · 버저닝 + scans/ 90일 만료 · menus/ 영구 보관 + presigned PUT/GET · CORS 허용 오리진 + + + Amazon ECR + + backend · ai · IMMUTABLE 태그 + 최근 10개 유지 + + + SSM Parameter Store + + SecureString 6개 · 태스크 시작 시 주입 + db_password · jwt_secret · google_client_id … + + + CloudWatch Logs + + /ecs/hanspoon-prod/backend + /ecs/hanspoon-prod/ai + + + Budgets + 월 예산 알림 · 80% / 100% + + + + CLOVA OCR + General V2 · 네이버 클라우드 + + + OpenAI + gpt-4o-mini + + + + + + + /api/* rewrite + → api.han-spoon.site + + + TLS + + + + api 오리진 · EIP:8080 · http-only + 커스텀 헤더 X-Origin-Secret · SG 는 CloudFront 프리픽스 목록만 허용 + + + + images 오리진 · /menus/* · OAC + + + + OIDC AssumeRole + 이미지 푸시 + 태스크 갱신 + + + + 5432 + SG: instance 에서만 + + + + + S3 트래픽은 인터넷을 타지 않음 + + + + + + + + + + + 아웃바운드 (IGW 경유) + + + + + 뷰어 요청 + + VPC 내부 + + 배포 · 아웃바운드 + 점선 박스 = 현재 비어 있으나 확장 시 사용 + 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(); + } + } +}