diff --git a/.env.example b/.env.example index db4aa504..34128d3b 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,22 @@ AI_RUNTIME_MAX_CONCURRENT_CALLS=8 AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION=30s +# 여권·외국인등록증 OCR은 일반 분석과 별도 endpoint/스위치를 사용합니다. +# 아래 두 enabled 값과 암호화 키가 모두 준비된 통합 환경에서만 켭니다. +AI_OCR_ENABLED=false +AI_OCR_ENDPOINT=http://127.0.0.1:8000/internal/v1/ocr/worker-documents +# AI_OCR_SERVICE_CREDENTIAL= +AI_OCR_CONNECT_TIMEOUT=2s +AI_OCR_OVERALL_TIMEOUT=20s +AI_OCR_MAX_RESPONSE_BYTES=1048576 +AI_OCR_MAX_CONCURRENT_CALLS=4 +AI_OCR_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +AI_OCR_CIRCUIT_BREAKER_OPEN_DURATION=30s +DOCUMENT_OCR_ENABLED=false +# OCR 결과는 DB에 평문으로 저장하지 않습니다. 아래 값은 32바이트 난수의 Base64입니다. +# 생성 예시: openssl rand -base64 32 +# OCR_RESULT_ENCRYPTION_KEY_BASE64= +OCR_RESULT_KEY_VERSION=local-v1 WORKER_IMPORT_SOURCE_RETENTION=7d # Client용 AiRun 상태 SSE입니다. 실행 기준은 SSE가 아니라 DB와 GET /api/v1/ai-runs/{id}입니다. diff --git a/README.md b/README.md index 2fdbf2eb..cd9d2d27 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,8 @@ src/main/java/com/fowoco/server/ - 사업장 데이터는 인증 Context의 `company_id`로 격리합니다. - Client가 보낸 `company_id`를 신뢰하지 않습니다. -- 외국인등록번호·여권번호·전화번호·계좌번호를 AI 입력과 일반 로그에 넣지 않습니다. +- 일반 자연어 분석 JSON에는 외국인등록번호·여권번호·전화번호·계좌번호를 넣지 않습니다. +- OCR은 HR이 선택한 서류 파일만 전용 내부 API로 전송합니다. 실행은 Outbox로 복구하고, 원본 추출값과 HR 수정값을 분리해 암호화 저장하며 일반 로그에는 값 대신 수정한 필드명만 남깁니다. - AI 결과와 요청 초안은 HR 승인 전 자동 발송하지 않습니다. - 중요한 변경은 actor, 시각, `request_id`와 함께 감사로그에 남깁니다. - Worker Link 원본 token, JWT, API Key와 비밀번호를 GitHub·로그·문서에 남기지 않습니다. @@ -202,5 +203,5 @@ src/main/java/com/fowoco/server/ - 외부기관 자동 로그인·자동 제출 - AI의 법률·노무 최종 판단 - 자체 학습 모델의 필수 서비스 탑재 -- OCR·대용량 파일 처리 전체 구현 +- 범용 OCR·대용량 일괄 파일 처리 - 실제 Blue/Green Agent 트래픽 전환 diff --git a/compose.demo.yml b/compose.demo.yml index 2893a9de..e72d27fb 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -51,6 +51,12 @@ services: AI_RUNTIME_ENABLED: ${AI_RUNTIME_ENABLED:-false} AI_RUNTIME_ENDPOINT: ${AI_RUNTIME_ENDPOINT:-http://host.docker.internal:8000/internal/v1/analyses} AI_RUNTIME_SERVICE_CREDENTIAL: ${AI_RUNTIME_SERVICE_CREDENTIAL:-} + AI_OCR_ENABLED: ${AI_OCR_ENABLED:-false} + AI_OCR_ENDPOINT: ${AI_OCR_ENDPOINT:-http://host.docker.internal:8000/internal/v1/ocr/worker-documents} + AI_OCR_SERVICE_CREDENTIAL: ${AI_OCR_SERVICE_CREDENTIAL:-} + DOCUMENT_OCR_ENABLED: ${DOCUMENT_OCR_ENABLED:-false} + OCR_RESULT_ENCRYPTION_KEY_BASE64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:-} + OCR_RESULT_KEY_VERSION: ${OCR_RESULT_KEY_VERSION:-demo-v1} DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false} DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-} extra_hosts: diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 75c95a65..7ffb1be6 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -206,6 +206,40 @@ Template을 고를 수 있도록 alpha-3 코드(`VNM`, `PHL`)로 변환합니다 AI를 호출하지 않습니다. 국가를 추가할 때는 AI Template 배포, Server 변환표와 양쪽 계약 테스트를 함께 변경합니다. 외국인등록증(`ARC`) 요청에는 국가 코드를 보내지 않습니다. +## OCR 실행·저장 경계 + +HR 화면은 AI를 직접 호출하지 않고 다음 Server API를 사용합니다. + +| API | 역할 | +| --- | --- | +| `POST /api/v1/documents/{documentId}/ocr-runs` | 실행 이력을 `QUEUED`로 먼저 저장하고 202 반환 | +| `GET /api/v1/documents/{documentId}/ocr-runs/{ocrRunId}` | 실행 상태와 HR 검토용 결과 조회 | +| `GET /api/v1/documents/{documentId}/ocr-runs/latest` | 해당 문서의 최신 실행 조회 | +| `POST /api/v1/documents/{documentId}/ocr-runs/{ocrRunId}/review` | HR의 수정값과 검토 완료·반려 기록 | + +Server는 연결된 `stored_file`을 읽어 AI의 +`POST /internal/v1/ocr/worker-documents/{workerDocumentId}`로 multipart 전송합니다. +AI는 DB에 접근하거나 결과를 저장하지 않습니다. OCR 요청은 실행 이력과 같은 트랜잭션에서 +Outbox 이벤트로 저장됩니다. 서버가 중단되면 메모리 작업 대신 DB에 남은 이벤트 lease를 +다른 인스턴스가 회수해 다시 실행합니다. 실행 중 중단된 트랜잭션은 `QUEUED`로 롤백되므로 +영구적인 `RUNNING` 상태를 만들지 않습니다. + +Server는 계약 검증을 통과한 `fields`, `field_confidences`, `review_reasons`를 하나의 +AES-256-GCM 암호문으로 저장합니다. HR의 `corrected_fields`는 OCR 원본을 덮어쓰지 않고 +별도 암호문으로 저장합니다. 감사로그에는 수정한 field key만 기록하며 여권번호 같은 실제 +값은 일반 컬럼·감사로그·오류 메시지에 남기지 않습니다. + +`READY_FOR_REVIEW`와 `REVIEW_REQUIRED` 모두 HR 확인 대상입니다. `APPROVE`는 OCR 검토를 +완료했다는 뜻이며, Worker·Document·Agent slot을 자동 수정하지 않습니다. 확정값 반영은 +별도 command와 권한 정책이 합의된 뒤 연결합니다. + +Server는 AI 응답의 허용 field와 confidence뿐 아니라 다음도 다시 검증합니다. + +- `SUCCEEDED` 결과의 문서 종류별 필수 field와 ISO `YYYY-MM-DD` 날짜 +- 여권 국가와 Template ID의 일치 +- ARC Template ID(`43024`, `43025`)와 `FRONT`·`BACK`의 일치 +- 빈 `SUCCEEDED` 결과와 모순된 Template·면 정보 거부 + ## Server가 거부하는 응답 - 요청과 다른 `requestId` @@ -275,6 +309,23 @@ Server 내부 요청의 `deadlineMs`와 `AI_RUNTIME_OVERALL_TIMEOUT` 중 더 짧 timeout으로 사용합니다. `deadlineMs` 자체는 Runtime JSON에 전송하지 않습니다. 따라서 상위 AiRun이 허용한 시간보다 오래 기다리지 않습니다. +OCR까지 활성화하려면 별도 Secret과 결과 암호화 키를 함께 설정합니다. + +```dotenv +AI_OCR_ENABLED=true +AI_OCR_ENDPOINT=https://ai.example.com/internal/v1/ocr/worker-documents +AI_OCR_SERVICE_CREDENTIAL=<배포 환경 Secret> +DOCUMENT_OCR_ENABLED=true +OCR_RESULT_ENCRYPTION_KEY_BASE64=<32바이트 난수의 Base64> +OCR_RESULT_KEY_VERSION=demo-v1 +``` + +`AI_OCR_ENABLED`만 켜거나 암호화 키 없이 `DOCUMENT_OCR_ENABLED`를 켜지 않습니다. 암호화 +키는 Git에 저장하지 않고 배포 Secret으로 주입합니다. key version은 암호문과 함께 남겨 +향후 KMS/Vault Adapter로 교체할 때 어떤 키로 생성했는지 추적합니다. AI #20의 Stateless +OCR 구현이 CI를 통과해 병합되고 실제 파일 smoke test까지 끝나기 전에는 두 기능 스위치를 +운영에서 `false`로 유지합니다. + ## 장애가 발생하면 | 상황 | 안전한 실패 코드 | 처리 방향 | diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index bde7745c..074f4ace 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -44,6 +44,10 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 | | AI | `AI_RUNTIME_ENDPOINT` | 예: `http://ai:8000/internal/v1/analyses` | | AI | `AI_RUNTIME_SERVICE_CREDENTIAL` | Server↔AI 내부 Bearer credential | +| OCR | `AI_OCR_ENABLED=true`, `DOCUMENT_OCR_ENABLED=true` | AI OCR 호출과 Server 저장 기능 활성화 | +| OCR | `AI_OCR_ENDPOINT`, `AI_OCR_SERVICE_CREDENTIAL` | OCR 내부 endpoint와 Bearer credential | +| OCR | `OCR_RESULT_ENCRYPTION_KEY_BASE64` | 32바이트 OCR 결과 암호화 키의 Base64 | +| OCR | `OCR_RESULT_KEY_VERSION` | 암호화 키 식별 version | 비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본 `PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/ocr/AiOcrPassportCountryCodeResolver.java b/src/main/java/com/fowoco/server/aiintegration/application/ocr/AiOcrPassportCountryCodeResolver.java index 4a87f1cc..4231440e 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/ocr/AiOcrPassportCountryCodeResolver.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/ocr/AiOcrPassportCountryCodeResolver.java @@ -22,8 +22,14 @@ public final class AiOcrPassportCountryCodeResolver { "CN", "CHN", "VN", "VNM" ); - private static final Set SUPPORTED_OCR_COUNTRIES = - Set.copyOf(WORKER_TO_OCR_COUNTRY.values()); + private static final Map OCR_TEMPLATE_IDS = Map.of( + "KOR", 43019L, + "PHL", 43021L, + "JPN", 43022L, + "CHN", 43023L, + "VNM", 43038L + ); + private static final Set SUPPORTED_OCR_COUNTRIES = OCR_TEMPLATE_IDS.keySet(); public String fromWorkerNationalityCode(String workerNationalityCode) { if (workerNationalityCode == null) { @@ -46,6 +52,10 @@ public boolean isSupportedOcrCountryCode(String countryCode) { && SUPPORTED_OCR_COUNTRIES.contains(countryCode); } + public Long expectedTemplateId(String countryCode) { + return OCR_TEMPLATE_IDS.get(countryCode); + } + private void reject(AiRuntimeFailureCode code, String safeMessage) { throw new AiRuntimeContractException(code, safeMessage); } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiOcrContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiOcrContractValidator.java index 18a28896..7cdc5f3c 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiOcrContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiOcrContractValidator.java @@ -2,15 +2,21 @@ import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentSide; import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentType; import com.fowoco.server.aiintegration.application.ocr.AiOcrPassportCountryCodeResolver; import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus; import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +import org.springframework.stereotype.Component; +@Component public final class AiOcrContractValidator { public static final int MAX_FILE_BYTES = 20 * 1024 * 1024; @@ -35,6 +41,16 @@ public final class AiOcrContractValidator { "stay_expiration_date", "residence_address_1" ); + private static final Set PASSPORT_REQUIRED_FIELDS = Set.of( + "passport_number", "surname", "given_names", "date_of_birth", "passport_expiry_date" + ); + private static final Set DATE_FIELDS = Set.of( + "date_of_birth", "passport_issue_date", "passport_expiry_date", "stay_expiration_date" + ); + private static final Map ARC_TEMPLATE_SIDES = Map.of( + 43024L, AiOcrDocumentSide.FRONT, + 43025L, AiOcrDocumentSide.BACK + ); private static final Pattern SAFE_REASON = Pattern.compile("[a-z0-9_:-]{1,120}"); private final AiOcrPassportCountryCodeResolver passportCountryCodeResolver = @@ -90,6 +106,9 @@ public void validateResponse(AiOcrRequest request, AiOcrResponse response) { if (value == null || value.isBlank() || value.length() > 500) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR response field value is invalid."); } + if (DATE_FIELDS.contains(key)) { + validateIsoDate(value); + } }); response.fieldConfidences().forEach(this::validateConfidence); response.reviewReasons().forEach(reason -> { @@ -97,7 +116,7 @@ public void validateResponse(AiOcrRequest request, AiOcrResponse response) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR review reason is invalid."); } }); - validateOutcome(response); + validateOutcome(request, response); } private void validateConfidence(String key, BigDecimal confidence) { @@ -108,7 +127,7 @@ private void validateConfidence(String key, BigDecimal confidence) { } } - private void validateOutcome(AiOcrResponse response) { + private void validateOutcome(AiOcrRequest request, AiOcrResponse response) { if (response.status() == AiOcrStatus.SUCCEEDED && !response.reviewReasons().isEmpty()) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Successful OCR cannot require review."); } @@ -118,6 +137,80 @@ private void validateOutcome(AiOcrResponse response) { if (response.matchedTemplateId() != null && response.matchedTemplateId() < 1) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template id is invalid."); } + validateTemplate(request, response); + if (response.status() == AiOcrStatus.SUCCEEDED) { + validateSuccessfulFields(request, response); + } + } + + private void validateTemplate(AiOcrRequest request, AiOcrResponse response) { + Long templateId = response.matchedTemplateId(); + if (templateId == null) { + if (response.status() == AiOcrStatus.SUCCEEDED + || response.documentSide() != null + || !response.fields().isEmpty()) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template result is inconsistent."); + } + return; + } + if (request.documentType() == AiOcrDocumentType.PASSPORT_COPY) { + Long expected = passportCountryCodeResolver.expectedTemplateId(request.countryCode()); + if (!templateId.equals(expected)) { + requireUnexpectedTemplateReview(response); + return; + } + if (response.documentSide() != null) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Passport OCR side must be omitted."); + } + return; + } + AiOcrDocumentSide expectedSide = ARC_TEMPLATE_SIDES.get(templateId); + if (expectedSide == null) { + requireUnexpectedTemplateReview(response); + return; + } + if (response.documentSide() != expectedSide) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC OCR template side does not match."); + } + } + + private void requireUnexpectedTemplateReview(AiOcrResponse response) { + if (response.status() != AiOcrStatus.REVIEW_REQUIRED + || !response.reviewReasons().contains("unexpected_template") + || response.documentSide() != null + || !response.fields().isEmpty()) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template does not match the request."); + } + } + + private void validateSuccessfulFields(AiOcrRequest request, AiOcrResponse response) { + if (response.fields().isEmpty()) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Successful OCR fields are empty."); + } + if (request.documentType() == AiOcrDocumentType.PASSPORT_COPY) { + if (!response.fields().keySet().containsAll(PASSPORT_REQUIRED_FIELDS)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Passport OCR required fields are missing."); + } + return; + } + if (response.documentSide() == AiOcrDocumentSide.FRONT + && !response.fields().containsKey("alien_registration_number")) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC front required field is missing."); + } + if (response.documentSide() == AiOcrDocumentSide.BACK + && response.fields().keySet().stream().noneMatch( + key -> key.startsWith("stay_") || key.startsWith("residence_") + )) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC back required field is missing."); + } + } + + private void validateIsoDate(String value) { + try { + LocalDate.parse(value); + } catch (DateTimeParseException exception) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR date field is invalid."); + } } private void reject(AiRuntimeFailureCode code, String safeMessage) { diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpConfiguration.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpConfiguration.java new file mode 100644 index 00000000..18d612e6 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpConfiguration.java @@ -0,0 +1,71 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.port.AiOcrClient; +import com.fowoco.server.aiintegration.application.validation.AiOcrContractValidator; +import com.fowoco.server.aiintegration.application.validation.ValidatingAiOcrClient; +import java.net.http.HttpClient; +import java.time.Clock; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.cfg.CoercionAction; +import tools.jackson.databind.cfg.CoercionInputShape; +import tools.jackson.databind.type.LogicalType; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AiOcrProperties.class) +public class AiOcrHttpConfiguration { + + @Bean + public AiOcrClient aiOcrClient( + AiOcrProperties properties, + AiOcrContractValidator validator, + ObjectMapper applicationObjectMapper, + Clock clock + ) { + if (!properties.isEnabled()) { + return new DisabledAiOcrClient(); + } + properties.validateEnabledConfiguration(); + ObjectMapper contractObjectMapper = createContractObjectMapper(applicationObjectMapper); + HttpClient httpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(properties.getConnectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + AiRuntimeCircuitBreaker circuitBreaker = new AiRuntimeCircuitBreaker( + properties.getCircuitBreakerFailureThreshold(), + properties.getCircuitBreakerOpenDuration(), + clock + ); + AiOcrClient remote = new RemoteAiOcrClient( + properties.getEndpoint(), + properties.authorizationHeader(), + properties.getOverallTimeout(), + properties.getMaxResponseBytes(), + properties.getMaxConcurrentCalls(), + httpClient, + contractObjectMapper, + circuitBreaker + ); + return new ValidatingAiOcrClient(remote, validator); + } + + static ObjectMapper createContractObjectMapper(ObjectMapper applicationObjectMapper) { + return applicationObjectMapper.rebuild() + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .withCoercionConfig(LogicalType.Textual, config -> { + config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail); + config.setCoercion(CoercionInputShape.Float, CoercionAction.Fail); + config.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail); + }) + .withCoercionConfig(LogicalType.Float, config -> + config.setCoercion(CoercionInputShape.String, CoercionAction.Fail)) + .build(); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpResponse.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpResponse.java new file mode 100644 index 00000000..c18a24d9 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrHttpResponse.java @@ -0,0 +1,33 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentSide; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +record AiOcrHttpResponse( + UUID requestId, + UUID workerDocumentId, + AiOcrStatus ocrStatus, + Long matchedTemplateId, + AiOcrDocumentSide documentSide, + Map fields, + Map fieldConfidences, + List reviewReasons +) { + AiOcrResponse toDomain() { + return new AiOcrResponse( + requestId, + workerDocumentId, + ocrStatus, + matchedTemplateId, + documentSide, + fields, + fieldConfidences, + reviewReasons + ); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrProperties.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrProperties.java new file mode 100644 index 00000000..62ebc570 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiOcrProperties.java @@ -0,0 +1,89 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import java.net.URI; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.ai-ocr") +public final class AiOcrProperties { + + private boolean enabled; + private URI endpoint = URI.create("http://127.0.0.1:8000/internal/v1/ocr/worker-documents"); + private String serviceCredential; + private Duration connectTimeout = Duration.ofSeconds(2); + private Duration overallTimeout = Duration.ofSeconds(20); + private int maxResponseBytes = 1_048_576; + private int maxConcurrentCalls = 4; + private int circuitBreakerFailureThreshold = 5; + private Duration circuitBreakerOpenDuration = Duration.ofSeconds(30); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public URI getEndpoint() { return endpoint; } + public void setEndpoint(URI endpoint) { this.endpoint = requireHttpEndpoint(endpoint); } + public void setServiceCredential(String serviceCredential) { this.serviceCredential = serviceCredential; } + public Duration getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Duration value) { this.connectTimeout = requirePositive(value, "connectTimeout"); } + public Duration getOverallTimeout() { return overallTimeout; } + public void setOverallTimeout(Duration value) { this.overallTimeout = requirePositive(value, "overallTimeout"); } + public int getMaxResponseBytes() { return maxResponseBytes; } + public void setMaxResponseBytes(int value) { + if (value < 1_024 || value > 10 * 1_024 * 1_024) { + throw new IllegalArgumentException("maxResponseBytes must be between 1 KiB and 10 MiB"); + } + this.maxResponseBytes = value; + } + public int getMaxConcurrentCalls() { return maxConcurrentCalls; } + public void setMaxConcurrentCalls(int value) { + if (value < 1 || value > 100) { + throw new IllegalArgumentException("maxConcurrentCalls must be between 1 and 100"); + } + this.maxConcurrentCalls = value; + } + public int getCircuitBreakerFailureThreshold() { return circuitBreakerFailureThreshold; } + public void setCircuitBreakerFailureThreshold(int value) { + if (value < 1 || value > 100) { + throw new IllegalArgumentException("circuitBreakerFailureThreshold must be between 1 and 100"); + } + this.circuitBreakerFailureThreshold = value; + } + public Duration getCircuitBreakerOpenDuration() { return circuitBreakerOpenDuration; } + public void setCircuitBreakerOpenDuration(Duration value) { + this.circuitBreakerOpenDuration = requirePositive(value, "circuitBreakerOpenDuration"); + } + + String authorizationHeader() { + if (serviceCredential == null || serviceCredential.isBlank()) { + throw new IllegalStateException("AI_OCR_SERVICE_CREDENTIAL must be configured when AI OCR is enabled"); + } + if (serviceCredential.indexOf('\r') >= 0 || serviceCredential.indexOf('\n') >= 0) { + throw new IllegalStateException("AI OCR service credential contains an invalid character"); + } + return "Bearer " + serviceCredential.strip(); + } + + void validateEnabledConfiguration() { + requireHttpEndpoint(endpoint); + authorizationHeader(); + requirePositive(connectTimeout, "connectTimeout"); + requirePositive(overallTimeout, "overallTimeout"); + } + + private static URI requireHttpEndpoint(URI value) { + if (value == null || !value.isAbsolute() + || (!"http".equalsIgnoreCase(value.getScheme()) && !"https".equalsIgnoreCase(value.getScheme())) + || value.getHost() == null || value.getUserInfo() != null + || value.getQuery() != null || value.getFragment() != null) { + throw new IllegalArgumentException("endpoint must be an absolute HTTP(S) URI without credentials or query"); + } + return value; + } + + private static Duration requirePositive(Duration value, String field) { + if (value == null || value.isZero() || value.isNegative() + || value.compareTo(Duration.ofMillis(100)) < 0) { + throw new IllegalArgumentException(field + " must be at least 100ms"); + } + return value; + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiOcrClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiOcrClient.java new file mode 100644 index 00000000..9d52baad --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiOcrClient.java @@ -0,0 +1,19 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.fowoco.server.aiintegration.application.port.AiOcrClient; + +final class DisabledAiOcrClient implements AiOcrClient { + + @Override + public AiOcrResponse recognize(AiOcrRequest request, AiRuntimeCallContext context) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RUNTIME_DISABLED, + "AI OCR integration is disabled." + ); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClient.java new file mode 100644 index 00000000..fad99c7b --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClient.java @@ -0,0 +1,247 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.fowoco.server.aiintegration.application.port.AiOcrClient; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +final class RemoteAiOcrClient implements AiOcrClient { + + private static final Set CIRCUIT_FAILURES = EnumSet.of( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + AiRuntimeFailureCode.RATE_LIMITED, + AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, + AiRuntimeFailureCode.RESPONSE_TOO_LARGE, + AiRuntimeFailureCode.RESPONSE_PARSING_FAILED, + AiRuntimeFailureCode.TRANSPORT_FAILURE + ); + + private final URI endpoint; + private final String authorizationHeader; + private final Duration overallTimeout; + private final int maxResponseBytes; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Semaphore bulkhead; + private final AiRuntimeCircuitBreaker circuitBreaker; + + RemoteAiOcrClient( + URI endpoint, + String authorizationHeader, + Duration overallTimeout, + int maxResponseBytes, + int maxConcurrentCalls, + HttpClient httpClient, + ObjectMapper objectMapper, + AiRuntimeCircuitBreaker circuitBreaker + ) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint must not be null"); + this.authorizationHeader = Objects.requireNonNull(authorizationHeader, "authorizationHeader must not be null"); + this.overallTimeout = Objects.requireNonNull(overallTimeout, "overallTimeout must not be null"); + this.maxResponseBytes = maxResponseBytes; + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + this.bulkhead = new Semaphore(maxConcurrentCalls); + this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuitBreaker must not be null"); + } + + @Override + public AiOcrResponse recognize(AiOcrRequest request, AiRuntimeCallContext context) { + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(context, "context must not be null"); + if (!bulkhead.tryAcquire()) { + throw failure(AiRuntimeFailureCode.BULKHEAD_FULL, "AI OCR concurrency limit is full."); + } + boolean permit = false; + try { + circuitBreaker.beforeCall(); + permit = true; + String boundary = "fowoco-" + UUID.randomUUID(); + URI requestUri = appendPath(endpoint, request.workerDocumentId().toString()); + HttpRequest.Builder builder = HttpRequest.newBuilder(requestUri) + .timeout(overallTimeout) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .header("Accept", "application/json") + .header("Authorization", authorizationHeader) + .header("X-Request-Id", request.requestId().toString()) + .POST(HttpRequest.BodyPublishers.ofByteArray(multipart(request, boundary))); + if (context.traceParent() != null) { + builder.header("traceparent", context.traceParent()); + } + HttpResponse response = execute(builder.build()); + AiOcrResponse decoded = decode(response); + circuitBreaker.recordSuccess(); + return decoded; + } catch (AiRuntimeCallException exception) { + if (permit) { + if (CIRCUIT_FAILURES.contains(exception.failureCode())) { + circuitBreaker.recordFailure(); + } else { + circuitBreaker.recordSuccess(); + } + } + throw exception; + } catch (RuntimeException exception) { + if (permit) { + circuitBreaker.recordFailure(); + } + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI OCR transport failed.", + exception + ); + } finally { + bulkhead.release(); + } + } + + private HttpResponse execute(HttpRequest request) { + CompletableFuture> future = httpClient.sendAsync( + request, + new LimitedByteArrayBodyHandler(maxResponseBytes) + ); + try { + return future.get(overallTimeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException exception) { + future.cancel(true); + throw new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI OCR deadline was exceeded.", + exception + ); + } catch (InterruptedException exception) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI OCR call was interrupted.", + exception + ); + } catch (ExecutionException exception) { + Throwable cause = unwrap(exception.getCause()); + if (cause instanceof HttpTimeoutException) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI OCR deadline was exceeded.", + cause + ); + } + if (cause instanceof LimitedByteArrayBodyHandler.ResponseTooLargeException) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RESPONSE_TOO_LARGE, + "AI OCR response exceeded the configured size limit.", + cause + ); + } + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI OCR transport failed.", + cause + ); + } + } + + private AiOcrResponse decode(HttpResponse response) { + int status = response.statusCode(); + if (status < 200 || status >= 300) { + if (status == 401 || status == 403) { + throw failure(AiRuntimeFailureCode.AUTHENTICATION_FAILED, "AI OCR service authentication failed."); + } + if (status == 408) { + throw failure(AiRuntimeFailureCode.DEADLINE_EXCEEDED, "AI OCR deadline was exceeded."); + } + if (status == 429) { + throw failure(AiRuntimeFailureCode.RATE_LIMITED, "AI OCR rate limit was reached."); + } + if (status >= 500) { + throw failure(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, "AI OCR is unavailable."); + } + throw failure(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI OCR rejected the request contract."); + } + try { + return objectMapper.readValue(response.body(), AiOcrHttpResponse.class).toDomain(); + } catch (JacksonException exception) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RESPONSE_PARSING_FAILED, + "AI OCR response JSON is invalid.", + exception + ); + } + } + + private byte[] multipart(AiOcrRequest request, String boundary) { + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + textPart(output, boundary, "request_id", request.requestId().toString()); + textPart(output, boundary, "document_type", request.documentType().name()); + if (request.countryCode() != null) { + textPart(output, boundary, "country_code", request.countryCode()); + } + output.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + + safeFileName(request.file().fileName()) + "\"\r\n").getBytes(StandardCharsets.UTF_8)); + output.write(("Content-Type: " + request.file().contentType() + "\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + output.write(request.file().content()); + output.write("\r\n".getBytes(StandardCharsets.UTF_8)); + output.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + return output.toByteArray(); + } catch (IOException exception) { + throw new IllegalStateException("AI OCR multipart body creation failed", exception); + } + } + + private void textPart(ByteArrayOutputStream output, String boundary, String name, String value) + throws IOException { + output.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + output.write(value.getBytes(StandardCharsets.UTF_8)); + output.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private String safeFileName(String value) { + return value.replace("\\", "_").replace("\"", "_").replace("\r", "_").replace("\n", "_"); + } + + private URI appendPath(URI base, String segment) { + String value = base.toString(); + return URI.create((value.endsWith("/") ? value : value + "/") + segment); + } + + private Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof ExecutionException + || current instanceof java.util.concurrent.CompletionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private AiRuntimeCallException failure(AiRuntimeFailureCode code, String message) { + return new AiRuntimeCallException(code, message); + } +} diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index df171520..daebd142 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -32,5 +32,11 @@ public enum AuditAction { WORKER_IMPORT_VALIDATED, WORKER_IMPORT_ROWS_UPDATED, WORKER_IMPORT_COMMITTED, - WORKER_IMPORT_RETRIED + WORKER_IMPORT_RETRIED, + DOCUMENT_OCR_REQUESTED, + DOCUMENT_OCR_COMPLETED, + DOCUMENT_OCR_FAILED, + DOCUMENT_OCR_RESULT_VIEWED, + DOCUMENT_OCR_APPROVED, + DOCUMENT_OCR_REJECTED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index a3074fbc..90b2f379 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -12,5 +12,6 @@ public enum AuditTargetType { OUTBOX_EVENT, WORKER_LINK, USER_ACCOUNT, - WORKER_IMPORT + WORKER_IMPORT, + DOCUMENT_OCR_RUN } diff --git a/src/main/java/com/fowoco/server/document/api/DocumentOcrController.java b/src/main/java/com/fowoco/server/document/api/DocumentOcrController.java new file mode 100644 index 00000000..d25a71ff --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentOcrController.java @@ -0,0 +1,147 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.document.application.DocumentOcrReviewCommand; +import com.fowoco.server.document.application.DocumentOcrRunResult; +import com.fowoco.server.document.application.DocumentOcrService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; +import java.net.URI; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document OCR", description = "HR 전용 문서 OCR 실행·조회·검토") +@SecurityRequirement(name = "bearerAuth") +@RestController +@Validated +@RequestMapping("/api/v1/documents/{documentId}/ocr-runs") +public class DocumentOcrController { + + private final DocumentOcrService documentOcrService; + private final ActorContextProvider actorContextProvider; + + public DocumentOcrController( + DocumentOcrService documentOcrService, + ActorContextProvider actorContextProvider + ) { + this.documentOcrService = documentOcrService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "createDocumentOcrRun", + summary = "연결된 파일의 OCR 실행 요청", + description = "요청을 QUEUED 상태로 저장한 뒤 AI Runtime을 비동기로 호출합니다. " + + "추출값은 암호화해 저장하며 근로자 정보에는 자동 반영하지 않습니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "OCR 실행 접수"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", description = "OCR 불가 서류·파일·국가"), + @ApiResponse(responseCode = "503", description = "OCR 비활성화") + }) + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public ResponseEntity create( + @Parameter(description = "근로자 서류 ID") @PathVariable UUID documentId, + @Parameter(description = "동일 요청의 중복 실행 방지 키", required = true) + @RequestHeader("Idempotency-Key") + @Size(min = 8, max = 100, message = "Idempotency-Key는 8자 이상 100자 이하여야 합니다.") + String idempotencyKey, + HttpServletRequest servletRequest + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + DocumentOcrRunResult result = documentOcrService.create( + documentId, + idempotencyKey, + actor, + RequestMetadata.from(servletRequest) + ); + URI location = URI.create("/api/v1/documents/" + documentId + "/ocr-runs/" + result.run().ocrRunId()); + return ResponseEntity.accepted().location(location).body(DocumentOcrRunResponse.from(result)); + } + + @Operation(operationId = "getDocumentOcrRun", summary = "OCR 실행 상태·결과 조회") + @GetMapping(path = "/{ocrRunId}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentOcrRunResponse findById( + @PathVariable UUID documentId, + @PathVariable UUID ocrRunId, + HttpServletRequest servletRequest + ) { + return DocumentOcrRunResponse.from(documentOcrService.findById( + documentId, + ocrRunId, + actorContextProvider.requireCurrentActor(), + RequestMetadata.from(servletRequest) + )); + } + + @Operation(operationId = "getLatestDocumentOcrRun", summary = "문서의 최신 OCR 실행 조회") + @GetMapping(path = "/latest", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentOcrRunResponse findLatest( + @PathVariable UUID documentId, + HttpServletRequest servletRequest + ) { + return DocumentOcrRunResponse.from(documentOcrService.findLatest( + documentId, + actorContextProvider.requireCurrentActor(), + RequestMetadata.from(servletRequest) + )); + } + + @Operation( + operationId = "reviewDocumentOcrRun", + summary = "OCR 결과 검토 완료·반려", + description = "OCR 원본과 HR 수정값을 분리해 검토 상태만 확정합니다. Worker·Document·Agent slot은 자동 수정하지 않습니다." + ) + @PostMapping( + path = "/{ocrRunId}/review", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentOcrRunResponse review( + @PathVariable UUID documentId, + @PathVariable UUID ocrRunId, + @Valid @RequestBody DocumentOcrReviewRequest request, + HttpServletRequest servletRequest + ) { + return DocumentOcrRunResponse.from(documentOcrService.review( + documentId, + ocrRunId, + new DocumentOcrReviewCommand( + request.expectedVersion(), + request.decision(), + request.reason(), + request.correctedFields() + ), + actorContextProvider.requireCurrentActor(), + RequestMetadata.from(servletRequest) + )); + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentOcrReviewRequest.java b/src/main/java/com/fowoco/server/document/api/DocumentOcrReviewRequest.java new file mode 100644 index 00000000..9fff9f80 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentOcrReviewRequest.java @@ -0,0 +1,34 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.document.domain.DocumentOcrReviewDecision; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Map; + +public record DocumentOcrReviewRequest( + @JsonProperty("expected_version") + @NotNull(message = "expected_version은 필수입니다.") + @Min(value = 0, message = "expected_version은 0 이상이어야 합니다.") + @Schema(description = "화면에서 확인한 OCR 실행 version", example = "2") + Long expectedVersion, + + @NotNull(message = "decision은 필수입니다.") + @Schema(description = "검토 결정. APPROVE는 업무 데이터 반영이 아닌 OCR 검토 완료를 뜻합니다.", example = "APPROVE") + DocumentOcrReviewDecision decision, + + @Size(max = 300, message = "검토 사유는 300자 이하여야 합니다.") + @Schema(description = "반려 시 필수인 검토 사유", example = "여권번호를 원본과 다시 확인해야 합니다.") + String reason, + + @JsonProperty("corrected_fields") + @Size(max = 20, message = "수정 필드는 20개 이하여야 합니다.") + @Schema( + description = "HR이 원문과 대조해 바로잡은 필드. OCR 원본과 분리해 암호화 저장하며 Worker에는 자동 반영하지 않습니다.", + example = "{\"passport_number\":\"M12345678\"}" + ) + Map correctedFields +) { +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentOcrRunResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentOcrRunResponse.java new file mode 100644 index 00000000..6337b68c --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentOcrRunResponse.java @@ -0,0 +1,56 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.document.application.DocumentOcrResultPayload; +import com.fowoco.server.document.application.DocumentOcrRunResult; +import com.fowoco.server.document.domain.DocumentOcrRun; +import com.fowoco.server.document.domain.DocumentOcrRunStatus; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Schema(description = "문서 OCR 실행 상태와 HR 검토용 추출 결과") +public record DocumentOcrRunResponse( + UUID ocrRunId, + UUID documentId, + UUID fileId, + DocumentType documentType, + DocumentOcrRunStatus status, + DocumentOcrResultPayload result, + @Schema(description = "HR 수정값. OCR 원본과 분리 저장되며 Worker·Document에는 자동 반영되지 않습니다.") + Map correctedFields, + String errorCode, + UUID reviewedBy, + String reviewReason, + Instant createdAt, + Instant startedAt, + Instant completedAt, + Instant reviewedAt, + Instant updatedAt, + long version, + boolean alreadyRequested +) { + public static DocumentOcrRunResponse from(DocumentOcrRunResult result) { + DocumentOcrRun run = result.run(); + return new DocumentOcrRunResponse( + run.ocrRunId(), + run.workerDocumentId(), + run.storedFileId(), + run.documentType(), + run.status(), + result.result(), + result.correctedFields(), + run.lastErrorCode(), + run.reviewedBy(), + run.reviewReason(), + run.createdAt(), + run.startedAt(), + run.completedAt(), + run.reviewedAt(), + run.updatedAt(), + run.version(), + result.alreadyRequested() + ); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrCorrectedFieldsPayload.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrCorrectedFieldsPayload.java new file mode 100644 index 00000000..5381fb6b --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrCorrectedFieldsPayload.java @@ -0,0 +1,10 @@ +package com.fowoco.server.document.application; + +import java.util.Map; + +record DocumentOcrCorrectedFieldsPayload(Map fields) { + + DocumentOcrCorrectedFieldsPayload { + fields = Map.copyOf(fields); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrDomainEvents.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrDomainEvents.java new file mode 100644 index 00000000..aa48c9fa --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrDomainEvents.java @@ -0,0 +1,43 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.document.domain.DocumentOcrRun; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import com.fowoco.server.reliability.domain.EventActorType; +import com.fowoco.server.reliability.domain.SafeEventPayload; +import java.time.Instant; +import java.util.UUID; + +final class DocumentOcrDomainEvents { + + static final String EXECUTION_REQUESTED = "DocumentOcrExecutionRequested"; + private static final String PAYLOAD_VERSION = "1"; + private static final String AGGREGATE_TYPE = "DocumentOcrRun"; + + private DocumentOcrDomainEvents() { + } + + static DomainEventEnvelope executionRequested( + UUID eventId, + DocumentOcrRun run, + ActorContext actor, + RequestMetadata metadata, + Instant occurredAt + ) { + return new DomainEventEnvelope( + eventId, + EXECUTION_REQUESTED, + PAYLOAD_VERSION, + AGGREGATE_TYPE, + run.ocrRunId(), + run.companyId(), + EventActorType.HR_USER, + actor.actorId(), + metadata.requestId(), + metadata.traceId(), + occurredAt, + SafeEventPayload.empty() + ); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrExecutionRequestedHandler.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrExecutionRequestedHandler.java new file mode 100644 index 00000000..5cd53b36 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrExecutionRequestedHandler.java @@ -0,0 +1,32 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.reliability.application.port.DomainEventHandler; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; +import org.springframework.stereotype.Component; + +@Component +public final class DocumentOcrExecutionRequestedHandler implements DomainEventHandler { + + private static final String HANDLER_NAME = "documentOcrExecution"; + + private final DocumentOcrService documentOcrService; + + public DocumentOcrExecutionRequestedHandler(DocumentOcrService documentOcrService) { + this.documentOcrService = documentOcrService; + } + + @Override + public String handlerName() { + return HANDLER_NAME; + } + + @Override + public boolean supports(String eventType) { + return DocumentOcrDomainEvents.EXECUTION_REQUESTED.equals(eventType); + } + + @Override + public void handle(DomainEventEnvelope event) { + documentOcrService.executeFromOutbox(event.aggregateId(), event.companyId()); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrResultPayload.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrResultPayload.java new file mode 100644 index 00000000..e0e2ef19 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrResultPayload.java @@ -0,0 +1,20 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentSide; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +public record DocumentOcrResultPayload( + Long matchedTemplateId, + AiOcrDocumentSide documentSide, + Map fields, + Map fieldConfidences, + List reviewReasons +) { + public DocumentOcrResultPayload { + fields = Map.copyOf(fields); + fieldConfidences = Map.copyOf(fieldConfidences); + reviewReasons = List.copyOf(reviewReasons); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrReviewCommand.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrReviewCommand.java new file mode 100644 index 00000000..e2246363 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrReviewCommand.java @@ -0,0 +1,24 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.document.domain.DocumentOcrReviewDecision; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record DocumentOcrReviewCommand( + long expectedVersion, + DocumentOcrReviewDecision decision, + String reason, + Map correctedFields +) { + public DocumentOcrReviewCommand { + if (expectedVersion < 0) { + throw new IllegalArgumentException("expectedVersion must not be negative"); + } + Objects.requireNonNull(decision, "decision must not be null"); + correctedFields = correctedFields == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(correctedFields)); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrRunResult.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrRunResult.java new file mode 100644 index 00000000..2265eab4 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrRunResult.java @@ -0,0 +1,16 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.document.domain.DocumentOcrRun; +import java.util.Map; + +public record DocumentOcrRunResult( + DocumentOcrRun run, + DocumentOcrResultPayload result, + Map correctedFields, + boolean alreadyRequested +) { + + public DocumentOcrRunResult { + correctedFields = Map.copyOf(correctedFields); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java new file mode 100644 index 00000000..2539f811 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java @@ -0,0 +1,640 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentType; +import com.fowoco.server.aiintegration.application.ocr.AiOcrFile; +import com.fowoco.server.aiintegration.application.ocr.AiOcrPassportCountryCodeResolver; +import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.fowoco.server.aiintegration.application.port.AiOcrClient; +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.document.application.error.DocumentErrorCode; +import com.fowoco.server.document.application.port.DocumentOcrRunRepository; +import com.fowoco.server.document.application.port.OcrResultCipher; +import com.fowoco.server.document.domain.DocumentOcrReviewDecision; +import com.fowoco.server.document.domain.DocumentOcrRun; +import com.fowoco.server.document.domain.DocumentOcrRunStatus; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.reliability.application.port.DomainEventPublisher; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Service +public class DocumentOcrService { + + private static final Logger log = LoggerFactory.getLogger(DocumentOcrService.class); + private static final int MAX_FILE_BYTES = 20 * 1024 * 1024; + private static final String AUDIT_EVENT_VERSION = "1.0"; + private static final Set PASSPORT_CORRECTABLE_FIELDS = Set.of( + "passport_number", "surname", "given_names", "date_of_birth", "sex", + "passport_issue_date", "passport_expiry_date" + ); + private static final Set ARC_CORRECTABLE_FIELDS = Set.of( + "alien_registration_number", "visa_type", "stay_expiration_date", "residence_address_1" + ); + private static final Set DATE_FIELDS = Set.of( + "date_of_birth", "passport_issue_date", "passport_expiry_date", "stay_expiration_date" + ); + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; + private final StoredFileRepository storedFileRepository; + private final FileStorage fileStorage; + private final DocumentOcrRunRepository ocrRunRepository; + private final OcrResultCipher resultCipher; + private final AiOcrClient aiOcrClient; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + private final DomainEventPublisher eventPublisher; + private final AiOcrPassportCountryCodeResolver countryCodeResolver = + new AiOcrPassportCountryCodeResolver(); + + public DocumentOcrService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + DocumentOcrRunRepository ocrRunRepository, + OcrResultCipher resultCipher, + AiOcrClient aiOcrClient, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate, + DomainEventPublisher eventPublisher + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerDocumentRepository = workerDocumentRepository; + this.workerRepository = workerRepository; + this.storedFileRepository = storedFileRepository; + this.fileStorage = fileStorage; + this.ocrRunRepository = ocrRunRepository; + this.resultCipher = resultCipher; + this.aiOcrClient = aiOcrClient; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; + this.eventPublisher = eventPublisher; + } + + public DocumentOcrRunResult create( + UUID documentId, + String idempotencyKey, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + requireFeatureEnabled(); + String keyHash = sha256(normalizeIdempotencyKey(idempotencyKey)); + String requestHash = sha256(documentId.toString()); + Creation creation; + try { + creation = transactionTemplate.execute(status -> createInTransaction( + documentId, keyHash, requestHash, actor, metadata + )); + } catch (DataIntegrityViolationException conflict) { + creation = transactionTemplate.execute(status -> replayAfterConflict( + keyHash, requestHash, actor.companyId() + )); + } + if (creation == null) { + throw new IllegalStateException("OCR creation transaction returned no result"); + } + return result(creation.run(), !creation.newlyCreated()); + } + + public DocumentOcrRunResult findById( + UUID documentId, + UUID ocrRunId, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + requireFeatureEnabled(); + return requiredTransaction(() -> { + bindTenant(actor.companyId()); + requireDocument(documentId, actor.companyId()); + DocumentOcrRun run = ocrRunRepository.findByIdAndCompanyId(ocrRunId, actor.companyId()) + .filter(found -> found.workerDocumentId().equals(documentId)) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_RUN_NOT_FOUND)); + DocumentOcrRunResult result = result(run, false); + auditResultViewIfSensitive(run, actor, metadata); + return result; + }); + } + + public DocumentOcrRunResult findLatest( + UUID documentId, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + requireFeatureEnabled(); + return requiredTransaction(() -> { + bindTenant(actor.companyId()); + requireDocument(documentId, actor.companyId()); + DocumentOcrRun run = ocrRunRepository + .findLatestByDocumentIdAndCompanyId(documentId, actor.companyId()) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_RUN_NOT_FOUND)); + DocumentOcrRunResult result = result(run, false); + auditResultViewIfSensitive(run, actor, metadata); + return result; + }); + } + + public DocumentOcrRunResult review( + UUID documentId, + UUID ocrRunId, + DocumentOcrReviewCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + requireFeatureEnabled(); + return requiredTransaction(() -> { + bindTenant(actor.companyId()); + requireDocument(documentId, actor.companyId()); + DocumentOcrRun current = ocrRunRepository.findByIdAndCompanyId(ocrRunId, actor.companyId()) + .filter(found -> found.workerDocumentId().equals(documentId)) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_RUN_NOT_FOUND)); + if (current.version() != command.expectedVersion()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_VERSION_CONFLICT); + } + if (!current.status().isReviewable()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_NOT_REVIEWABLE); + } + String reason = normalizeReviewReason(command.decision(), command.reason()); + Map correctedFields = normalizeCorrectedFields( + current.documentType(), command.decision(), command.correctedFields() + ); + String correctedCiphertext = correctedFields.isEmpty() + ? null + : resultCipher.encrypt(serializeCorrections(correctedFields), actor.companyId(), ocrRunId); + String correctedKeyVersion = correctedFields.isEmpty() ? null : resultCipher.keyVersion(); + DocumentOcrRun saved = ocrRunRepository.update( + current.review( + command.decision(), actor.actorId(), reason, + correctedCiphertext, correctedKeyVersion, clock.instant() + ) + ); + appendHumanAudit( + saved, + actor, + command.decision() == DocumentOcrReviewDecision.APPROVE + ? AuditAction.DOCUMENT_OCR_APPROVED + : AuditAction.DOCUMENT_OCR_REJECTED, + metadata, + reviewAuditSummary(command.decision(), correctedFields) + ); + return result(saved, false); + }); + } + + private Creation createInTransaction( + UUID documentId, + String keyHash, + String requestHash, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor.companyId()); + DocumentOcrRun existing = ocrRunRepository + .findByIdempotencyKeyHashAndCompanyId(keyHash, actor.companyId()) + .orElse(null); + if (existing != null) { + return replay(existing, requestHash); + } + WorkerDocument document = requireDocument(documentId, actor.companyId()); + AiOcrDocumentType aiDocumentType = toAiDocumentType(document.documentType()); + UUID fileId = document.fileId(); + if (fileId == null) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_FILE_REQUIRED); + } + StoredFile file = storedFileRepository.findByIdAndCompanyId(fileId, actor.companyId()) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_FILE_REQUIRED)); + if (file.workerId() != null && !document.workerId().equals(file.workerId())) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_FILE_MISMATCH); + } + String countryCode = null; + if (aiDocumentType == AiOcrDocumentType.PASSPORT_COPY) { + Worker worker = workerRepository + .findByWorkerIdAndCompanyId(document.workerId(), actor.companyId()) + .orElseThrow(() -> new ApiException(ErrorCode.RESOURCE_NOT_FOUND)); + countryCode = resolveCountryCode(worker.nationalityCode()); + } + Instant now = clock.instant(); + DocumentOcrRun run = DocumentOcrRun.create( + uuidGenerator.generate(), + actor.companyId(), + documentId, + fileId, + actor.actorId(), + uuidGenerator.generate(), + keyHash, + requestHash, + document.documentType(), + countryCode, + now + ); + ocrRunRepository.insert(run); + appendHumanAudit(run, actor, AuditAction.DOCUMENT_OCR_REQUESTED, metadata, "문서 OCR 실행 요청"); + eventPublisher.publish(DocumentOcrDomainEvents.executionRequested( + uuidGenerator.generate(), run, actor, metadata, now + )); + return new Creation(run, true); + } + + private Creation replayAfterConflict(String keyHash, String requestHash, UUID companyId) { + bindTenant(companyId); + DocumentOcrRun existing = ocrRunRepository + .findByIdempotencyKeyHashAndCompanyId(keyHash, companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_IDEMPOTENCY_CONFLICT)); + return replay(existing, requestHash); + } + + private Creation replay(DocumentOcrRun existing, String requestHash) { + if (!existing.requestHash().equals(requestHash)) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_IDEMPOTENCY_CONFLICT); + } + return new Creation(existing, false); + } + + void executeFromOutbox(UUID ocrRunId, UUID companyId) { + ExecutionInput input; + try { + input = requiredTransaction(() -> prepareExecution(ocrRunId, companyId)); + if (input == null) { + return; + } + byte[] content = readFile(input.file()); + AiOcrRequest request = new AiOcrRequest( + input.run().runtimeRequestId(), + input.run().workerDocumentId(), + toAiDocumentType(input.run().documentType()), + input.run().countryCode(), + new AiOcrFile(input.file().name(), input.file().mimeType(), content) + ); + AiOcrResponse response = aiOcrClient.recognize(request, AiRuntimeCallContext.withoutTrace()); + byte[] plaintext = serializePayload(response); + String ciphertext = resultCipher.encrypt(plaintext, companyId, ocrRunId); + requiredTransaction(() -> { + bindTenant(companyId); + DocumentOcrRun current = requireRun(ocrRunId, companyId); + DocumentOcrRun saved = ocrRunRepository.update(current.complete( + response.status(), ciphertext, resultCipher.keyVersion(), clock.instant() + )); + appendSystemAudit(saved, AuditAction.DOCUMENT_OCR_COMPLETED, "문서 OCR 실행 완료"); + return null; + }); + } catch (RuntimeException exception) { + AiRuntimeFailureCode failureCode = failureCode(exception); + log.warn("Document OCR failed. ocrRunId={}, failureCode={}", ocrRunId, failureCode); + markFailed(ocrRunId, companyId, failureCode); + } + } + + private ExecutionInput prepareExecution(UUID ocrRunId, UUID companyId) { + bindTenant(companyId); + DocumentOcrRun current = requireRun(ocrRunId, companyId); + if (current.status().hasResult() || current.status() == DocumentOcrRunStatus.FAILED) { + return null; + } + DocumentOcrRun running = current.status() == DocumentOcrRunStatus.QUEUED + ? ocrRunRepository.update(current.start(clock.instant())) + : current; + StoredFile file = storedFileRepository.findByIdAndCompanyId(running.storedFileId(), companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_FILE_REQUIRED)); + return new ExecutionInput(running, file); + } + + private void markFailed(UUID ocrRunId, UUID companyId, AiRuntimeFailureCode failureCode) { + requiredTransaction(() -> { + bindTenant(companyId); + DocumentOcrRun current = requireRun(ocrRunId, companyId); + if (current.status().hasResult() + || current.status() == DocumentOcrRunStatus.FAILED) { + return null; + } + DocumentOcrRun saved = ocrRunRepository.update(current.fail(failureCode.name(), clock.instant())); + appendSystemAudit(saved, AuditAction.DOCUMENT_OCR_FAILED, "문서 OCR 실행 실패: " + failureCode.name()); + return null; + }); + } + + private DocumentOcrRunResult result(DocumentOcrRun run, boolean alreadyRequested) { + DocumentOcrResultPayload payload = null; + if (run.status().hasResult()) { + byte[] plaintext = resultCipher.decrypt(run.resultCiphertext(), run.companyId(), run.ocrRunId()); + try { + payload = objectMapper.readValue(plaintext, DocumentOcrResultPayload.class); + } catch (JacksonException exception) { + throw new IllegalStateException("stored OCR result is invalid", exception); + } + } + return new DocumentOcrRunResult(run, payload, decryptCorrections(run), alreadyRequested); + } + + private Map decryptCorrections(DocumentOcrRun run) { + if (run.correctedFieldsCiphertext() == null) { + return Map.of(); + } + byte[] plaintext = resultCipher.decrypt( + run.correctedFieldsCiphertext(), run.companyId(), run.ocrRunId() + ); + try { + return objectMapper.readValue(plaintext, DocumentOcrCorrectedFieldsPayload.class).fields(); + } catch (JacksonException exception) { + throw new IllegalStateException("stored OCR corrections are invalid", exception); + } + } + + private byte[] serializePayload(AiOcrResponse response) { + DocumentOcrResultPayload payload = new DocumentOcrResultPayload( + response.matchedTemplateId(), + response.documentSide(), + response.fields(), + response.fieldConfidences(), + response.reviewReasons() + ); + try { + return objectMapper.writeValueAsBytes(payload); + } catch (JacksonException exception) { + throw new IllegalStateException("OCR result serialization failed", exception); + } + } + + private byte[] serializeCorrections(Map correctedFields) { + try { + return objectMapper.writeValueAsBytes(new DocumentOcrCorrectedFieldsPayload(correctedFields)); + } catch (JacksonException exception) { + throw new IllegalStateException("OCR corrections serialization failed", exception); + } + } + + private byte[] readFile(StoredFile file) { + try (InputStream input = fileStorage.open(file.storageKey()) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_FILE_REQUIRED))) { + byte[] bytes = input.readNBytes(MAX_FILE_BYTES + 1); + if (bytes.length > MAX_FILE_BYTES || bytes.length != file.size()) { + throw new AiRuntimeContractException( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "OCR file size does not match stored metadata." + ); + } + return bytes; + } catch (IOException exception) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "OCR source file could not be read.", + exception + ); + } + } + + private WorkerDocument requireDocument(UUID documentId, UUID companyId) { + return workerDocumentRepository.findByIdAndCompanyId(documentId, companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND)); + } + + private DocumentOcrRun requireRun(UUID ocrRunId, UUID companyId) { + return ocrRunRepository.findByIdAndCompanyId(ocrRunId, companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_OCR_RUN_NOT_FOUND)); + } + + private AiOcrDocumentType toAiDocumentType(DocumentType documentType) { + return switch (documentType) { + case PASSPORT_COPY -> AiOcrDocumentType.PASSPORT_COPY; + case ARC -> AiOcrDocumentType.ARC; + case CONTRACT, PERMIT -> throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_UNSUPPORTED_TYPE); + }; + } + + private String resolveCountryCode(String nationalityCode) { + try { + return countryCodeResolver.fromWorkerNationalityCode(nationalityCode); + } catch (AiRuntimeContractException exception) { + if (exception.failureCode() == AiRuntimeFailureCode.UNSUPPORTED_OCR_COUNTRY) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_UNSUPPORTED_COUNTRY); + } + throw new ApiException(ErrorCode.VALIDATION_FAILED); + } + } + + private String normalizeIdempotencyKey(String value) { + if (value == null || value.isBlank()) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + String normalized = value.strip(); + if (normalized.length() < 8 || normalized.length() > 100 + || normalized.indexOf('\r') >= 0 || normalized.indexOf('\n') >= 0) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + return normalized; + } + + private String normalizeReviewReason(DocumentOcrReviewDecision decision, String value) { + String normalized = value == null || value.isBlank() ? null : value.strip(); + if (normalized != null && normalized.length() > 300) { + throw new ApiException(ErrorCode.VALIDATION_FAILED); + } + if (decision == DocumentOcrReviewDecision.REJECT && normalized == null) { + throw new ApiException(ErrorCode.VALIDATION_FAILED); + } + return normalized; + } + + private Map normalizeCorrectedFields( + DocumentType documentType, + DocumentOcrReviewDecision decision, + Map rawFields + ) { + Map fields = rawFields == null ? Map.of() : rawFields; + if (fields.size() > 20 || (decision == DocumentOcrReviewDecision.REJECT && !fields.isEmpty())) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_CORRECTION_INVALID); + } + Set allowedFields = switch (documentType) { + case PASSPORT_COPY -> PASSPORT_CORRECTABLE_FIELDS; + case ARC -> ARC_CORRECTABLE_FIELDS; + case CONTRACT, PERMIT -> Set.of(); + }; + Map normalized = new LinkedHashMap<>(); + fields.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + String key = entry.getKey(); + String value = entry.getValue() == null ? null : entry.getValue().strip(); + if (!allowedFields.contains(key) + || value == null || value.isEmpty() || value.length() > 500 + || (DATE_FIELDS.contains(key) && !isIsoDate(value))) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_CORRECTION_INVALID); + } + normalized.put(key, value); + }); + return Map.copyOf(normalized); + } + + private boolean isIsoDate(String value) { + try { + LocalDate.parse(value); + return true; + } catch (java.time.format.DateTimeParseException exception) { + return false; + } + } + + private String reviewAuditSummary( + DocumentOcrReviewDecision decision, + Map correctedFields + ) { + String summary = "OCR 결과 검토 완료: " + decision.name(); + if (!correctedFields.isEmpty()) { + summary += ", 수정 필드=" + correctedFields.keySet().stream().sorted().toList(); + } + return summary; + } + + private void requireFeatureEnabled() { + if (!resultCipher.isAvailable()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_DISABLED); + } + } + + private void bindTenant(UUID companyId) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + } + + private T requiredTransaction(java.util.concurrent.Callable callback) { + T result = transactionTemplate.execute(status -> { + try { + return callback.call(); + } catch (RuntimeException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }); + return result; + } + + private AiRuntimeFailureCode failureCode(RuntimeException exception) { + if (exception instanceof AiRuntimeCallException callException) { + return callException.failureCode(); + } + if (exception instanceof AiRuntimeContractException contractException) { + return contractException.failureCode(); + } + return AiRuntimeFailureCode.TRANSPORT_FAILURE; + } + + private String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private void appendHumanAudit( + DocumentOcrRun run, + ActorContext actor, + AuditAction action, + RequestMetadata metadata, + String summary + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), run.companyId(), ActorType.HR_USER, actor.actorId(), + effectiveRole(actor), action, AuditTargetType.DOCUMENT_OCR_RUN, run.ocrRunId(), + metadata.requestId(), metadata.traceId(), AUDIT_EVENT_VERSION, summary, clock.instant() + )); + } + + private void appendSystemAudit(DocumentOcrRun run, AuditAction action, String summary) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), run.companyId(), ActorType.AI_AGENT, null, null, + action, AuditTargetType.DOCUMENT_OCR_RUN, run.ocrRunId(), + run.runtimeRequestId().toString(), null, AUDIT_EVENT_VERSION, summary, clock.instant() + )); + } + + private void auditResultViewIfSensitive( + DocumentOcrRun run, + ActorContext actor, + RequestMetadata metadata + ) { + if (run.status().hasResult()) { + appendHumanAudit( + run, + actor, + AuditAction.DOCUMENT_OCR_RESULT_VIEWED, + metadata, + "OCR 민감 결과 조회" + ); + } + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(role -> switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + })) + .orElseThrow(); + } + + private record Creation(DocumentOcrRun run, boolean newlyCreated) { } + private record ExecutionInput(DocumentOcrRun run, StoredFile file) { } +} diff --git a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java index a361e8fa..c738cf02 100644 --- a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -11,6 +11,46 @@ public enum DocumentErrorCode implements ApiErrorCode { DOCUMENT_NOT_FOUND( HttpStatus.NOT_FOUND, "문서를 찾을 수 없습니다." + ), + DOCUMENT_OCR_DISABLED( + HttpStatus.SERVICE_UNAVAILABLE, + "OCR 기능이 아직 활성화되지 않았습니다." + ), + DOCUMENT_OCR_UNSUPPORTED_TYPE( + HttpStatus.UNPROCESSABLE_CONTENT, + "OCR을 지원하지 않는 서류 유형입니다." + ), + DOCUMENT_OCR_UNSUPPORTED_COUNTRY( + HttpStatus.UNPROCESSABLE_CONTENT, + "현재 OCR을 지원하지 않는 여권 발급 국가입니다." + ), + DOCUMENT_OCR_FILE_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "OCR을 실행할 파일이 연결되어 있지 않습니다." + ), + DOCUMENT_OCR_FILE_MISMATCH( + HttpStatus.UNPROCESSABLE_CONTENT, + "서류와 파일의 근로자 정보가 일치하지 않습니다." + ), + DOCUMENT_OCR_RUN_NOT_FOUND( + HttpStatus.NOT_FOUND, + "OCR 실행 이력을 찾을 수 없습니다." + ), + DOCUMENT_OCR_IDEMPOTENCY_CONFLICT( + HttpStatus.CONFLICT, + "같은 Idempotency-Key가 다른 OCR 요청에 사용되었습니다." + ), + DOCUMENT_OCR_VERSION_CONFLICT( + HttpStatus.CONFLICT, + "다른 사용자가 OCR 결과를 먼저 검토했습니다. 새로고침 후 다시 시도해 주세요." + ), + DOCUMENT_OCR_NOT_REVIEWABLE( + HttpStatus.CONFLICT, + "현재 상태에서는 OCR 결과를 검토할 수 없습니다." + ), + DOCUMENT_OCR_CORRECTION_INVALID( + HttpStatus.UNPROCESSABLE_CONTENT, + "OCR 수정 필드가 문서 유형 또는 입력 규칙에 맞지 않습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/document/application/port/DocumentOcrRunRepository.java b/src/main/java/com/fowoco/server/document/application/port/DocumentOcrRunRepository.java new file mode 100644 index 00000000..8974bef3 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/port/DocumentOcrRunRepository.java @@ -0,0 +1,18 @@ +package com.fowoco.server.document.application.port; + +import com.fowoco.server.document.domain.DocumentOcrRun; +import java.util.Optional; +import java.util.UUID; + +public interface DocumentOcrRunRepository { + + void insert(DocumentOcrRun run); + + Optional findByIdAndCompanyId(UUID ocrRunId, UUID companyId); + + Optional findByIdempotencyKeyHashAndCompanyId(String keyHash, UUID companyId); + + Optional findLatestByDocumentIdAndCompanyId(UUID documentId, UUID companyId); + + DocumentOcrRun update(DocumentOcrRun run); +} diff --git a/src/main/java/com/fowoco/server/document/application/port/OcrResultCipher.java b/src/main/java/com/fowoco/server/document/application/port/OcrResultCipher.java new file mode 100644 index 00000000..6ebc7cab --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/port/OcrResultCipher.java @@ -0,0 +1,14 @@ +package com.fowoco.server.document.application.port; + +import java.util.UUID; + +public interface OcrResultCipher { + + boolean isAvailable(); + + String keyVersion(); + + String encrypt(byte[] plaintext, UUID companyId, UUID ocrRunId); + + byte[] decrypt(String ciphertext, UUID companyId, UUID ocrRunId); +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentOcrReviewDecision.java b/src/main/java/com/fowoco/server/document/domain/DocumentOcrReviewDecision.java new file mode 100644 index 00000000..512f28f2 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentOcrReviewDecision.java @@ -0,0 +1,6 @@ +package com.fowoco.server.document.domain; + +public enum DocumentOcrReviewDecision { + APPROVE, + REJECT +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentOcrRun.java b/src/main/java/com/fowoco/server/document/domain/DocumentOcrRun.java new file mode 100644 index 00000000..f680787a --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentOcrRun.java @@ -0,0 +1,232 @@ +package com.fowoco.server.document.domain; + +import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus; +import com.fowoco.server.worker.domain.DocumentType; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class DocumentOcrRun { + + private final UUID ocrRunId; + private final UUID companyId; + private final UUID workerDocumentId; + private final UUID storedFileId; + private final UUID requestedBy; + private final UUID runtimeRequestId; + private final String idempotencyKeyHash; + private final String requestHash; + private final DocumentType documentType; + private final String countryCode; + private final DocumentOcrRunStatus status; + private final String resultCiphertext; + private final String resultKeyVersion; + private final String correctedFieldsCiphertext; + private final String correctedFieldsKeyVersion; + private final String lastErrorCode; + private final UUID reviewedBy; + private final String reviewReason; + private final Instant createdAt; + private final Instant startedAt; + private final Instant completedAt; + private final Instant reviewedAt; + private final Instant updatedAt; + private final long version; + + public DocumentOcrRun( + UUID ocrRunId, + UUID companyId, + UUID workerDocumentId, + UUID storedFileId, + UUID requestedBy, + UUID runtimeRequestId, + String idempotencyKeyHash, + String requestHash, + DocumentType documentType, + String countryCode, + DocumentOcrRunStatus status, + String resultCiphertext, + String resultKeyVersion, + String correctedFieldsCiphertext, + String correctedFieldsKeyVersion, + String lastErrorCode, + UUID reviewedBy, + String reviewReason, + Instant createdAt, + Instant startedAt, + Instant completedAt, + Instant reviewedAt, + Instant updatedAt, + long version + ) { + this.ocrRunId = Objects.requireNonNull(ocrRunId, "ocrRunId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.workerDocumentId = Objects.requireNonNull(workerDocumentId, "workerDocumentId must not be null"); + this.storedFileId = Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + this.requestedBy = Objects.requireNonNull(requestedBy, "requestedBy must not be null"); + this.runtimeRequestId = Objects.requireNonNull(runtimeRequestId, "runtimeRequestId must not be null"); + this.idempotencyKeyHash = requireText(idempotencyKeyHash, "idempotencyKeyHash"); + this.requestHash = requireText(requestHash, "requestHash"); + this.documentType = Objects.requireNonNull(documentType, "documentType must not be null"); + this.countryCode = normalize(countryCode); + this.status = Objects.requireNonNull(status, "status must not be null"); + this.resultCiphertext = normalize(resultCiphertext); + this.resultKeyVersion = normalize(resultKeyVersion); + this.correctedFieldsCiphertext = normalize(correctedFieldsCiphertext); + this.correctedFieldsKeyVersion = normalize(correctedFieldsKeyVersion); + if ((this.resultCiphertext == null) != (this.resultKeyVersion == null)) { + throw new IllegalArgumentException("OCR result ciphertext and key version must be paired"); + } + if ((this.correctedFieldsCiphertext == null) != (this.correctedFieldsKeyVersion == null)) { + throw new IllegalArgumentException("OCR corrected fields and key version must be paired"); + } + this.lastErrorCode = normalize(lastErrorCode); + this.reviewedBy = reviewedBy; + this.reviewReason = normalize(reviewReason); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + this.startedAt = startedAt; + this.completedAt = completedAt; + this.reviewedAt = reviewedAt; + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null"); + if (version < 0) { + throw new IllegalArgumentException("version must not be negative"); + } + this.version = version; + } + + public static DocumentOcrRun create( + UUID ocrRunId, + UUID companyId, + UUID workerDocumentId, + UUID storedFileId, + UUID requestedBy, + UUID runtimeRequestId, + String idempotencyKeyHash, + String requestHash, + DocumentType documentType, + String countryCode, + Instant now + ) { + return new DocumentOcrRun( + ocrRunId, companyId, workerDocumentId, storedFileId, requestedBy, + runtimeRequestId, idempotencyKeyHash, requestHash, documentType, + countryCode, DocumentOcrRunStatus.QUEUED, null, null, null, null, + null, null, null, now, null, null, null, now, 0L + ); + } + + public DocumentOcrRun start(Instant now) { + requireStatus(DocumentOcrRunStatus.QUEUED); + return copy(DocumentOcrRunStatus.RUNNING, null, null, null, null, null, + null, null, now, null, null, now); + } + + public DocumentOcrRun complete( + AiOcrStatus outcome, + String ciphertext, + String keyVersion, + Instant now + ) { + requireStatus(DocumentOcrRunStatus.RUNNING); + DocumentOcrRunStatus next = outcome == AiOcrStatus.SUCCEEDED + ? DocumentOcrRunStatus.READY_FOR_REVIEW + : DocumentOcrRunStatus.REVIEW_REQUIRED; + return copy(next, requireText(ciphertext, "ciphertext"), requireText(keyVersion, "keyVersion"), + null, null, null, null, null, startedAt, now, null, now); + } + + public DocumentOcrRun fail(String errorCode, Instant now) { + if (status != DocumentOcrRunStatus.QUEUED && status != DocumentOcrRunStatus.RUNNING) { + throw new IllegalStateException("only queued or running OCR can fail"); + } + return copy(DocumentOcrRunStatus.FAILED, null, null, null, null, + requireText(errorCode, "errorCode"), null, null, startedAt, now, null, now); + } + + public DocumentOcrRun review( + DocumentOcrReviewDecision decision, + UUID reviewerId, + String reason, + String correctedCiphertext, + String correctedKeyVersion, + Instant now + ) { + if (!status.isReviewable()) { + throw new IllegalStateException("OCR result is not reviewable"); + } + DocumentOcrRunStatus next = decision == DocumentOcrReviewDecision.APPROVE + ? DocumentOcrRunStatus.APPROVED + : DocumentOcrRunStatus.REJECTED; + return copy(next, resultCiphertext, resultKeyVersion, + normalize(correctedCiphertext), normalize(correctedKeyVersion), null, + Objects.requireNonNull(reviewerId, "reviewerId must not be null"), normalize(reason), + startedAt, completedAt, now, now); + } + + private DocumentOcrRun copy( + DocumentOcrRunStatus nextStatus, + String nextCiphertext, + String nextKeyVersion, + String nextCorrectedCiphertext, + String nextCorrectedKeyVersion, + String nextErrorCode, + UUID nextReviewedBy, + String nextReviewReason, + Instant nextStartedAt, + Instant nextCompletedAt, + Instant nextReviewedAt, + Instant nextUpdatedAt + ) { + return new DocumentOcrRun( + ocrRunId, companyId, workerDocumentId, storedFileId, requestedBy, + runtimeRequestId, idempotencyKeyHash, requestHash, documentType, countryCode, + nextStatus, nextCiphertext, nextKeyVersion, + nextCorrectedCiphertext, nextCorrectedKeyVersion, nextErrorCode, nextReviewedBy, + nextReviewReason, createdAt, nextStartedAt, nextCompletedAt, + nextReviewedAt, nextUpdatedAt, version + ); + } + + private void requireStatus(DocumentOcrRunStatus expected) { + if (status != expected) { + throw new IllegalStateException("OCR status must be " + expected); + } + } + + private static String requireText(String value, String field) { + String normalized = normalize(value); + if (normalized == null) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } + + private static String normalize(String value) { + return value == null || value.isBlank() ? null : value.strip(); + } + + public UUID ocrRunId() { return ocrRunId; } + public UUID companyId() { return companyId; } + public UUID workerDocumentId() { return workerDocumentId; } + public UUID storedFileId() { return storedFileId; } + public UUID requestedBy() { return requestedBy; } + public UUID runtimeRequestId() { return runtimeRequestId; } + public String idempotencyKeyHash() { return idempotencyKeyHash; } + public String requestHash() { return requestHash; } + public DocumentType documentType() { return documentType; } + public String countryCode() { return countryCode; } + public DocumentOcrRunStatus status() { return status; } + public String resultCiphertext() { return resultCiphertext; } + public String resultKeyVersion() { return resultKeyVersion; } + public String correctedFieldsCiphertext() { return correctedFieldsCiphertext; } + public String correctedFieldsKeyVersion() { return correctedFieldsKeyVersion; } + public String lastErrorCode() { return lastErrorCode; } + public UUID reviewedBy() { return reviewedBy; } + public String reviewReason() { return reviewReason; } + public Instant createdAt() { return createdAt; } + public Instant startedAt() { return startedAt; } + public Instant completedAt() { return completedAt; } + public Instant reviewedAt() { return reviewedAt; } + public Instant updatedAt() { return updatedAt; } + public long version() { return version; } +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentOcrRunStatus.java b/src/main/java/com/fowoco/server/document/domain/DocumentOcrRunStatus.java new file mode 100644 index 00000000..074174a0 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentOcrRunStatus.java @@ -0,0 +1,22 @@ +package com.fowoco.server.document.domain; + +public enum DocumentOcrRunStatus { + QUEUED, + RUNNING, + READY_FOR_REVIEW, + REVIEW_REQUIRED, + APPROVED, + REJECTED, + FAILED; + + public boolean hasResult() { + return this == READY_FOR_REVIEW + || this == REVIEW_REQUIRED + || this == APPROVED + || this == REJECTED; + } + + public boolean isReviewable() { + return this == READY_FOR_REVIEW || this == REVIEW_REQUIRED; + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipher.java b/src/main/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipher.java new file mode 100644 index 00000000..36dc57f4 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipher.java @@ -0,0 +1,83 @@ +package com.fowoco.server.document.infrastructure.crypto; + +import com.fowoco.server.document.application.port.OcrResultCipher; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import java.util.UUID; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +final class AesGcmOcrResultCipher implements OcrResultCipher { + + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + + private final SecretKeySpec key; + private final String keyVersion; + private final SecureRandom secureRandom; + + AesGcmOcrResultCipher(byte[] keyBytes, String keyVersion, SecureRandom secureRandom) { + if (keyBytes.length != 32) { + throw new IllegalStateException("OCR result encryption key must decode to 32 bytes"); + } + this.key = new SecretKeySpec(keyBytes.clone(), "AES"); + this.keyVersion = Objects.requireNonNull(keyVersion, "keyVersion must not be null"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null"); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String keyVersion() { + return keyVersion; + } + + @Override + public String encrypt(byte[] plaintext, UUID companyId, UUID ocrRunId) { + Objects.requireNonNull(plaintext, "plaintext must not be null"); + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + cipher.updateAAD(aad(companyId, ocrRunId)); + byte[] encrypted = cipher.doFinal(plaintext); + return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(iv) + + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("OCR result encryption failed", exception); + } + } + + @Override + public byte[] decrypt(String ciphertext, UUID companyId, UUID ocrRunId) { + String[] parts = Objects.requireNonNull(ciphertext, "ciphertext must not be null").split("\\.", -1); + if (parts.length != 3 || !"v1".equals(parts[0])) { + throw new IllegalStateException("OCR result ciphertext format is invalid"); + } + try { + byte[] iv = Base64.getUrlDecoder().decode(parts[1]); + byte[] encrypted = Base64.getUrlDecoder().decode(parts[2]); + if (iv.length != IV_BYTES) { + throw new IllegalStateException("OCR result ciphertext IV is invalid"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + cipher.updateAAD(aad(companyId, ocrRunId)); + return cipher.doFinal(encrypted); + } catch (GeneralSecurityException | IllegalArgumentException exception) { + throw new IllegalStateException("OCR result decryption failed", exception); + } + } + + private byte[] aad(UUID companyId, UUID ocrRunId) { + return (companyId + ":" + ocrRunId).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/crypto/DisabledOcrResultCipher.java b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DisabledOcrResultCipher.java new file mode 100644 index 00000000..19a18491 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DisabledOcrResultCipher.java @@ -0,0 +1,27 @@ +package com.fowoco.server.document.infrastructure.crypto; + +import com.fowoco.server.document.application.port.OcrResultCipher; +import java.util.UUID; + +final class DisabledOcrResultCipher implements OcrResultCipher { + + @Override + public boolean isAvailable() { + return false; + } + + @Override + public String keyVersion() { + throw new IllegalStateException("OCR result encryption is disabled"); + } + + @Override + public String encrypt(byte[] plaintext, UUID companyId, UUID ocrRunId) { + throw new IllegalStateException("OCR result encryption is disabled"); + } + + @Override + public byte[] decrypt(String ciphertext, UUID companyId, UUID ocrRunId) { + throw new IllegalStateException("OCR result encryption is disabled"); + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrCryptoConfiguration.java b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrCryptoConfiguration.java new file mode 100644 index 00000000..d8106e1f --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrCryptoConfiguration.java @@ -0,0 +1,28 @@ +package com.fowoco.server.document.infrastructure.crypto; + +import com.fowoco.server.document.application.port.OcrResultCipher; +import java.security.SecureRandom; +import java.util.Base64; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(DocumentOcrProperties.class) +public class DocumentOcrCryptoConfiguration { + + @Bean + public OcrResultCipher ocrResultCipher(DocumentOcrProperties properties) { + if (!properties.isEnabled()) { + return new DisabledOcrResultCipher(); + } + properties.validateEnabledConfiguration(); + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(properties.getEncryptionKeyBase64().strip()); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("OCR result encryption key is not valid Base64", exception); + } + return new AesGcmOcrResultCipher(keyBytes, properties.getKeyVersion().strip(), new SecureRandom()); + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrProperties.java b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrProperties.java new file mode 100644 index 00000000..f62741a2 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/crypto/DocumentOcrProperties.java @@ -0,0 +1,49 @@ +package com.fowoco.server.document.infrastructure.crypto; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.document.ocr") +public final class DocumentOcrProperties { + + private boolean enabled; + private String encryptionKeyBase64; + private String keyVersion = "local-v1"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getEncryptionKeyBase64() { + return encryptionKeyBase64; + } + + public void setEncryptionKeyBase64(String encryptionKeyBase64) { + this.encryptionKeyBase64 = encryptionKeyBase64; + } + + public String getKeyVersion() { + return keyVersion; + } + + public void setKeyVersion(String keyVersion) { + this.keyVersion = keyVersion; + } + + public void validateEnabledConfiguration() { + if (!enabled) { + return; + } + if (encryptionKeyBase64 == null || encryptionKeyBase64.isBlank()) { + throw new IllegalStateException( + "OCR_RESULT_ENCRYPTION_KEY_BASE64 must be configured when document OCR is enabled" + ); + } + if (keyVersion == null || keyVersion.isBlank() || keyVersion.length() > 60) { + throw new IllegalStateException("OCR_RESULT_KEY_VERSION must be 1 to 60 characters"); + } + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentOcrRunJpaEntity.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentOcrRunJpaEntity.java new file mode 100644 index 00000000..8ca45768 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentOcrRunJpaEntity.java @@ -0,0 +1,158 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.domain.DocumentOcrRun; +import com.fowoco.server.document.domain.DocumentOcrRunStatus; +import com.fowoco.server.worker.domain.DocumentType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "document_ocr_run") +public class DocumentOcrRunJpaEntity { + + @Id + @Column(name = "ocr_run_id", nullable = false, updatable = false) + private UUID ocrRunId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "worker_document_id", nullable = false, updatable = false) + private UUID workerDocumentId; + + @Column(name = "stored_file_id", nullable = false, updatable = false) + private UUID storedFileId; + + @Column(name = "requested_by", nullable = false, updatable = false) + private UUID requestedBy; + + @Column(name = "runtime_request_id", nullable = false, updatable = false) + private UUID runtimeRequestId; + + @Column(name = "idempotency_key_hash", nullable = false, updatable = false, length = 64) + private String idempotencyKeyHash; + + @Column(name = "request_hash", nullable = false, updatable = false, length = 64) + private String requestHash; + + @Enumerated(EnumType.STRING) + @Column(name = "document_type", nullable = false, updatable = false, length = 40) + private DocumentType documentType; + + @Column(name = "country_code", updatable = false, length = 3) + private String countryCode; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private DocumentOcrRunStatus status; + + @Column(name = "result_ciphertext", columnDefinition = "TEXT") + private String resultCiphertext; + + @Column(name = "result_key_version", length = 60) + private String resultKeyVersion; + + @Column(name = "corrected_fields_ciphertext", columnDefinition = "TEXT") + private String correctedFieldsCiphertext; + + @Column(name = "corrected_fields_key_version", length = 60) + private String correctedFieldsKeyVersion; + + @Column(name = "last_error_code", length = 80) + private String lastErrorCode; + + @Column(name = "reviewed_by") + private UUID reviewedBy; + + @Column(name = "review_reason", length = 300) + private String reviewReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private long version; + + protected DocumentOcrRunJpaEntity() { + } + + private DocumentOcrRunJpaEntity(DocumentOcrRun run) { + this.ocrRunId = run.ocrRunId(); + this.companyId = run.companyId(); + this.workerDocumentId = run.workerDocumentId(); + this.storedFileId = run.storedFileId(); + this.requestedBy = run.requestedBy(); + this.runtimeRequestId = run.runtimeRequestId(); + this.idempotencyKeyHash = run.idempotencyKeyHash(); + this.requestHash = run.requestHash(); + this.documentType = run.documentType(); + this.countryCode = run.countryCode(); + applyMutableState(run); + this.createdAt = run.createdAt(); + this.version = run.version(); + } + + public static DocumentOcrRunJpaEntity fromDomain(DocumentOcrRun run) { + return new DocumentOcrRunJpaEntity(Objects.requireNonNull(run, "run must not be null")); + } + + public void applyState(DocumentOcrRun run) { + Objects.requireNonNull(run, "run must not be null"); + if (!ocrRunId.equals(run.ocrRunId()) + || !companyId.equals(run.companyId()) + || !workerDocumentId.equals(run.workerDocumentId()) + || !storedFileId.equals(run.storedFileId()) + || !runtimeRequestId.equals(run.runtimeRequestId()) + || version != run.version()) { + throw new IllegalArgumentException("immutable OCR run fields or version do not match"); + } + applyMutableState(run); + } + + private void applyMutableState(DocumentOcrRun run) { + this.status = run.status(); + this.resultCiphertext = run.resultCiphertext(); + this.resultKeyVersion = run.resultKeyVersion(); + this.correctedFieldsCiphertext = run.correctedFieldsCiphertext(); + this.correctedFieldsKeyVersion = run.correctedFieldsKeyVersion(); + this.lastErrorCode = run.lastErrorCode(); + this.reviewedBy = run.reviewedBy(); + this.reviewReason = run.reviewReason(); + this.startedAt = run.startedAt(); + this.completedAt = run.completedAt(); + this.reviewedAt = run.reviewedAt(); + this.updatedAt = run.updatedAt(); + } + + public DocumentOcrRun toDomain() { + return new DocumentOcrRun( + ocrRunId, companyId, workerDocumentId, storedFileId, requestedBy, + runtimeRequestId, idempotencyKeyHash, requestHash, documentType, + countryCode, status, resultCiphertext, resultKeyVersion, + correctedFieldsCiphertext, correctedFieldsKeyVersion, lastErrorCode, + reviewedBy, reviewReason, createdAt, startedAt, completedAt, reviewedAt, + updatedAt, version + ); + } +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentOcrRunRepository.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentOcrRunRepository.java new file mode 100644 index 00000000..6fc08344 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentOcrRunRepository.java @@ -0,0 +1,94 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.application.port.DocumentOcrRunRepository; +import com.fowoco.server.document.domain.DocumentOcrRun; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaDocumentOcrRunRepository implements DocumentOcrRunRepository { + + private final EntityManager entityManager; + + public JpaDocumentOcrRunRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(DocumentOcrRun run) { + entityManager.persist(DocumentOcrRunJpaEntity.fromDomain(run)); + entityManager.flush(); + } + + @Override + public Optional findByIdAndCompanyId(UUID ocrRunId, UUID companyId) { + return findOne( + "where run.ocrRunId = :value and run.companyId = :companyId", + "value", + ocrRunId, + companyId + ); + } + + @Override + public Optional findByIdempotencyKeyHashAndCompanyId(String keyHash, UUID companyId) { + return findOne( + "where run.idempotencyKeyHash = :value and run.companyId = :companyId", + "value", + keyHash, + companyId + ); + } + + @Override + public Optional findLatestByDocumentIdAndCompanyId(UUID documentId, UUID companyId) { + return entityManager.createQuery( + """ + select run from DocumentOcrRunJpaEntity run + where run.workerDocumentId = :documentId + and run.companyId = :companyId + order by run.createdAt desc, run.ocrRunId desc + """, + DocumentOcrRunJpaEntity.class + ) + .setParameter("documentId", documentId) + .setParameter("companyId", companyId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .map(DocumentOcrRunJpaEntity::toDomain); + } + + @Override + public DocumentOcrRun update(DocumentOcrRun run) { + DocumentOcrRunJpaEntity entity = entityManager.find(DocumentOcrRunJpaEntity.class, run.ocrRunId()); + if (entity == null) { + throw new IllegalStateException("OCR run to update was not found"); + } + entity.applyState(run); + entityManager.flush(); + return entity.toDomain(); + } + + private Optional findOne( + String where, + String parameterName, + Object value, + UUID companyId + ) { + Objects.requireNonNull(value, "query value must not be null"); + return entityManager.createQuery( + "select run from DocumentOcrRunJpaEntity run " + where, + DocumentOcrRunJpaEntity.class + ) + .setParameter(parameterName, value) + .setParameter("companyId", companyId) + .setMaxResults(1) + .getResultStream() + .findFirst() + .map(DocumentOcrRunJpaEntity::toDomain); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dd1a1298..966c0880 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -76,6 +76,21 @@ app: circuit-breaker-open-duration: ${AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION:30s} worker-import: source-retention: ${WORKER_IMPORT_SOURCE_RETENTION:7d} + ai-ocr: + enabled: ${AI_OCR_ENABLED:false} + endpoint: ${AI_OCR_ENDPOINT:http://127.0.0.1:8000/internal/v1/ocr/worker-documents} + service-credential: ${AI_OCR_SERVICE_CREDENTIAL:} + connect-timeout: ${AI_OCR_CONNECT_TIMEOUT:2s} + overall-timeout: ${AI_OCR_OVERALL_TIMEOUT:20s} + max-response-bytes: ${AI_OCR_MAX_RESPONSE_BYTES:1048576} + max-concurrent-calls: ${AI_OCR_MAX_CONCURRENT_CALLS:4} + circuit-breaker-failure-threshold: ${AI_OCR_CIRCUIT_BREAKER_FAILURE_THRESHOLD:5} + circuit-breaker-open-duration: ${AI_OCR_CIRCUIT_BREAKER_OPEN_DURATION:30s} + document: + ocr: + enabled: ${DOCUMENT_OCR_ENABLED:false} + encryption-key-base64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:} + key-version: ${OCR_RESULT_KEY_VERSION:local-v1} ai-run: sse: timeout: ${AI_RUN_SSE_TIMEOUT:5m} diff --git a/src/main/resources/db/migration-postgresql/V34__prepare_document_ocr_run_rls.sql b/src/main/resources/db/migration-postgresql/V34__prepare_document_ocr_run_rls.sql new file mode 100644 index 00000000..c0f5973f --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V34__prepare_document_ocr_run_rls.sql @@ -0,0 +1,12 @@ +CREATE POLICY pl_document_ocr_run_tenant_isolation + ON public.document_ocr_run + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V33__create_document_ocr_run.sql b/src/main/resources/db/migration/V33__create_document_ocr_run.sql new file mode 100644 index 00000000..aa52803f --- /dev/null +++ b/src/main/resources/db/migration/V33__create_document_ocr_run.sql @@ -0,0 +1,93 @@ +ALTER TABLE worker_document + ADD CONSTRAINT uq_worker_document_id_company + UNIQUE (worker_document_id, company_id); + +CREATE TABLE document_ocr_run ( + ocr_run_id UUID NOT NULL, + company_id UUID NOT NULL, + worker_document_id UUID NOT NULL, + stored_file_id UUID NOT NULL, + requested_by UUID NOT NULL, + runtime_request_id UUID NOT NULL, + idempotency_key_hash VARCHAR(64) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + document_type VARCHAR(40) NOT NULL, + country_code VARCHAR(3), + status VARCHAR(30) NOT NULL, + result_ciphertext TEXT, + result_key_version VARCHAR(60), + corrected_fields_ciphertext TEXT, + corrected_fields_key_version VARCHAR(60), + last_error_code VARCHAR(80), + reviewed_by UUID, + review_reason VARCHAR(300), + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + started_at TIMESTAMP(6) WITH TIME ZONE, + completed_at TIMESTAMP(6) WITH TIME ZONE, + reviewed_at TIMESTAMP(6) WITH TIME ZONE, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_document_ocr_run PRIMARY KEY (ocr_run_id), + CONSTRAINT uq_document_ocr_run_id_company UNIQUE (ocr_run_id, company_id), + CONSTRAINT uq_document_ocr_run_runtime_request UNIQUE (runtime_request_id), + CONSTRAINT uq_document_ocr_run_idempotency + UNIQUE (company_id, idempotency_key_hash), + CONSTRAINT fk_document_ocr_run_document_company + FOREIGN KEY (worker_document_id, company_id) + REFERENCES worker_document (worker_document_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_document_ocr_run_file_company + FOREIGN KEY (stored_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_document_ocr_run_requester_company + FOREIGN KEY (requested_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_document_ocr_run_reviewer_company + FOREIGN KEY (reviewed_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_document_ocr_run_idempotency_hash + CHECK (CHAR_LENGTH(idempotency_key_hash) = 64), + CONSTRAINT ck_document_ocr_run_request_hash + CHECK (CHAR_LENGTH(request_hash) = 64), + CONSTRAINT ck_document_ocr_run_document_type + CHECK (document_type IN ('PASSPORT_COPY', 'ARC')), + CONSTRAINT ck_document_ocr_run_country_code + CHECK ( + (document_type = 'PASSPORT_COPY' AND country_code IS NOT NULL AND CHAR_LENGTH(country_code) = 3) + OR (document_type = 'ARC' AND country_code IS NULL) + ), + CONSTRAINT ck_document_ocr_run_status + CHECK (status IN ( + 'QUEUED', 'RUNNING', 'READY_FOR_REVIEW', 'REVIEW_REQUIRED', + 'APPROVED', 'REJECTED', 'FAILED' + )), + CONSTRAINT ck_document_ocr_run_result_pair + CHECK ( + (result_ciphertext IS NULL AND result_key_version IS NULL) + OR (result_ciphertext IS NOT NULL AND result_key_version IS NOT NULL) + ), + CONSTRAINT ck_document_ocr_run_correction_pair + CHECK ( + (corrected_fields_ciphertext IS NULL AND corrected_fields_key_version IS NULL) + OR (corrected_fields_ciphertext IS NOT NULL AND corrected_fields_key_version IS NOT NULL) + ), + CONSTRAINT ck_document_ocr_run_review_pair + CHECK ( + (reviewed_by IS NULL AND reviewed_at IS NULL) + OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL) + ), + CONSTRAINT ck_document_ocr_run_review_reason + CHECK (review_reason IS NULL OR CHAR_LENGTH(TRIM(review_reason)) BETWEEN 1 AND 300), + CONSTRAINT ck_document_ocr_run_version CHECK (version >= 0), + CONSTRAINT ck_document_ocr_run_time_order CHECK ( + updated_at >= created_at + AND (started_at IS NULL OR started_at >= created_at) + AND (completed_at IS NULL OR completed_at >= created_at) + AND (reviewed_at IS NULL OR reviewed_at >= created_at) + ) +); + +CREATE INDEX idx_document_ocr_run_document_created + ON document_ocr_run (company_id, worker_document_id, created_at DESC, ocr_run_id); + +CREATE INDEX idx_document_ocr_run_company_status + ON document_ocr_run (company_id, status, updated_at DESC); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 38920d2d..fb6ad45b 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -107,7 +107,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "password_reset_token", "worker_import_job", "worker_import_row", - "worker_import_commit_idempotency" + "worker_import_commit_idempotency", + "document_ocr_run" ); assertThat(columnSpecs(connection, "company")) @@ -321,6 +322,18 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("request_hash", new ColumnSpec("varchar", false)) .containsEntry("response_snapshot_json", new ColumnSpec("text", false)) .containsEntry("created_at", new ColumnSpec("timestamptz", false)); + assertThat(columnSpecs(connection, "document_ocr_run")) + .containsEntry("ocr_run_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("worker_document_id", new ColumnSpec("uuid", false)) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("runtime_request_id", new ColumnSpec("uuid", false)) + .containsEntry("status", new ColumnSpec("varchar", false)) + .containsEntry("result_ciphertext", new ColumnSpec("text", true)) + .containsEntry("result_key_version", new ColumnSpec("varchar", true)) + .containsEntry("corrected_fields_ciphertext", new ColumnSpec("text", true)) + .containsEntry("corrected_fields_key_version", new ColumnSpec("varchar", true)) + .containsEntry("version", new ColumnSpec("int8", false)); assertThat(constraintNames(connection)) .contains( @@ -407,7 +420,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_worker_import_row_job_company", "fk_worker_import_row_worker_company", "pk_worker_import_commit_idempotency", - "fk_worker_import_commit_idempotency_job_company" + "fk_worker_import_commit_idempotency_job_company", + "pk_document_ocr_run", + "uq_document_ocr_run_idempotency", + "ck_document_ocr_run_correction_pair", + "fk_document_ocr_run_document_company", + "fk_document_ocr_run_file_company", + "fk_document_ocr_run_requester_company" ); assertThat(indexNames(connection)) .contains( @@ -444,7 +463,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_password_reset_token_company_user", "idx_password_reset_token_active", "idx_worker_import_job_company_updated", - "idx_worker_import_row_job_status" + "idx_worker_import_row_job_status", + "idx_document_ocr_run_document_created", + "idx_document_ocr_run_company_status" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -482,7 +503,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_password_reset_token_tenant_isolation", "pl_worker_import_job_tenant_isolation", "pl_worker_import_row_tenant_isolation", - "pl_worker_import_commit_idempotency_tenant_isolation" + "pl_worker_import_commit_idempotency_tenant_isolation", + "pl_document_ocr_run_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiOcrClientTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiOcrClientTest.java index bd10ac3c..b2501c27 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiOcrClientTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiOcrClientTest.java @@ -38,8 +38,8 @@ void validPassportContractIsDelegatedExactlyOnce() { AiOcrRequest request = passportRequest(); AiOcrResponse response = passportResponse( REQUEST_ID, - Map.of("passport_number", "M12345678"), - Map.of("passport_number", new BigDecimal("0.98")) + validPassportFields(), + validPassportConfidences() ); fake.enqueueResponse(response); @@ -106,8 +106,8 @@ void responseRequestIdMismatchIsRejected() { FakeAiOcrClient fake = new FakeAiOcrClient(); fake.enqueueResponse(passportResponse( UUID.fromString("10000000-0000-0000-0000-000000000002"), - Map.of("passport_number", "M12345678"), - Map.of("passport_number", new BigDecimal("0.98")) + validPassportFields(), + validPassportConfidences() )); assertFailureCode( @@ -154,6 +154,81 @@ void reviewRequiredMustIncludeSafeReason() { ); } + @Test + void successfulResponseCannotHaveEmptyFields() { + FakeAiOcrClient fake = new FakeAiOcrClient(); + fake.enqueueResponse(passportResponse(REQUEST_ID, Map.of(), Map.of())); + + assertFailureCode( + () -> new ValidatingAiOcrClient(fake, validator) + .recognize(passportRequest(), AiRuntimeCallContext.withoutTrace()), + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT + ); + } + + @Test + void invalidIsoDateIsRejected() { + FakeAiOcrClient fake = new FakeAiOcrClient(); + Map fields = new java.util.HashMap<>(validPassportFields()); + fields.put("date_of_birth", "잘못된날짜"); + fake.enqueueResponse(passportResponse(REQUEST_ID, fields, validPassportConfidences())); + + assertFailureCode( + () -> new ValidatingAiOcrClient(fake, validator) + .recognize(passportRequest(), AiRuntimeCallContext.withoutTrace()), + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT + ); + } + + @Test + void passportTemplateMustMatchRequestedCountry() { + FakeAiOcrClient fake = new FakeAiOcrClient(); + fake.enqueueResponse(new AiOcrResponse( + REQUEST_ID, + DOCUMENT_ID, + AiOcrStatus.SUCCEEDED, + 43021L, + null, + validPassportFields(), + validPassportConfidences(), + List.of() + )); + + assertFailureCode( + () -> new ValidatingAiOcrClient(fake, validator) + .recognize(passportRequest(), AiRuntimeCallContext.withoutTrace()), + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT + ); + } + + @Test + void arcTemplateMustMatchDocumentSide() { + FakeAiOcrClient fake = new FakeAiOcrClient(); + AiOcrRequest request = new AiOcrRequest( + REQUEST_ID, + DOCUMENT_ID, + AiOcrDocumentType.ARC, + null, + new AiOcrFile("arc.png", "image/png", new byte[]{1}) + ); + fake.enqueueResponse(new AiOcrResponse( + REQUEST_ID, + DOCUMENT_ID, + AiOcrStatus.SUCCEEDED, + 43024L, + AiOcrDocumentSide.BACK, + Map.of("alien_registration_number", "000000-0000000"), + Map.of("alien_registration_number", new BigDecimal("0.99")), + List.of() + )); + + assertFailureCode( + () -> new ValidatingAiOcrClient(fake, validator) + .recognize(request, AiRuntimeCallContext.withoutTrace()), + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT + ); + } + private AiOcrRequest passportRequest() { return new AiOcrRequest( REQUEST_ID, @@ -173,7 +248,7 @@ private AiOcrResponse passportResponse( requestId, DOCUMENT_ID, AiOcrStatus.SUCCEEDED, - 43019L, + 43038L, null, fields, confidences, @@ -181,6 +256,26 @@ private AiOcrResponse passportResponse( ); } + private Map validPassportFields() { + return Map.of( + "passport_number", "M12345678", + "surname", "NGUYEN", + "given_names", "VAN AN", + "date_of_birth", "1995-03-01", + "passport_expiry_date", "2028-03-01" + ); + } + + private Map validPassportConfidences() { + return Map.of( + "passport_number", new BigDecimal("0.98"), + "surname", new BigDecimal("0.97"), + "given_names", new BigDecimal("0.96"), + "date_of_birth", new BigDecimal("0.99"), + "passport_expiry_date", new BigDecimal("0.95") + ); + } + private void assertFailureCode(Runnable action, AiRuntimeFailureCode expected) { assertThatThrownBy(action::run) .isInstanceOfSatisfying(AiRuntimeContractException.class, exception -> diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClientWireMockTest.java new file mode 100644 index 00000000..29ae2c06 --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiOcrClientWireMockTest.java @@ -0,0 +1,159 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentType; +import com.fowoco.server.aiintegration.application.ocr.AiOcrFile; +import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class RemoteAiOcrClientWireMockTest { + + private static final UUID REQUEST_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID DOCUMENT_ID = UUID.fromString("20000000-0000-0000-0000-000000000001"); + private static final String PATH = "/internal/v1/ocr/worker-documents/" + DOCUMENT_ID; + private static final String TRACEPARENT = + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + private final ObjectMapper objectMapper = AiOcrHttpConfiguration.createContractObjectMapper( + new ObjectMapper() + ); + + private WireMockServer wireMock; + + @BeforeEach + void startWireMock() { + wireMock = new WireMockServer(wireMockConfig().dynamicPort()); + wireMock.start(); + } + + @AfterEach + void stopWireMock() { + if (wireMock != null) { + wireMock.stop(); + } + } + + @Test + void sendsAuthenticatedMultipartRequestAndParsesSnakeCaseResponse() { + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "request_id":"10000000-0000-0000-0000-000000000001", + "worker_document_id":"20000000-0000-0000-0000-000000000001", + "ocr_status":"SUCCEEDED", + "matched_template_id":1, + "document_side":"FRONT", + "fields":{"passport_number":"M12345678"}, + "field_confidences":{"passport_number":0.99}, + "review_reasons":[] + } + """))); + RemoteAiOcrClient client = client(); + + AiOcrResponse response = client.recognize(request(), new AiRuntimeCallContext(TRACEPARENT)); + + assertThat(response.fields()).containsEntry("passport_number", "M12345678"); + wireMock.verify(postRequestedFor(urlEqualTo(PATH)) + .withHeader("Authorization", equalTo("Bearer test-service-credential")) + .withHeader("X-Request-Id", equalTo(REQUEST_ID.toString())) + .withHeader("traceparent", equalTo(TRACEPARENT)) + .withHeader("Content-Type", matching("multipart/form-data; boundary=.*")) + .withRequestBody(containing("name=\"request_id\"")) + .withRequestBody(containing(REQUEST_ID.toString())) + .withRequestBody(containing("name=\"document_type\"")) + .withRequestBody(containing("PASSPORT_COPY")) + .withRequestBody(containing("name=\"country_code\"")) + .withRequestBody(containing("VNM")) + .withRequestBody(containing("filename=\"passport.jpg\""))); + } + + @Test + void classifiesServerFailureWithoutExposingResponseBody() { + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse() + .withStatus(503) + .withBody("secret-provider-error"))); + + assertThatThrownBy(() -> client().recognize(request(), AiRuntimeCallContext.withoutTrace())) + .isInstanceOfSatisfying(AiRuntimeCallException.class, exception -> { + assertThat(exception.failureCode()).isEqualTo(AiRuntimeFailureCode.RUNTIME_UNAVAILABLE); + assertThat(exception.getMessage()).doesNotContain("secret-provider-error"); + }); + } + + @Test + void rejectsNumericOcrFieldInsteadOfCoercingItToSensitiveText() { + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "request_id":"10000000-0000-0000-0000-000000000001", + "worker_document_id":"20000000-0000-0000-0000-000000000001", + "ocr_status":"SUCCEEDED", + "matched_template_id":1, + "document_side":"FRONT", + "fields":{"passport_number":12345}, + "field_confidences":{"passport_number":0.99}, + "review_reasons":[] + } + """))); + + assertThatThrownBy(() -> client().recognize(request(), AiRuntimeCallContext.withoutTrace())) + .isInstanceOfSatisfying(AiRuntimeCallException.class, exception -> + assertThat(exception.failureCode()) + .isEqualTo(AiRuntimeFailureCode.RESPONSE_PARSING_FAILED)); + } + + private RemoteAiOcrClient client() { + return new RemoteAiOcrClient( + URI.create(wireMock.baseUrl() + "/internal/v1/ocr/worker-documents"), + "Bearer test-service-credential", + Duration.ofSeconds(2), + 1_048_576, + 2, + HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(), + objectMapper, + new AiRuntimeCircuitBreaker(5, Duration.ofSeconds(30), Clock.systemUTC()) + ); + } + + private AiOcrRequest request() { + return new AiOcrRequest( + REQUEST_ID, + DOCUMENT_ID, + AiOcrDocumentType.PASSPORT_COPY, + "VNM", + new AiOcrFile( + "passport.jpg", + "image/jpeg", + "image-content".getBytes(StandardCharsets.UTF_8) + ) + ); + } +} diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index ffc4a2e1..34a0a29f 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -78,6 +78,14 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("aa000000-0000-0000-0000-000000000001"); private static final UUID PASSWORD_RESET_B = UUID.fromString("ba000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_DOCUMENT_A = + UUID.fromString("ac000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_DOCUMENT_B = + UUID.fromString("bc000000-0000-0000-0000-000000000002"); + private static final UUID OCR_RUN_A = + UUID.fromString("ad000000-0000-0000-0000-000000000001"); + private static final UUID OCR_RUN_B = + UUID.fromString("bd000000-0000-0000-0000-000000000002"); private static final List RLS_TABLES = List.of( "company", "user_account", @@ -93,7 +101,8 @@ class PostgreSqlRlsIsolationTest { "worker_document_upload_idempotency", "outbox_manual_retry", "user_agreement_consent", - "password_reset_token" + "password_reset_token", + "document_ocr_run" ); @Test @@ -186,6 +195,7 @@ private void prepareFixture( + "public.worker_response_upload, " + "public.worker_document_upload_idempotency, " + "public.outbox_manual_retry, " + + "public.document_ocr_run, " + "public.user_agreement_consent, " + "public.password_reset_token TO " + quotedRole @@ -290,6 +300,38 @@ INSERT INTO stored_file ( STORED_FILE_B, COMPANY_B, STORED_FILE_B_UNLINKED, COMPANY_B )); + statement.execute(""" + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, document_type, + submission_status, file_id, created_at, updated_at + ) VALUES + ('%s', '%s', '%s', 'PASSPORT_COPY', 'SUBMITTED', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('%s', '%s', '%s', 'PASSPORT_COPY', 'SUBMITTED', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.formatted( + WORKER_DOCUMENT_A, WORKER_A, COMPANY_A, STORED_FILE_A, + WORKER_DOCUMENT_B, WORKER_B, COMPANY_B, STORED_FILE_B + )); + statement.execute(""" + INSERT INTO document_ocr_run ( + ocr_run_id, company_id, worker_document_id, stored_file_id, + requested_by, runtime_request_id, idempotency_key_hash, + request_hash, document_type, country_code, status, + created_at, updated_at + ) VALUES + ('%s', '%s', '%s', '%s', '%s', + 'ae000000-0000-0000-0000-000000000001', repeat('1', 64), + repeat('2', 64), 'PASSPORT_COPY', 'VNM', 'QUEUED', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('%s', '%s', '%s', '%s', '%s', + 'be000000-0000-0000-0000-000000000002', repeat('3', 64), + repeat('4', 64), 'PASSPORT_COPY', 'PHL', 'QUEUED', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.formatted( + OCR_RUN_A, COMPANY_A, WORKER_DOCUMENT_A, STORED_FILE_A, USER_A, + OCR_RUN_B, COMPANY_B, WORKER_DOCUMENT_B, STORED_FILE_B, USER_B + )); statement.execute(""" INSERT INTO document_request_draft ( draft_id, task_id, company_id, language, message, review_status @@ -405,6 +447,7 @@ private void assertMissingAndInvalidContextFailClosed(Connection connection) assertThat(tableCount(connection, "outbox_manual_retry")).isZero(); assertThat(tableCount(connection, "user_agreement_consent")).isZero(); assertThat(tableCount(connection, "password_reset_token")).isZero(); + assertThat(tableCount(connection, "document_ocr_run")).isZero(); setTenantContext(connection, ""); assertThat(workerCount(connection)).isZero(); @@ -477,6 +520,10 @@ private void assertTenantCrudIsolation(Connection connection) throws SQLExceptio "SELECT password_reset_token_id FROM public.password_reset_token " + "ORDER BY password_reset_token_id" )).containsExactly(PASSWORD_RESET_A); + assertThat(uuidValues( + connection, + "SELECT ocr_run_id FROM public.document_ocr_run ORDER BY ocr_run_id" + )).containsExactly(OCR_RUN_A); assertThat(executeUpdate( connection, @@ -514,6 +561,23 @@ INSERT INTO workflow_case ( WORKER_A_NEW )).isOne(); + assertSqlState( + connection, + "42501", + """ + INSERT INTO document_ocr_run ( + ocr_run_id, company_id, worker_document_id, stored_file_id, + requested_by, runtime_request_id, idempotency_key_hash, + request_hash, document_type, country_code, status, + created_at, updated_at + ) VALUES ( + 'bd000000-0000-0000-0000-000000000099', '%s', '%s', '%s', '%s', + 'be000000-0000-0000-0000-000000000099', repeat('5', 64), + repeat('6', 64), 'PASSPORT_COPY', 'PHL', 'QUEUED', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_B, WORKER_DOCUMENT_B, STORED_FILE_B, USER_B) + ); assertSqlState( connection, "42501", @@ -645,6 +709,11 @@ INSERT INTO outbox_manual_retry ( "UPDATE workflow_case SET title = 'Hidden Update' WHERE case_id = ?", CASE_B )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM document_ocr_run WHERE ocr_run_id = ?", + OCR_RUN_B + )).isZero(); assertThat(executeUpdate( connection, "DELETE FROM workflow_case WHERE case_id = ?", @@ -690,6 +759,7 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL assertThat(workerCount(connection)).isZero(); assertThat(tableCount(connection, "workflow_case")).isZero(); assertThat(tableCount(connection, "outbox_manual_retry")).isZero(); + assertThat(tableCount(connection, "document_ocr_run")).isZero(); setTenantContext(connection, COMPANY_B.toString()); assertThat(workerIds(connection)).containsExactly(WORKER_B); assertThat(uuidValues( @@ -700,6 +770,10 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL connection, "SELECT manual_retry_id FROM public.outbox_manual_retry ORDER BY manual_retry_id" )).containsExactly(MANUAL_RETRY_B); + assertThat(uuidValues( + connection, + "SELECT ocr_run_id FROM public.document_ocr_run ORDER BY ocr_run_id" + )).containsExactly(OCR_RUN_B); connection.rollback(); } @@ -742,6 +816,11 @@ private SQLException runCleanupStep(SQLException failure, SqlCleanupStep step) { } private void deleteFixtureRows(Statement statement) throws SQLException { + statement.execute(""" + DELETE FROM document_ocr_run + WHERE ocr_run_id IN ('%s', '%s') + OR ocr_run_id = 'bd000000-0000-0000-0000-000000000099' + """.formatted(OCR_RUN_A, OCR_RUN_B)); statement.execute(""" DELETE FROM outbox_manual_retry WHERE manual_retry_id IN ('%s', '%s') @@ -782,6 +861,10 @@ WHERE draft_id IN ('%s', '%s') DELETE FROM document_request_draft WHERE draft_id IN ('%s', '%s') """.formatted(DRAFT_A, DRAFT_B)); + statement.execute(""" + DELETE FROM worker_document + WHERE worker_document_id IN ('%s', '%s') + """.formatted(WORKER_DOCUMENT_A, WORKER_DOCUMENT_B)); statement.execute(""" DELETE FROM stored_file WHERE stored_file_id IN ('%s', '%s', '%s') diff --git a/src/test/java/com/fowoco/server/document/DocumentOcrApiIntegrationTest.java b/src/test/java/com/fowoco/server/document/DocumentOcrApiIntegrationTest.java new file mode 100644 index 00000000..a0840806 --- /dev/null +++ b/src/test/java/com/fowoco/server/document/DocumentOcrApiIntegrationTest.java @@ -0,0 +1,584 @@ +package com.fowoco.server.document; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest; +import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse; +import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus; +import com.fowoco.server.aiintegration.application.port.AiOcrClient; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.reliability.application.OutboxProcessor; +import com.fowoco.server.reliability.application.OutboxClaimService; +import com.jayway.jsonpath.JsonPath; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "app.document.ocr.enabled=true", + "app.document.ocr.encryption-key-base64=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.document.ocr.key-version=test-v1", + "app.reliability.outbox.enabled=false" + } +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DocumentOcrApiIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("c1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("c2000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("c3000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("c4000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A = UUID.fromString("c5000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_B = UUID.fromString("c6000000-0000-0000-0000-000000000002"); + private static final UUID FILE_A = UUID.fromString("c7000000-0000-0000-0000-000000000001"); + private static final UUID FILE_B = UUID.fromString("c8000000-0000-0000-0000-000000000002"); + private static final UUID DOCUMENT_A = UUID.fromString("c9000000-0000-0000-0000-000000000001"); + private static final UUID DOCUMENT_B = UUID.fromString("ca000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.ocr.a@example.com"; + private static final String HR_B_EMAIL = "hr.ocr.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final byte[] FILE_CONTENT = "fake-passport-image".getBytes(StandardCharsets.UTF_8); + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private TestFileStorage fileStorage; + + @Autowired + private TestAiOcrClient aiOcrClient; + + @Autowired + private OutboxProcessor outboxProcessor; + + @Autowired + private OutboxClaimService outboxClaimService; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + @BeforeAll + void seedAccounts() { + deleteFixture(); + insertCompany(COMPANY_A, "OCR 사업장 A"); + insertCompany(COMPANY_B, "OCR 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + } + + @BeforeEach + void resetOcrFixture() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM document_ocr_run"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM worker"); + insertWorker(WORKER_A, COMPANY_A, "응웬반안", "VN"); + insertWorker(WORKER_B, COMPANY_B, "마리아", "PH"); + insertStoredFile(FILE_A, COMPANY_A, WORKER_A, "ocr-a"); + insertStoredFile(FILE_B, COMPANY_B, WORKER_B, "ocr-b"); + insertDocument(DOCUMENT_A, WORKER_A, COMPANY_A, FILE_A); + insertDocument(DOCUMENT_B, WORKER_B, COMPANY_B, FILE_B); + fileStorage.reset(Map.of("ocr-a", FILE_CONTENT, "ocr-b", FILE_CONTENT)); + aiOcrClient.reset(); + } + + @Test + void ocrRunIsRecoveredAfterExpiredLeaseThenEncryptedAndReviewedWithoutUpdatingWorker() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse created = postOcr(DOCUMENT_A, "ocr-request-0001", token); + + assertThat(created.statusCode()).as(created.body()).isEqualTo(202); + UUID runId = UUID.fromString(JsonPath.read(created.body(), "$.ocr_run_id")); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("QUEUED"); + assertThat(aiOcrClient.requests()).isEmpty(); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM event_publication WHERE aggregate_id = ?", + String.class, + runId + )).isEqualTo("PENDING"); + + UUID eventId = jdbcTemplate.queryForObject( + "SELECT event_id FROM event_publication WHERE aggregate_id = ?", + UUID.class, + runId + ); + assertThat(outboxClaimService.claimBatch("stopped-ocr-server")) + .containsExactly(new OutboxClaimService.ClaimedEvent(eventId, COMPANY_A)); + jdbcTemplate.update( + """ + UPDATE document_ocr_run + SET status = 'RUNNING', started_at = ?, updated_at = ?, version = version + 1 + WHERE ocr_run_id = ? + """, + java.time.Instant.now(), + java.time.Instant.now(), + runId + ); + jdbcTemplate.update( + "UPDATE event_publication SET lease_expires_at = ?, version = version + 1 WHERE event_id = ?", + java.time.Instant.now().minusSeconds(60), + eventId + ); + + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + + HttpResponse completed = awaitStatus(DOCUMENT_A, runId, token, "READY_FOR_REVIEW"); + assertThat(JsonPath.read(completed.body(), "$.result.fields.passport_number")) + .isEqualTo("M12345678"); + assertThat(JsonPath.read(completed.body(), "$.result.fields.surname")) + .isEqualTo("NGUYEN"); + assertThat(aiOcrClient.requests()).hasSize(1); + assertThat(aiOcrClient.requests().get(0).countryCode()).isEqualTo("VNM"); + + String ciphertext = jdbcTemplate.queryForObject( + "SELECT result_ciphertext FROM document_ocr_run WHERE ocr_run_id = ?", + String.class, + runId + ); + assertThat(ciphertext).startsWith("v1.").doesNotContain("M12345678", "NGUYEN"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'DOCUMENT_OCR_COMPLETED'", + Integer.class, + runId + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'DOCUMENT_OCR_RESULT_VIEWED'", + Integer.class, + runId + )).isGreaterThanOrEqualTo(1); + + long version = JsonPath.read(completed.body(), "$.version").longValue(); + HttpResponse reviewed = review( + DOCUMENT_A, + runId, + version, + "APPROVE", + null, + Map.of("passport_number", "M87654321"), + token + ); + assertThat(reviewed.statusCode()).as(reviewed.body()).isEqualTo(200); + assertThat(JsonPath.read(reviewed.body(), "$.status")).isEqualTo("APPROVED"); + assertThat(JsonPath.read(reviewed.body(), "$.result.fields.passport_number")) + .isEqualTo("M12345678"); + assertThat(JsonPath.read(reviewed.body(), "$.corrected_fields.passport_number")) + .isEqualTo("M87654321"); + String correctedCiphertext = jdbcTemplate.queryForObject( + "SELECT corrected_fields_ciphertext FROM document_ocr_run WHERE ocr_run_id = ?", + String.class, + runId + ); + assertThat(correctedCiphertext).startsWith("v1.").doesNotContain("M87654321"); + String auditSummary = jdbcTemplate.queryForObject( + "SELECT change_summary FROM audit_event WHERE target_id = ? AND action = 'DOCUMENT_OCR_APPROVED'", + String.class, + runId + ); + assertThat(auditSummary).contains("passport_number").doesNotContain("M87654321"); + assertThat(jdbcTemplate.queryForObject( + "SELECT nationality_code FROM worker WHERE worker_id = ?", + String.class, + WORKER_A + )).isEqualTo("VN"); + } + + @Test + void sameIdempotencyKeyReturnsSameRunAndDoesNotCallAiTwice() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + HttpResponse first = postOcr(DOCUMENT_A, "ocr-request-0002", token); + UUID runId = UUID.fromString(JsonPath.read(first.body(), "$.ocr_run_id")); + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + awaitStatus(DOCUMENT_A, runId, token, "READY_FOR_REVIEW"); + + HttpResponse replay = postOcr(DOCUMENT_A, "ocr-request-0002", token); + + assertThat(replay.statusCode()).isEqualTo(202); + assertThat(JsonPath.read(replay.body(), "$.ocr_run_id")).isEqualTo(runId.toString()); + assertThat(JsonPath.read(replay.body(), "$.already_requested")).isTrue(); + assertThat(aiOcrClient.requests()).hasSize(1); + } + + @Test + void otherCompanyCannotReadOcrRunAndMissingIdempotencyHeaderIsBadRequest() throws Exception { + String tokenA = accessToken(login(HR_A_EMAIL)); + HttpResponse created = postOcr(DOCUMENT_A, "ocr-request-0003", tokenA); + UUID runId = UUID.fromString(JsonPath.read(created.body(), "$.ocr_run_id")); + String tokenB = accessToken(login(HR_B_EMAIL)); + + HttpResponse otherCompany = get( + "/api/v1/documents/" + DOCUMENT_A + "/ocr-runs/" + runId, + tokenB + ); + HttpResponse missingHeader = post( + "/api/v1/documents/" + DOCUMENT_B + "/ocr-runs", + null, + tokenB + ); + + assertThat(otherCompany.statusCode()).isEqualTo(404); + assertThat(missingHeader.statusCode()).isEqualTo(400); + } + + @Test + void retryUsesNewRunAndDoesNotOverwriteFailedHistory() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + aiOcrClient.failNext(); + HttpResponse first = postOcr(DOCUMENT_A, "ocr-request-0004", token); + UUID failedRunId = UUID.fromString(JsonPath.read(first.body(), "$.ocr_run_id")); + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + HttpResponse failed = awaitStatus(DOCUMENT_A, failedRunId, token, "FAILED"); + assertThat(JsonPath.read(failed.body(), "$.error_code")) + .isEqualTo("RUNTIME_UNAVAILABLE"); + + HttpResponse retry = postOcr(DOCUMENT_A, "ocr-request-0005", token); + UUID retryRunId = UUID.fromString(JsonPath.read(retry.body(), "$.ocr_run_id")); + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + awaitStatus(DOCUMENT_A, retryRunId, token, "READY_FOR_REVIEW"); + + assertThat(retryRunId).isNotEqualTo(failedRunId); + assertThat(JsonPath.read( + get("/api/v1/documents/" + DOCUMENT_A + "/ocr-runs/" + failedRunId, token).body(), + "$.status" + )).isEqualTo("FAILED"); + assertThat(aiOcrClient.requests()).hasSize(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'DOCUMENT_OCR_FAILED'", + Integer.class, + failedRunId + )).isEqualTo(1); + } + + @Test + void unknownCorrectionFieldIsRejectedWithoutChangingReviewState() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + HttpResponse created = postOcr(DOCUMENT_A, "ocr-request-0006", token); + UUID runId = UUID.fromString(JsonPath.read(created.body(), "$.ocr_run_id")); + assertThat(outboxProcessor.processAvailable()).isEqualTo(1); + HttpResponse completed = awaitStatus(DOCUMENT_A, runId, token, "READY_FOR_REVIEW"); + long version = JsonPath.read(completed.body(), "$.version").longValue(); + + HttpResponse rejected = review( + DOCUMENT_A, + runId, + version, + "APPROVE", + null, + Map.of("raw_provider_response", "노출되면 안 되는 값"), + token + ); + + assertThat(rejected.statusCode()).as(rejected.body()).isEqualTo(422); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM document_ocr_run WHERE ocr_run_id = ?", + String.class, + runId + )).isEqualTo("READY_FOR_REVIEW"); + assertThat(jdbcTemplate.queryForObject( + "SELECT corrected_fields_ciphertext FROM document_ocr_run WHERE ocr_run_id = ?", + String.class, + runId + )).isNull(); + } + + private HttpResponse awaitStatus( + UUID documentId, + UUID runId, + String token, + String expectedStatus + ) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + HttpResponse response; + do { + response = get("/api/v1/documents/" + documentId + "/ocr-runs/" + runId, token); + if (response.statusCode() == 200 + && expectedStatus.equals(JsonPath.read(response.body(), "$.status"))) { + return response; + } + Thread.sleep(25); + } while (System.nanoTime() < deadline); + throw new AssertionError("OCR did not reach " + expectedStatus + ": " + response.body()); + } + + private HttpResponse postOcr(UUID documentId, String key, String token) throws Exception { + return post("/api/v1/documents/" + documentId + "/ocr-runs", key, token); + } + + private HttpResponse review( + UUID documentId, + UUID runId, + long version, + String decision, + String reason, + Map correctedFields, + String token + ) throws Exception { + String fieldsJson = correctedFields.entrySet().stream() + .map(entry -> "\"" + entry.getKey() + "\":\"" + entry.getValue() + "\"") + .collect(java.util.stream.Collectors.joining(",", "{", "}")); + String body = "{\"expected_version\":" + version + + ",\"decision\":\"" + decision + "\"" + + (reason == null ? "" : ",\"reason\":\"" + reason + "\"") + + ",\"corrected_fields\":" + fieldsJson + "}"; + HttpRequest request = HttpRequest.newBuilder(uri( + "/api/v1/documents/" + documentId + "/ocr-runs/" + runId + "/review" + )) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(String path, String idempotencyKey, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .POST(HttpRequest.BodyPublishers.noBody()); + if (idempotencyKey != null) { + builder.header("Idempotency-Key", idempotencyKey); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse get(String path, String token) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse login(String email) throws Exception { + String body = "{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"; + return httpClient.send( + HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private String accessToken(HttpResponse response) { + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private void insertCompany(UUID id, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + id, + name + ); + } + + private void insertUser(UUID id, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, role, status + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE') + """, + id, + companyId, + email, + email, + passwordHash + ); + } + + private void insertWorker(UUID id, UUID companyId, String displayName, String nationality) { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, nationality_code, work_status + ) VALUES (?, ?, ?, ?, 'ACTIVE') + """, + id, + companyId, + displayName, + nationality + ); + } + + private void insertStoredFile(UUID id, UUID companyId, UUID workerId, String storageKey) { + jdbcTemplate.update( + """ + INSERT INTO stored_file ( + stored_file_id, company_id, name, mime_type, size, purpose, + worker_id, storage_key, scan_status + ) VALUES (?, ?, 'passport.jpg', 'image/jpeg', ?, 'WORKER_DOCUMENT', ?, ?, 'NOT_SCANNED') + """, + id, + companyId, + FILE_CONTENT.length, + workerId, + storageKey + ); + } + + private void insertDocument(UUID id, UUID workerId, UUID companyId, UUID fileId) { + jdbcTemplate.update( + """ + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, document_type, + submission_status, file_id + ) VALUES (?, ?, ?, 'PASSPORT_COPY', 'SUBMITTED', ?) + """, + id, + workerId, + companyId, + fileId + ); + } + + private void deleteFixture() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM document_ocr_run"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + @TestConfiguration(proxyBeanMethods = false) + static class OcrTestConfiguration { + + @Bean + @Primary + TestFileStorage testFileStorage() { + return new TestFileStorage(); + } + + @Bean + @Primary + TestAiOcrClient testAiOcrClient() { + return new TestAiOcrClient(); + } + } + + static final class TestFileStorage implements FileStorage { + private final Map files = new ConcurrentHashMap<>(); + + void reset(Map next) { + files.clear(); + next.forEach((key, value) -> files.put(key, value.clone())); + } + + @Override + public void store(String storageKey, InputStream content, long size, String mimeType) { + throw new UnsupportedOperationException("not needed in OCR API test"); + } + + @Override + public Optional open(String storageKey) { + byte[] value = files.get(storageKey); + return value == null ? Optional.empty() : Optional.of(new ByteArrayInputStream(value)); + } + } + + static final class TestAiOcrClient implements AiOcrClient { + private final List requests = new CopyOnWriteArrayList<>(); + private final AtomicBoolean failNext = new AtomicBoolean(); + + void reset() { + requests.clear(); + failNext.set(false); + } + + void failNext() { + failNext.set(true); + } + + List requests() { + return List.copyOf(requests); + } + + @Override + public AiOcrResponse recognize(AiOcrRequest request, AiRuntimeCallContext context) { + requests.add(request); + if (failNext.compareAndSet(true, false)) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, + "AI OCR is unavailable." + ); + } + return new AiOcrResponse( + request.requestId(), + request.workerDocumentId(), + AiOcrStatus.SUCCEEDED, + 43038L, + null, + Map.of( + "passport_number", "M12345678", + "surname", "NGUYEN", + "given_names", "VAN AN", + "date_of_birth", "1995-03-01", + "passport_expiry_date", "2028-03-01" + ), + Map.of( + "passport_number", new BigDecimal("0.99"), + "surname", new BigDecimal("0.98"), + "given_names", new BigDecimal("0.97"), + "date_of_birth", new BigDecimal("0.99"), + "passport_expiry_date", new BigDecimal("0.96") + ), + List.of() + ); + } + } +} diff --git a/src/test/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipherTest.java b/src/test/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipherTest.java new file mode 100644 index 00000000..3cdec640 --- /dev/null +++ b/src/test/java/com/fowoco/server/document/infrastructure/crypto/AesGcmOcrResultCipherTest.java @@ -0,0 +1,31 @@ +package com.fowoco.server.document.infrastructure.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class AesGcmOcrResultCipherTest { + + @Test + void encryptsWithTenantAndRunBoundAuthenticatedData() { + AesGcmOcrResultCipher cipher = new AesGcmOcrResultCipher( + new byte[32], + "test-v1", + new SecureRandom() + ); + UUID companyId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + byte[] plaintext = "M12345678".getBytes(StandardCharsets.UTF_8); + + String encrypted = cipher.encrypt(plaintext, companyId, runId); + + assertThat(encrypted).startsWith("v1.").doesNotContain("M12345678"); + assertThat(cipher.decrypt(encrypted, companyId, runId)).isEqualTo(plaintext); + assertThatThrownBy(() -> cipher.decrypt(encrypted, UUID.randomUUID(), runId)) + .isInstanceOf(IllegalStateException.class); + } +}