diff --git a/.env.example b/.env.example index 0acc6256..9f3285ab 100644 --- a/.env.example +++ b/.env.example @@ -153,3 +153,5 @@ DEMO_SEED_ENABLED=false # DEMO_SEED_ADMIN_EMAIL=demo.admin@example.com # DEMO_SEED_COMPANY_NAME=FOWOCO Demo Company # DEMO_SEED_TEST_COMPANY_NAME=FOWOCO Test Company +# demo document fixture command: none | import | verify | cleanup +DEMO_DOCUMENT_DATA_COMMAND=none diff --git a/compose.demo.yml b/compose.demo.yml index 11daa353..31739165 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -63,10 +63,14 @@ services: 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} + FILE_STORAGE_LOCAL_PATH: /app/data/files DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false} DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-} + DEMO_DOCUMENT_DATA_COMMAND: ${DEMO_DOCUMENT_DATA_COMMAND:-none} extra_hosts: - "host.docker.internal:host-gateway" + volumes: + - fowoco-server-data:/app/data healthcheck: test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/actuator/health/readiness"] interval: 10s @@ -76,3 +80,4 @@ services: volumes: fowoco-postgres-data: + fowoco-server-data: diff --git a/docs/demo-document-data.md b/docs/demo-document-data.md new file mode 100644 index 00000000..96f0f136 --- /dev/null +++ b/docs/demo-document-data.md @@ -0,0 +1,98 @@ +# 합성 근로자 문서 영구 적재 + +이 기능은 기존 Demo Company·Worker Seed 위에 합성 문서 파일과 메타데이터를 추가한다. +실제 개인정보나 행정기관 원본 문서를 사용하지 않으며, 모든 파일에는 +`DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 문구가 포함된다. + +## 저장 구조 + +- DB: PostgreSQL 16의 `worker_document`, `stored_file`, `document_ocr_run` +- 원본: `app.file-storage.local-path` 아래 파일시스템 +- Kubernetes: `server-data` PVC의 `/app/data/files` +- 로컬 Compose: `fowoco-server-data` named volume의 `/app/data/files` + +DB에는 개발자 개인 절대경로를 저장하지 않는다. 저장 키는 다음 형태다. + +```text +demo/{company_id}/workers/{worker_id}/documents/{document_id}/{filename} +``` + +## 준비 + +다음 값은 로컬 Secret으로만 설정하고 터미널 출력, Issue, PR, 문서에 실제 값을 남기지 않는다. + +```bash +export DEMO_DB_PASSWORD='' +export JWT_SECRET_BASE64='<32-byte-base64-secret>' +export DEMO_SEED_ADMIN_PASSWORD='<12-or-more-character-local-secret>' +export OCR_RESULT_ENCRYPTION_KEY_BASE64='<32-byte-base64-secret>' +``` + +## 명령 + +```bash +./scripts/demo-data import +./scripts/demo-data verify +./scripts/demo-data cleanup +``` + +- `import`: PostgreSQL과 파일 volume을 준비하고, 기존 Demo Seed를 멱등 실행한 뒤 문서 + fixture를 적재·검증한다. 성공하면 API Server도 기동한다. +- `verify`: 고정 ID, 회사·근로자·업무 연결, 날짜·상태, MIME·크기·SHA-256, 실제 파일, + OCR 원본 계보를 읽기 검증한다. +- `cleanup`: 이 기능이 예약한 ID와 해시가 모두 일치할 때만 OCR·문서·파일을 제거한다. + 기존 Demo Company, Worker, Task와 기존 Showcase 문서는 삭제하지 않는다. + +같은 `import`를 다시 실행하면 DB row와 파일 수가 증가하지 않는다. 예약 ID 또는 저장 키에 +다른 내용이 있으면 덮어쓰지 않고 즉시 실패한다. + +## 대표 fixture + +대표 근로자는 `응웬반A / NGUYEN VAN AN`, `VN`, `vi`, `E-9`이다. 숫자·주소는 +`SYNTHETIC`, `DEMO`, `SAMPLE` 표식을 포함해 실제 식별자로 사용할 수 없게 만들었다. + +대표 근로자 파일: + +- 여권 인적사항면 PNG, 여권 사본 PDF +- 외국인등록증 앞면 PNG, 뒷면 JPG, 통합 사본 PDF +- 고용허가서 PDF +- 표준근로계약서 PDF·HWPX·HWP +- 취업활동기간 연장신청서 초안 HWPX +- 통합신청서 초안 HWPX +- 체류지 입증자료 PDF + +추가 근로자 4명에게 정상·임박·만료·필수 문서 누락 상태를 연결한다. 대표 근로자의 +ARC 앞면에는 암호화된 합성 OCR 결과와 `REVIEW_REQUIRED` 상태를 연결한다. + +## API 확인 + +로그인 후 다음 API를 사용한다. `company_id`는 요청에서 받지 않고 인증 Context에서 결정한다. + +```text +GET /api/v1/documents +GET /api/v1/documents?workerId={worker_id} +GET /api/v1/documents/{worker_document_id} +GET /api/v1/files/{file_id}/content +GET /api/v1/documents/{worker_document_id}/ocr-runs/latest +``` + +다른 Demo Test Company 계정으로 같은 ID를 조회하면 파일·문서 존재 여부를 감춘 `404`가 +반환되어야 한다. + +## 배포 환경 실행 + +Kubernetes Secret 값을 로컬로 출력하거나 복사하지 않는다. 권한이 있는 운영자가 현재 +Server image에 이 변경이 배포된 뒤 Pod 내부에서 one-shot command를 실행하고, 별도 Pod의 +동시 실행이 없도록 확인해야 한다. 정확한 명령은 배포 시스템의 Secret 주입 방식을 유지한 채 +다음 Spring argument를 사용한다. + +```text +--server.port=0 +--app.demo-document-data.command=import|verify|cleanup +--app.reliability.outbox.enabled=false +``` + +`DEMO_SEED_ENABLED=true`, `DOCUMENT_OCR_ENABLED=true`, 영구 `FILE_STORAGE_LOCAL_PATH`, OCR +암호화 키가 필요하다. 명령 완료 후 runner가 Application Context를 닫으므로 별도 Pod는 종료된다. +기존 운영·개발 데이터가 섞인 DB에서는 실행하지 않고 전용 Demo Company와 volume에서만 +실행한다. diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 80bc328e..3383d4fd 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -11,6 +11,10 @@ Demo Seed에는 목적이 다른 두 종류의 데이터가 함께 있다. - **Showcase Seed**: 목록·업무함·문서함·대시보드의 다양한 상태와 화면 밀도를 위한 다른 근로자의 예시 데이터 +원본 이미지·PDF·HWP·HWPX와 암호화된 합성 OCR 결과가 필요한 문서 저장소 시나리오는 +[합성 근로자 문서 영구 적재](demo-document-data.md)의 별도 멱등 명령을 사용한다. 이 명령은 +기존 수량과 Golden Flow 시작 상태를 바꾸지 않도록 별도 예약 ID 영역을 사용한다. + 예약 ID와 Figma 대응 관계는 [Demo Fixture Manifest](demo-seed-fixture-manifest.md)에서 확인한다. diff --git a/scripts/demo-data b/scripts/demo-data new file mode 100755 index 00000000..c2ef2a1d --- /dev/null +++ b/scripts/demo-data @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +server_dir="$(cd "${script_dir}/.." && pwd)" +compose_file="${server_dir}/compose.demo.yml" +command_name="${1:-}" + +case "${command_name}" in + import|verify|cleanup) ;; + *) + echo "사용법: ./scripts/demo-data {import|verify|cleanup}" >&2 + exit 2 + ;; +esac + +required_variables=( + DEMO_DB_PASSWORD + JWT_SECRET_BASE64 + DEMO_SEED_ADMIN_PASSWORD + OCR_RESULT_ENCRYPTION_KEY_BASE64 +) + +for variable_name in "${required_variables[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + echo "${variable_name} 환경변수가 필요합니다. 값은 로그나 Git에 남기지 마세요." >&2 + exit 1 + fi +done + +cd "${server_dir}" +docker compose -f "${compose_file}" up -d --wait postgres +docker compose -f "${compose_file}" build server +docker compose -f "${compose_file}" run --rm --no-deps \ + --user root \ + --entrypoint sh \ + server \ + -c 'mkdir -p /app/data/files && chown -R 10001:10001 /app/data' + +DEMO_SEED_ENABLED=true \ +DOCUMENT_OCR_ENABLED=true \ +DEMO_DOCUMENT_DATA_COMMAND="${command_name}" \ +docker compose -f "${compose_file}" run --rm --no-deps \ + server \ + --server.port=0 \ + --app.reliability.outbox.enabled=false + +if [[ "${command_name}" == "import" ]]; then + DEMO_SEED_ENABLED=true \ + DOCUMENT_OCR_ENABLED=true \ + DEMO_DOCUMENT_DATA_COMMAND=none \ + docker compose -f "${compose_file}" up -d --wait server +fi diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java new file mode 100644 index 00000000..a10e2386 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java @@ -0,0 +1,87 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentDataService.DemoDocumentDataReport; +import java.util.Locale; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(3) +class DemoDocumentDataCommandRunner implements ApplicationRunner { + + private static final Logger LOGGER = LoggerFactory.getLogger(DemoDocumentDataCommandRunner.class); + + private final DemoDocumentDataService service; + private final DemoAuthSeedProperties seedProperties; + private final ConfigurableApplicationContext applicationContext; + private final String command; + + DemoDocumentDataCommandRunner( + DemoDocumentDataService service, + DemoAuthSeedProperties seedProperties, + ConfigurableApplicationContext applicationContext, + @Value("${app.demo-document-data.command:none}") String command + ) { + this.service = Objects.requireNonNull(service, "service must not be null"); + this.seedProperties = Objects.requireNonNull(seedProperties, "seedProperties must not be null"); + this.applicationContext = Objects.requireNonNull(applicationContext, "applicationContext must not be null"); + this.command = Objects.requireNonNull(command, "command must not be null"); + } + + @Override + public void run(ApplicationArguments arguments) { + Command parsed = Command.parse(command); + if (parsed == Command.NONE) { + return; + } + if (!seedProperties.enabled()) { + throw new IllegalStateException("DEMO_SEED_ENABLED=true is required for demo document data commands"); + } + DemoDocumentDataReport report = switch (parsed) { + case IMPORT -> service.importData(); + case VERIFY -> service.verifyData(); + case CLEANUP -> service.cleanupData(); + case NONE -> throw new IllegalStateException("unreachable demo document command"); + }; + LOGGER.info( + "demo_document_data command={} document_count={} file_count={} image_count={} " + + "pdf_count={} hwp_count={} hwpx_count={} task_linked_count={} missing_count={}", + parsed.name().toLowerCase(Locale.ROOT), + report.documentCount(), + report.fileCount(), + report.imageCount(), + report.pdfCount(), + report.hwpCount(), + report.hwpxCount(), + report.taskLinkedDocumentCount(), + report.missingDocumentCount() + ); + applicationContext.close(); + } + + private enum Command { + NONE, + IMPORT, + VERIFY, + CLEANUP; + + static Command parse(String value) { + try { + return Command.valueOf(value.strip().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException( + "app.demo-document-data.command must be one of none, import, verify, cleanup", + exception + ); + } + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java new file mode 100644 index 00000000..8e675766 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java @@ -0,0 +1,529 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentSide; +import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.FixtureFormat; +import com.fowoco.server.document.application.DocumentOcrResultPayload; +import com.fowoco.server.document.application.port.DocumentOcrRunRepository; +import com.fowoco.server.document.application.port.OcrResultCipher; +import com.fowoco.server.document.domain.DocumentOcrRun; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Service +class DemoDocumentDataService { + + private static final String SOURCE = "DEMO_SEED"; + private static final String PURPOSE = "DEMO_WORKER_DOCUMENT"; + private static final String OCR_IDEMPOTENCY_HASH = sha256Text("demo-document-ocr-review-v1"); + private static final String OCR_REQUEST_HASH = sha256Text( + DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID.toString() + ); + + private final JdbcTemplate jdbcTemplate; + private final TenantDatabaseContext tenantDatabaseContext; + private final DocumentOcrRunRepository ocrRunRepository; + private final OcrResultCipher ocrResultCipher; + private final ObjectMapper objectMapper; + private final Clock clock; + private final SyntheticDocumentGenerator generator = new SyntheticDocumentGenerator(); + private final DemoDocumentFileInstaller fileInstaller; + + DemoDocumentDataService( + JdbcTemplate jdbcTemplate, + TenantDatabaseContext tenantDatabaseContext, + DocumentOcrRunRepository ocrRunRepository, + OcrResultCipher ocrResultCipher, + ObjectMapper objectMapper, + Clock clock, + @Value("${app.file-storage.local-path}") String localFileStoragePath + ) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.tenantDatabaseContext = Objects.requireNonNull( + tenantDatabaseContext, "tenantDatabaseContext must not be null" + ); + this.ocrRunRepository = Objects.requireNonNull(ocrRunRepository, "ocrRunRepository must not be null"); + this.ocrResultCipher = Objects.requireNonNull(ocrResultCipher, "ocrResultCipher must not be null"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.fileInstaller = new DemoDocumentFileInstaller(localFileStoragePath); + } + + @Transactional + DemoDocumentDataReport importData() { + bindTenant(); + requireBaseSeed(); + requireOcrEncryption(); + LocalDate anchorDate = anchorDate(); + Instant now = clock.instant(); + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + requireWorkerAndTask(fixture); + byte[] content = content(fixture); + if (content != null) { + fileInstaller.install(fixture.storageKey(), content); + seedStoredFile(fixture, content, now); + } + seedWorkerDocument(fixture, anchorDate, now); + } + seedOcrReview(now); + return verifyInternal(anchorDate); + } + + @Transactional(readOnly = true) + DemoDocumentDataReport verifyData() { + bindTenant(); + requireBaseSeed(); + requireOcrEncryption(); + return verifyInternal(anchorDate()); + } + + @Transactional + DemoDocumentDataReport cleanupData() { + bindTenant(); + requireBaseSeed(); + verifyOwnedRowsBeforeCleanup(anchorDate()); + + jdbcTemplate.update( + "DELETE FROM document_ocr_run WHERE ocr_run_id = ? AND company_id = ?", + DemoDocumentFixtureCatalog.OCR_RUN_ID, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + jdbcTemplate.update( + "DELETE FROM worker_document WHERE worker_document_id = ? AND company_id = ?", + fixture.documentId(), DemoDocumentFixtureCatalog.COMPANY_ID + ); + } + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + byte[] content = content(fixture); + if (content == null) { + continue; + } + jdbcTemplate.update( + "DELETE FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + fixture.fileId(), DemoDocumentFixtureCatalog.COMPANY_ID + ); + fileInstaller.cleanup(fixture.storageKey(), content); + } + return new DemoDocumentDataReport(0, 0, 0, 0, 0, 0, 0, 0); + } + + private void seedStoredFile(DemoDocumentFixture fixture, byte[] content, Instant now) { + List> existing = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ?", + fixture.fileId() + ); + if (!existing.isEmpty()) { + verifyStoredFile(fixture, content, existing.get(0)); + return; + } + jdbcTemplate.update( + """ + INSERT INTO stored_file ( + stored_file_id, company_id, name, mime_type, size, purpose, + task_id, worker_id, storage_key, scan_status, verified, + checksum_sha256, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'NOT_SCANNED', TRUE, ?, ?, ?) + """, + fixture.fileId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + fixture.originalFilename(), + fixture.contentType(), + content.length, + PURPOSE, + fixture.taskId(), + fixture.workerId(), + fixture.storageKey(), + DemoDocumentFileInstaller.sha256(content), + Timestamp.from(now), + Timestamp.from(now) + ); + } + + private void seedWorkerDocument(DemoDocumentFixture fixture, LocalDate anchorDate, Instant now) { + List> existing = jdbcTemplate.queryForList( + "SELECT * FROM worker_document WHERE worker_document_id = ?", + fixture.documentId() + ); + if (!existing.isEmpty()) { + verifyWorkerDocument(fixture, anchorDate, existing.get(0)); + return; + } + jdbcTemplate.update( + """ + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, task_id, document_type, + submission_status, issue_date, expiry_date, destination, note, + file_id, source, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + """, + fixture.documentId(), + fixture.workerId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + fixture.taskId(), + fixture.documentType().name(), + fixture.status().name(), + date(relativeDate(anchorDate, fixture.issueDays())), + date(relativeDate(anchorDate, fixture.expiryDays())), + fixture.destination(), + fixture.note(), + fixture.fileId(), + SOURCE, + Timestamp.from(now), + Timestamp.from(now) + ); + } + + private void seedOcrReview(Instant now) { + var existing = ocrRunRepository.findByIdAndCompanyId( + DemoDocumentFixtureCatalog.OCR_RUN_ID, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (existing.isPresent()) { + verifyOcrRun(existing.get()); + return; + } + byte[] plaintext = ocrPayload(); + String ciphertext = ocrResultCipher.encrypt( + plaintext, + DemoDocumentFixtureCatalog.COMPANY_ID, + DemoDocumentFixtureCatalog.OCR_RUN_ID + ); + DocumentOcrRun run = DocumentOcrRun.create( + DemoDocumentFixtureCatalog.OCR_RUN_ID, + DemoDocumentFixtureCatalog.COMPANY_ID, + DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID, + fixtureById(DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID).fileId(), + DemoDocumentFixtureCatalog.ADMIN_USER_ID, + DemoDocumentFixtureCatalog.OCR_RUNTIME_REQUEST_ID, + OCR_IDEMPOTENCY_HASH, + OCR_REQUEST_HASH, + com.fowoco.server.worker.domain.DocumentType.ARC, + null, + now + ) + .start(now) + .complete(AiOcrStatus.REVIEW_REQUIRED, ciphertext, ocrResultCipher.keyVersion(), now); + ocrRunRepository.insert(run); + } + + private DemoDocumentDataReport verifyInternal(LocalDate anchorDate) { + int fileCount = 0; + int imageCount = 0; + int pdfCount = 0; + int hwpCount = 0; + int hwpxCount = 0; + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + requireWorkerAndTask(fixture); + List> documentRows = jdbcTemplate.queryForList( + "SELECT * FROM worker_document WHERE worker_document_id = ?", + fixture.documentId() + ); + if (documentRows.size() != 1) { + throw new IllegalStateException("demo worker document fixture is missing"); + } + verifyWorkerDocument(fixture, anchorDate, documentRows.get(0)); + byte[] content = content(fixture); + if (content == null) { + continue; + } + List> fileRows = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ?", + fixture.fileId() + ); + if (fileRows.size() != 1) { + throw new IllegalStateException("demo stored file fixture is missing"); + } + verifyStoredFile(fixture, content, fileRows.get(0)); + fileInstaller.verify(fixture.storageKey(), content); + fileCount++; + switch (fixture.format()) { + case PNG, JPEG -> imageCount++; + case PDF -> pdfCount++; + case HWP -> hwpCount++; + case HWPX -> hwpxCount++; + case NONE -> throw new IllegalStateException("missing fixture unexpectedly had content"); + } + } + DocumentOcrRun ocrRun = ocrRunRepository.findByIdAndCompanyId( + DemoDocumentFixtureCatalog.OCR_RUN_ID, + DemoDocumentFixtureCatalog.COMPANY_ID + ) + .orElseThrow(() -> new IllegalStateException("demo OCR review fixture is missing")); + verifyOcrRun(ocrRun); + int taskLinked = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE source = ? AND company_id = ? AND task_id IS NOT NULL", + Integer.class, + SOURCE, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + int missing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE source = ? AND company_id = ? AND submission_status = 'MISSING'", + Integer.class, + SOURCE, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + return new DemoDocumentDataReport( + DemoDocumentFixtureCatalog.fixtures().size(), + fileCount, + imageCount, + pdfCount, + hwpCount, + hwpxCount, + taskLinked, + missing + ); + } + + private void verifyOwnedRowsBeforeCleanup(LocalDate anchorDate) { + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + List> documents = jdbcTemplate.queryForList( + "SELECT * FROM worker_document WHERE worker_document_id = ?", + fixture.documentId() + ); + if (!documents.isEmpty()) { + verifyWorkerDocument(fixture, anchorDate, documents.get(0)); + } + byte[] content = content(fixture); + if (content == null) { + continue; + } + List> files = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ?", + fixture.fileId() + ); + if (!files.isEmpty()) { + verifyStoredFile(fixture, content, files.get(0)); + fileInstaller.verify(fixture.storageKey(), content); + } + } + } + + private void verifyStoredFile( + DemoDocumentFixture fixture, + byte[] content, + Map row + ) { + if (!fixture.fileId().equals(row.get("stored_file_id")) + || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(row.get("company_id")) + || !fixture.originalFilename().equals(row.get("name")) + || !fixture.contentType().equals(row.get("mime_type")) + || ((Number) row.get("size")).longValue() != content.length + || !PURPOSE.equals(row.get("purpose")) + || !Objects.equals(fixture.taskId(), row.get("task_id")) + || !fixture.workerId().equals(row.get("worker_id")) + || !fixture.storageKey().equals(row.get("storage_key")) + || !"NOT_SCANNED".equals(row.get("scan_status").toString()) + || !Boolean.TRUE.equals(row.get("verified")) + || !DemoDocumentFileInstaller.sha256(content).equals(row.get("checksum_sha256"))) { + throw new IllegalStateException("reserved demo stored file id belongs to different data"); + } + } + + private void verifyWorkerDocument( + DemoDocumentFixture fixture, + LocalDate anchorDate, + Map row + ) { + if (!fixture.documentId().equals(row.get("worker_document_id")) + || !fixture.workerId().equals(row.get("worker_id")) + || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(row.get("company_id")) + || !Objects.equals(fixture.taskId(), row.get("task_id")) + || !fixture.documentType().name().equals(row.get("document_type").toString()) + || !fixture.status().name().equals(row.get("submission_status").toString()) + || !Objects.equals(relativeDate(anchorDate, fixture.issueDays()), localDate(row.get("issue_date"))) + || !Objects.equals(relativeDate(anchorDate, fixture.expiryDays()), localDate(row.get("expiry_date"))) + || !fixture.destination().equals(row.get("destination")) + || !fixture.note().equals(row.get("note")) + || !Objects.equals(fixture.fileId(), row.get("file_id")) + || !SOURCE.equals(row.get("source"))) { + throw new IllegalStateException("reserved demo worker document id belongs to different data"); + } + } + + private void verifyOcrRun(DocumentOcrRun run) { + if (!DemoDocumentFixtureCatalog.OCR_RUN_ID.equals(run.ocrRunId()) + || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(run.companyId()) + || !DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID.equals(run.workerDocumentId()) + || run.status() != com.fowoco.server.document.domain.DocumentOcrRunStatus.REVIEW_REQUIRED + || run.resultCiphertext() == null + || run.resultKeyVersion() == null) { + throw new IllegalStateException("reserved demo OCR run id belongs to different data"); + } + byte[] decrypted = ocrResultCipher.decrypt( + run.resultCiphertext(), run.companyId(), run.ocrRunId() + ); + DocumentOcrResultPayload actualPayload; + try { + actualPayload = objectMapper.readValue(decrypted, DocumentOcrResultPayload.class); + } catch (JacksonException exception) { + throw new IllegalStateException("failed to deserialize demo OCR payload", exception); + } + if (!ocrPayloadObject().equals(actualPayload)) { + throw new IllegalStateException("demo OCR result payload does not match the fixture"); + } + } + + private byte[] ocrPayload() { + try { + return objectMapper.writeValueAsBytes(ocrPayloadObject()); + } catch (JacksonException exception) { + throw new IllegalStateException("failed to serialize demo OCR payload", exception); + } + } + + private DocumentOcrResultPayload ocrPayloadObject() { + return new DocumentOcrResultPayload( + 43019L, + AiOcrDocumentSide.FRONT, + new TreeMap<>(Map.of( + "alien_registration_number", "SYNTHETIC-ARC-0001", + "visa_type", "E-9", + "stay_expiration_date", "2099-12-31", + "residence_address_1", "DEMO ADDRESS - NOT REAL" + )), + new TreeMap<>(Map.of( + "alien_registration_number", new BigDecimal("0.61"), + "visa_type", new BigDecimal("0.98"), + "stay_expiration_date", new BigDecimal("0.82"), + "residence_address_1", new BigDecimal("0.58") + )), + List.of("LOW_CONFIDENCE_IDENTIFIER", "SYNTHETIC_FIXTURE_REVIEW") + ); + } + + private void requireBaseSeed() { + Integer company = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM company WHERE company_id = ? AND status = 'ACTIVE'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + Integer worker = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE worker_id = ? AND company_id = ? AND display_name = ?", + Integer.class, + DemoDocumentFixtureCatalog.GOLD_WORKER_ID, + DemoDocumentFixtureCatalog.COMPANY_ID, + "응웬반A" + ); + if (company != 1 || worker != 1) { + throw new IllegalStateException("base Demo Company and Gold Worker seed must exist first"); + } + } + + private void requireWorkerAndTask(DemoDocumentFixture fixture) { + Integer worker = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE worker_id = ? AND company_id = ?", + Integer.class, + fixture.workerId(), + DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (worker != 1) { + throw new IllegalStateException("demo document worker is missing"); + } + if (fixture.taskId() == null) { + return; + } + Integer task = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task WHERE task_id = ? AND worker_id = ? AND company_id = ?", + Integer.class, + fixture.taskId(), + fixture.workerId(), + DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (task != 1) { + throw new IllegalStateException("demo document task relationship is invalid"); + } + } + + private void requireOcrEncryption() { + if (!ocrResultCipher.isAvailable()) { + throw new IllegalStateException( + "demo document data requires DOCUMENT_OCR_ENABLED and OCR_RESULT_ENCRYPTION_KEY_BASE64" + ); + } + } + + private void bindTenant() { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(DemoDocumentFixtureCatalog.COMPANY_ID); + } + + private byte[] content(DemoDocumentFixture fixture) { + return fixture.format() == FixtureFormat.NONE ? null : generator.generate(fixture); + } + + private DemoDocumentFixture fixtureById(UUID documentId) { + return DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.documentId().equals(documentId)) + .findFirst() + .orElseThrow(); + } + + private LocalDate relativeDate(LocalDate today, Integer days) { + return days == null ? null : today.plusDays(days); + } + + private LocalDate anchorDate() { + DemoDocumentFixture anchorFixture = DemoDocumentFixtureCatalog.fixtures().get(0); + List> rows = jdbcTemplate.queryForList( + "SELECT expiry_date FROM worker_document WHERE worker_document_id = ? AND company_id = ?", + anchorFixture.documentId(), DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (rows.isEmpty()) { + return LocalDate.now(clock); + } + LocalDate expiryDate = localDate(rows.get(0).get("expiry_date")); + if (expiryDate == null || anchorFixture.expiryDays() == null) { + throw new IllegalStateException("demo document anchor date is missing"); + } + return expiryDate.minusDays(anchorFixture.expiryDays()); + } + + private Date date(LocalDate value) { + return value == null ? null : Date.valueOf(value); + } + + private LocalDate localDate(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDate localDate) { + return localDate; + } + return ((Date) value).toLocalDate(); + } + + private static String sha256Text(String value) { + return DemoDocumentFileInstaller.sha256(value.getBytes(StandardCharsets.UTF_8)); + } + + record DemoDocumentDataReport( + int documentCount, + int fileCount, + int imageCount, + int pdfCount, + int hwpCount, + int hwpxCount, + int taskLinkedDocumentCount, + int missingDocumentCount + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFileInstaller.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFileInstaller.java new file mode 100644 index 00000000..6c807aeb --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFileInstaller.java @@ -0,0 +1,103 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +final class DemoDocumentFileInstaller { + + private final Path rootDirectory; + + DemoDocumentFileInstaller(String localPath) { + this.rootDirectory = Path.of(localPath).toAbsolutePath().normalize(); + } + + void install(String storageKey, byte[] content) { + Path target = target(storageKey); + try { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireExpectedRegularFile(target, storageKey, content); + return; + } + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), ".demo-document-", ".tmp"); + try { + Files.write(temporary, content); + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(temporary); + } + requireExpectedRegularFile(target, storageKey, content); + } catch (IOException exception) { + throw new UncheckedIOException("failed to install demo document file", exception); + } + } + + void verify(String storageKey, byte[] content) { + try { + requireExpectedRegularFile(target(storageKey), storageKey, content); + } catch (IOException exception) { + throw new UncheckedIOException("failed to verify demo document file", exception); + } + } + + void cleanup(String storageKey, byte[] content) { + Path target = target(storageKey); + try { + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return; + } + requireExpectedRegularFile(target, storageKey, content); + Files.delete(target); + pruneEmptyParents(target.getParent()); + } catch (IOException exception) { + throw new UncheckedIOException("failed to clean up demo document file", exception); + } + } + + private void requireExpectedRegularFile(Path target, String storageKey, byte[] expected) + throws IOException { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) + || Files.size(target) != expected.length + || !sha256(Files.readAllBytes(target)).equals(sha256(expected))) { + throw new IllegalStateException( + "demo document storage key contains unexpected content: " + storageKey + ); + } + } + + private void pruneEmptyParents(Path directory) throws IOException { + Path current = directory; + while (current != null && !current.equals(rootDirectory) && current.startsWith(rootDirectory)) { + try (var entries = Files.list(current)) { + if (entries.findAny().isPresent()) { + return; + } + } + Files.delete(current); + current = current.getParent(); + } + } + + private Path target(String storageKey) { + Path target = rootDirectory.resolve(storageKey).normalize(); + if (!target.startsWith(rootDirectory)) { + throw new IllegalArgumentException("demo document storage key must not escape the storage root"); + } + return target; + } + + static String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java new file mode 100644 index 00000000..6af91b95 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java @@ -0,0 +1,170 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.List; +import java.util.UUID; + +final class DemoDocumentFixtureCatalog { + + static final UUID COMPANY_ID = uuid("90000000-0000-0000-0000-000000000001"); + static final UUID ADMIN_USER_ID = uuid("90000000-0000-0000-0000-000000000002"); + static final UUID GOLD_WORKER_ID = workerId(6); + static final UUID OCR_DOCUMENT_ID = documentId(3); + static final UUID OCR_RUN_ID = uuid("95300000-0000-0000-0000-000000000001"); + static final UUID OCR_RUNTIME_REQUEST_ID = uuid("95310000-0000-0000-0000-000000000001"); + + private static final List FIXTURES = List.of( + fixture(1, 6, null, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, + -365, 365, "여권_인적사항면_응웬반A.png", "passport-bio.png", + "image/png", FixtureFormat.PNG, "Passport biographical page"), + fixture(2, 6, null, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, + -365, 365, "여권_사본_응웬반A.pdf", "passport-copy.pdf", + "application/pdf", FixtureFormat.PDF, "Passport copy"), + fixture(3, 6, null, DocumentType.ARC, SubmissionStatus.SUBMITTED, + -180, 270, "외국인등록증_앞면_응웬반A.png", "arc-front.png", + "image/png", FixtureFormat.PNG, "ARC front - OCR review required"), + fixture(4, 6, null, DocumentType.ARC, SubmissionStatus.SUBMITTED, + -180, 270, "외국인등록증_뒷면_응웬반A.jpg", "arc-back.jpg", + "image/jpeg", FixtureFormat.JPEG, "ARC back - OCR review required"), + fixture(5, 6, null, DocumentType.ARC, SubmissionStatus.VERIFIED, + -180, 14, "외국인등록증_통합사본_응웬반A.pdf", "arc-combined.pdf", + "application/pdf", FixtureFormat.PDF, "ARC combined copy - expiring soon"), + fixture(6, 6, null, DocumentType.PERMIT, SubmissionStatus.VERIFIED, + -180, 180, "고용허가서_응웬반A.pdf", "employment-permit.pdf", + "application/pdf", FixtureFormat.PDF, "Employment permit"), + fixture(7, 6, null, DocumentType.CONTRACT, SubmissionStatus.VERIFIED, + -30, 180, "표준근로계약서_응웬반A.pdf", "employment-contract.pdf", + "application/pdf", FixtureFormat.PDF, "Standard employment contract"), + fixture(8, 6, null, DocumentType.CONTRACT, SubmissionStatus.VERIFIED, + -30, 180, "표준근로계약서_응웬반A.hwpx", "employment-contract.hwpx", + "application/hwp+zip", FixtureFormat.HWPX, "Standard employment contract HWPX"), + fixture(9, 6, null, DocumentType.CONTRACT, SubmissionStatus.VERIFIED, + -30, 180, "표준근로계약서_응웬반A.hwp", "employment-contract.hwp", + "application/x-hwp", FixtureFormat.HWP, "Standard employment contract HWP"), + fixture(10, 6, null, DocumentType.EMPLOYMENT_EXTENSION_APPLICATION, SubmissionStatus.DRAFT, + 0, 60, "취업활동기간_연장신청서_초안_응웬반A.hwpx", "employment-extension-draft.hwpx", + "application/hwp+zip", FixtureFormat.HWPX, "Employment activity extension draft"), + fixture(11, 6, null, DocumentType.INTEGRATED_APPLICATION, SubmissionStatus.DRAFT, + 0, 60, "통합신청서_초안_응웬반A.hwpx", "integrated-application-draft.hwpx", + "application/hwp+zip", FixtureFormat.HWPX, "Integrated application draft"), + fixture(12, 6, null, DocumentType.RESIDENCE_PROOF, SubmissionStatus.VERIFIED, + -45, 180, "체류지_입증자료_응웬반A.pdf", "residence-proof.pdf", + "application/pdf", FixtureFormat.PDF, "Residence proof"), + fixture(13, 1, 1, DocumentType.CONTRACT, SubmissionStatus.VERIFIED, + -40, 220, "근로계약서_리웨이.pdf", "worker-01-contract.pdf", + "application/pdf", FixtureFormat.PDF, "Normal document state"), + fixture(14, 2, 2, DocumentType.PERMIT, SubmissionStatus.VERIFIED, + -200, 10, "고용허가서_속체아.png", "worker-02-permit.png", + "image/png", FixtureFormat.PNG, "Expiring-soon document state"), + fixture(15, 3, 3, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, + -500, -10, "여권사본_아르준타파.pdf", "worker-03-expired-passport.pdf", + "application/pdf", FixtureFormat.PDF, "Expired document state"), + fixture(16, 4, 4, DocumentType.RESIDENCE_PROOF, SubmissionStatus.MISSING, + null, null, null, null, null, FixtureFormat.NONE, "Required document missing") + ); + + private DemoDocumentFixtureCatalog() { + } + + static List fixtures() { + return FIXTURES; + } + + private static DemoDocumentFixture fixture( + int number, + int workerNumber, + Integer taskNumber, + DocumentType documentType, + SubmissionStatus status, + Integer issueDays, + Integer expiryDays, + String originalFilename, + String storageFilename, + String contentType, + FixtureFormat format, + String title + ) { + return new DemoDocumentFixture( + documentId(number), + format == FixtureFormat.NONE ? null : fileId(number), + workerId(workerNumber), + taskNumber == null ? null : taskId(taskNumber), + documentType, + status, + issueDays, + expiryDays, + originalFilename, + storageFilename, + contentType, + format, + title, + destination(documentType), + "DEMO/SAMPLE fixture - not for official submission" + ); + } + + private static String destination(DocumentType type) { + return switch (type) { + case PASSPORT_COPY, ARC, INTEGRATED_APPLICATION, RESIDENCE_PROOF -> "체류기간 연장"; + case CONTRACT -> "근로계약 갱신"; + case PERMIT, EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장"; + }; + } + + static UUID documentId(int number) { + return uuid("95200000-0000-0000-0000-000000000%03d".formatted(number)); + } + + private static UUID fileId(int number) { + return uuid("94900000-0000-0000-0000-000000000%03d".formatted(number)); + } + + private static UUID workerId(int number) { + return uuid("92000000-0000-0000-0000-000000000%03d".formatted(number)); + } + + private static UUID taskId(int number) { + return uuid("94000000-0000-0000-0000-000000000%03d".formatted(number)); + } + + private static UUID uuid(String value) { + return UUID.fromString(value); + } + + enum FixtureFormat { + PNG, + JPEG, + PDF, + HWP, + HWPX, + NONE + } + + record DemoDocumentFixture( + UUID documentId, + UUID fileId, + UUID workerId, + UUID taskId, + DocumentType documentType, + SubmissionStatus status, + Integer issueDays, + Integer expiryDays, + String originalFilename, + String storageFilename, + String contentType, + FixtureFormat format, + String title, + String destination, + String note + ) { + String storageKey() { + if (fileId == null) { + return null; + } + return "demo/%s/workers/%s/documents/%s/%s".formatted( + COMPANY_ID, workerId, documentId, storageFilename + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java new file mode 100644 index 00000000..c2bc24ba --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java @@ -0,0 +1,235 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import javax.imageio.ImageIO; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; + +final class SyntheticDocumentGenerator { + + private static final List IDENTITY_LINES = List.of( + "DISPLAY NAME: NGUYEN VAN AN", + "NATIONALITY: VIET NAM (VN)", + "PREFERRED LANGUAGE: vi", + "VISA: E-9", + "SYNTHETIC REFERENCE: DEMO-VN-FOWOCO-0001", + "DATE OF BIRTH: 1995-04-12 (SYNTHETIC)", + "ADDRESS: DEMO ADDRESS 14, SAMPLE-RO, FOWOCO" + ); + + byte[] generate(DemoDocumentFixture fixture) { + return switch (fixture.format()) { + case PNG -> image(fixture, "png"); + case JPEG -> image(fixture, "jpg"); + case PDF -> pdf(fixture); + case HWP -> hwp(fixture); + case HWPX -> hwpx(fixture); + case NONE -> throw new IllegalArgumentException("missing fixtures do not have file content"); + }; + } + + private byte[] image(DemoDocumentFixture fixture, String format) { + int width = 1200; + int height = 760; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setColor(new Color(248, 250, 252)); + graphics.fillRect(0, 0, width, height); + graphics.setColor(new Color(185, 28, 28)); + graphics.fillRect(0, 0, width, 92); + graphics.setColor(Color.WHITE); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 30)); + graphics.drawString("DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION", 48, 58); + + graphics.setColor(new Color(15, 23, 42)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 36)); + graphics.drawString(fixture.title(), 58, 164); + graphics.setStroke(new BasicStroke(2f)); + graphics.setColor(new Color(148, 163, 184)); + graphics.drawRoundRect(48, 195, width - 96, height - 255, 18, 18); + + graphics.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 23)); + graphics.setColor(new Color(30, 41, 59)); + int y = 250; + for (String line : IDENTITY_LINES) { + graphics.drawString(line, 82, y); + y += 55; + } + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 22)); + graphics.setColor(new Color(185, 28, 28)); + graphics.drawString("SYNTHETIC DATA ONLY / NO GOVERNMENT SECURITY FEATURES", 82, height - 82); + } finally { + graphics.dispose(); + } + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + if (!ImageIO.write(image, format, output)) { + throw new IllegalStateException("ImageIO writer is unavailable: " + format); + } + return output.toByteArray(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to generate synthetic image", exception); + } + } + + private byte[] pdf(DemoDocumentFixture fixture) { + List lines = new ArrayList<>(); + lines.add("DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION"); + lines.add(fixture.title()); + lines.addAll(IDENTITY_LINES); + lines.add("NO GOVERNMENT LOGO, SECURITY PATTERN, OR OFFICIAL SIGNATURE"); + + StringBuilder stream = new StringBuilder("BT\n/F1 12 Tf\n50 790 Td\n"); + for (int index = 0; index < lines.size(); index++) { + if (index > 0) { + stream.append("0 -42 Td\n"); + } + stream.append('(').append(pdfEscape(lines.get(index))).append(") Tj\n"); + } + stream.append("ET\n"); + byte[] streamBytes = stream.toString().getBytes(StandardCharsets.US_ASCII); + + List objects = List.of( + ascii("<< /Type /Catalog /Pages 2 0 R >>"), + ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] " + + "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"), + concat(ascii("<< /Length " + streamBytes.length + " >>\nstream\n"), + streamBytes, ascii("endstream")), + ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + ); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + write(output, ascii("%PDF-1.4\n% FOWOCO synthetic fixture\n")); + List offsets = new ArrayList<>(); + for (int index = 0; index < objects.size(); index++) { + offsets.add(output.size()); + write(output, ascii((index + 1) + " 0 obj\n")); + write(output, objects.get(index)); + write(output, ascii("\nendobj\n")); + } + int xref = output.size(); + write(output, ascii("xref\n0 " + (objects.size() + 1) + "\n")); + write(output, ascii("0000000000 65535 f \n")); + offsets.forEach(offset -> write(output, ascii("%010d 00000 n \n".formatted(offset)))); + write(output, ascii("trailer\n<< /Size " + (objects.size() + 1) + " /Root 1 0 R >>\n" + + "startxref\n" + xref + "\n%%EOF\n")); + return output.toByteArray(); + } + + private byte[] hwp(DemoDocumentFixture fixture) { + try (POIFSFileSystem fileSystem = new POIFSFileSystem(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] header = new byte[256]; + byte[] signature = "HWP Document File".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(signature, 0, header, 0, signature.length); + fileSystem.getRoot().createDocument("FileHeader", new ByteArrayInputStream(header)); + var body = fileSystem.getRoot().createDirectory("BodyText"); + String text = fixture.title() + "\nDEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION\n" + + String.join("\n", IDENTITY_LINES); + body.createDocument("Section0", new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))); + fileSystem.writeFilesystem(output); + return output.toByteArray(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to generate synthetic HWP container", exception); + } + } + + private byte[] hwpx(DemoDocumentFixture fixture) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + byte[] mime = "application/hwp+zip".getBytes(StandardCharsets.US_ASCII); + CRC32 crc = new CRC32(); + crc.update(mime); + ZipEntry mimeEntry = new ZipEntry("mimetype"); + mimeEntry.setMethod(ZipEntry.STORED); + mimeEntry.setSize(mime.length); + mimeEntry.setCompressedSize(mime.length); + mimeEntry.setCrc(crc.getValue()); + mimeEntry.setTime(0L); + zip.putNextEntry(mimeEntry); + zip.write(mime); + zip.closeEntry(); + + addZipEntry(zip, "META-INF/container.xml", """ + + + + + """); + addZipEntry(zip, "Contents/content.hpf", """ + + + FOWOCO synthetic document fixture + + """); + addZipEntry(zip, "Contents/header.xml", """ + + + """); + String body = fixture.title() + " | DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION | " + + String.join(" | ", IDENTITY_LINES); + addZipEntry(zip, "Contents/section0.xml", """ + + + + %s + + + """.formatted(xmlEscape(body))); + addZipEntry(zip, "Preview/PrvText.txt", body); + zip.finish(); + return output.toByteArray(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to generate synthetic HWPX", exception); + } + } + + private void addZipEntry(ZipOutputStream zip, String name, String content) throws IOException { + ZipEntry entry = new ZipEntry(name); + entry.setTime(0L); + zip.putNextEntry(entry); + zip.write(content.strip().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + + private String pdfEscape(String value) { + return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)"); + } + + private String xmlEscape(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private byte[] concat(byte[]... chunks) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (byte[] chunk : chunks) { + write(output, chunk); + } + return output.toByteArray(); + } + + private void write(ByteArrayOutputStream output, byte[] bytes) { + output.writeBytes(bytes); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java index 9f3f873c..74049621 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -84,6 +84,8 @@ private static UUID matchingTaskId(DocumentSeed document, List tasks) case PASSPORT_COPY, ARC -> TaskType.STAY_PERIOD_EXTENSION; case CONTRACT -> TaskType.RECONTRACT; case PERMIT -> TaskType.EMPLOYMENT_PERIOD_EXTENSION; + case EMPLOYMENT_EXTENSION_APPLICATION -> TaskType.EMPLOYMENT_PERIOD_EXTENSION; + case INTEGRATED_APPLICATION, RESIDENCE_PROOF -> TaskType.STAY_PERIOD_EXTENSION; }; return tasks.stream() .filter(task -> task.workerId().equals(document.workerId())) @@ -159,6 +161,8 @@ private static Integer scenarioExpiryDays( case ARC -> null; case CONTRACT -> 180; case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + case EMPLOYMENT_EXTENSION_APPLICATION, INTEGRATED_APPLICATION, RESIDENCE_PROOF -> + throw new IllegalStateException("worker 6 has no extended document seed"); }; } return EXPIRY_DAY_OFFSETS[additionIndex % EXPIRY_DAY_OFFSETS.length]; @@ -184,6 +188,8 @@ private static String scenarioNote( case ARC -> "외국인등록증 사본 요청 필요"; case CONTRACT -> "현재 근로계약서 확인 완료"; case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + case EMPLOYMENT_EXTENSION_APPLICATION, INTEGRATED_APPLICATION, RESIDENCE_PROOF -> + throw new IllegalStateException("worker 6 has no extended document seed"); }; } return note(documentType, status); @@ -194,6 +200,8 @@ private static String destination(DocumentType documentType) { case PASSPORT_COPY, ARC -> "체류기간 연장"; case CONTRACT -> "근로계약 갱신"; case PERMIT -> "고용허가기간 연장"; + case EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장"; + case INTEGRATED_APPLICATION, RESIDENCE_PROOF -> "체류기간 연장"; }; } @@ -203,8 +211,12 @@ private static String note(DocumentType documentType, SubmissionStatus status) { case ARC -> "외국인등록증 사본"; case CONTRACT -> "근로계약서"; case PERMIT -> "고용허가서"; + case EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장신청서"; + case INTEGRATED_APPLICATION -> "통합신청서"; + case RESIDENCE_PROOF -> "체류지 입증자료"; }; return switch (status) { + case DRAFT -> documentName + " 초안"; case MISSING -> documentName + " 요청 필요"; case SUBMITTED -> documentName + " 제출본 검토 대기"; case VERIFIED -> documentName + " 확인 완료"; diff --git a/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java b/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java index 9d284dd5..882a4df6 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentOcrService.java @@ -468,7 +468,9 @@ 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); + case CONTRACT, PERMIT, EMPLOYMENT_EXTENSION_APPLICATION, + INTEGRATED_APPLICATION, RESIDENCE_PROOF -> + throw new ApiException(DocumentErrorCode.DOCUMENT_OCR_UNSUPPORTED_TYPE); }; } @@ -518,7 +520,8 @@ private Map normalizeCorrectedFields( Set allowedFields = switch (documentType) { case PASSPORT_COPY -> PASSPORT_CORRECTABLE_FIELDS; case ARC -> ARC_CORRECTABLE_FIELDS; - case CONTRACT, PERMIT -> Set.of(); + case CONTRACT, PERMIT, EMPLOYMENT_EXTENSION_APPLICATION, + INTEGRATED_APPLICATION, RESIDENCE_PROOF -> Set.of(); }; Map normalized = new LinkedHashMap<>(); fields.entrySet().stream() diff --git a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java index 27c73cb5..6e9a4239 100644 --- a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java +++ b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java @@ -397,7 +397,11 @@ private String identityDocumentLabel(List documentTypes) { .map(documentType -> switch (documentType) { case PASSPORT_COPY -> "여권"; case ARC -> "외국인등록증"; - default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); + case CONTRACT -> "근로계약서"; + case PERMIT -> "고용허가서"; + case EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장신청서"; + case INTEGRATED_APPLICATION -> "통합신청서"; + case RESIDENCE_PROOF -> "체류지 입증자료"; }) .collect(java.util.stream.Collectors.joining("·")); } diff --git a/src/main/java/com/fowoco/server/worker/domain/DocumentType.java b/src/main/java/com/fowoco/server/worker/domain/DocumentType.java index 6f81de17..a27315c1 100644 --- a/src/main/java/com/fowoco/server/worker/domain/DocumentType.java +++ b/src/main/java/com/fowoco/server/worker/domain/DocumentType.java @@ -4,5 +4,8 @@ public enum DocumentType { PASSPORT_COPY, ARC, CONTRACT, - PERMIT + PERMIT, + EMPLOYMENT_EXTENSION_APPLICATION, + INTEGRATED_APPLICATION, + RESIDENCE_PROOF } diff --git a/src/main/java/com/fowoco/server/worker/domain/SubmissionStatus.java b/src/main/java/com/fowoco/server/worker/domain/SubmissionStatus.java index cb1c13bb..957e0af2 100644 --- a/src/main/java/com/fowoco/server/worker/domain/SubmissionStatus.java +++ b/src/main/java/com/fowoco/server/worker/domain/SubmissionStatus.java @@ -1,6 +1,7 @@ package com.fowoco.server.worker.domain; public enum SubmissionStatus { + DRAFT, MISSING, SUBMITTED, VERIFIED; diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerRequestedAction.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerRequestedAction.java index bc7a519e..e403379a 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerRequestedAction.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerRequestedAction.java @@ -43,6 +43,9 @@ private static String documentLabel(DocumentType documentType) { case ARC -> "외국인등록증"; case CONTRACT -> "근로계약서"; case PERMIT -> "고용허가 관련 서류"; + case EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장신청서"; + case INTEGRATED_APPLICATION -> "통합신청서"; + case RESIDENCE_PROOF -> "체류지 입증자료"; }; } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1edddc96..8131912e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -144,6 +144,8 @@ app: admin-display-name: ${DEMO_SEED_ADMIN_DISPLAY_NAME:데모 관리자} admin-email: ${DEMO_SEED_ADMIN_EMAIL:demo.admin@example.com} admin-password: ${DEMO_SEED_ADMIN_PASSWORD:} + demo-document-data: + command: ${DEMO_DOCUMENT_DATA_COMMAND:none} auth: jwt: issuer: ${JWT_ISSUER:fowoco-server} diff --git a/src/main/resources/db/migration/V45__add_document_storage_metadata.sql b/src/main/resources/db/migration/V45__add_document_storage_metadata.sql new file mode 100644 index 00000000..4a1602cb --- /dev/null +++ b/src/main/resources/db/migration/V45__add_document_storage_metadata.sql @@ -0,0 +1,57 @@ +ALTER TABLE stored_file + ADD COLUMN checksum_sha256 VARCHAR(64); + +ALTER TABLE stored_file + ADD COLUMN updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE stored_file + ADD CONSTRAINT ck_stored_file_checksum_sha256 + CHECK ( + checksum_sha256 IS NULL + OR ( + CHAR_LENGTH(checksum_sha256) = 64 + AND checksum_sha256 = LOWER(checksum_sha256) + ) + ); + +ALTER TABLE worker_document + ADD COLUMN issue_date DATE; + +ALTER TABLE worker_document + ADD COLUMN source VARCHAR(30) NOT NULL DEFAULT 'LEGACY'; + +ALTER TABLE worker_document + ADD CONSTRAINT ck_worker_document_issue_expiry + CHECK (issue_date IS NULL OR expiry_date IS NULL OR expiry_date >= issue_date); + +ALTER TABLE worker_document + ADD CONSTRAINT ck_worker_document_source + CHECK (source IN ('LEGACY', 'DEMO_SEED', 'HR_UPLOAD', 'WORKER_UPLOAD', 'AI_GENERATED')); + +ALTER TABLE worker_document + DROP CONSTRAINT ck_worker_document_type; + +ALTER TABLE worker_document + ADD CONSTRAINT ck_worker_document_type + CHECK (document_type IN ( + 'PASSPORT_COPY', + 'ARC', + 'CONTRACT', + 'PERMIT', + 'EMPLOYMENT_EXTENSION_APPLICATION', + 'INTEGRATED_APPLICATION', + 'RESIDENCE_PROOF' + )); + +ALTER TABLE worker_document + DROP CONSTRAINT ck_worker_document_submission_status; + +ALTER TABLE worker_document + ADD CONSTRAINT ck_worker_document_submission_status + CHECK (submission_status IN ('DRAFT', 'MISSING', 'SUBMITTED', 'VERIFIED')); + +CREATE INDEX idx_worker_document_company_expiry + ON worker_document (company_id, expiry_date); + +CREATE INDEX idx_worker_document_company_source + ON worker_document (company_id, source); diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java new file mode 100644 index 00000000..5ff5093c --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java @@ -0,0 +1,98 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentDataService.DemoDocumentDataReport; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "app.demo-seed.enabled=true", + "app.demo-seed.admin-password=Demo-password-1!", + "app.demo-document-data.command=none", + "app.document.ocr.enabled=true", + "app.document.ocr.encryption-key-base64=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.document.ocr.key-version=test-v1" +}) +class DemoDocumentDataIntegrationTest { + + private static final Path FILE_STORAGE = Path.of( + System.getProperty("java.io.tmpdir"), + "fowoco-demo-documents-" + UUID.randomUUID() + ); + + @Autowired + private DemoDocumentDataService service; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @DynamicPropertySource + static void fileStorage(DynamicPropertyRegistry registry) { + registry.add("app.file-storage.local-path", FILE_STORAGE::toString); + } + + @Test + void importsVerifiesCleansAndReimportsWithoutDuplicates() { + assertReport(service.importData()); + assertReport(service.verifyData()); + assertCounts(16, 15, 1); + + assertReport(service.importData()); + assertCounts(16, 15, 1); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ? AND checksum_sha256 IS NOT NULL", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(15); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND source = 'DEMO_SEED' " + + "AND issue_date IS NOT NULL", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(15); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND submission_status = 'DRAFT'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(2); + + service.cleanupData(); + assertCounts(0, 0, 0); + + assertReport(service.importData()); + assertCounts(16, 15, 1); + } + + private void assertReport(DemoDocumentDataReport report) { + assertThat(report).isEqualTo(new DemoDocumentDataReport(16, 15, 4, 7, 1, 3, 4, 1)); + } + + private void assertCounts(int documents, int files, int ocrRuns) { + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND source = 'DEMO_SEED'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(documents); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ? AND purpose = 'DEMO_WORKER_DOCUMENT'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(files); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM document_ocr_run WHERE company_id = ? AND ocr_run_id = ?", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID, + DemoDocumentFixtureCatalog.OCR_RUN_ID + )).isEqualTo(ocrRuns); + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java new file mode 100644 index 00000000..a7b89297 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java @@ -0,0 +1,33 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.FixtureFormat; +import com.fowoco.server.file.application.validation.HwpSignatureValidator; +import com.fowoco.server.file.application.validation.HwpxSignatureValidator; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class SyntheticDocumentGeneratorTest { + + private final SyntheticDocumentGenerator generator = new SyntheticDocumentGenerator(); + + @Test + void generatesRealSignaturesForEverySupportedDemoFormat() { + assertThat(generate(FixtureFormat.PNG)).startsWith(0x89, 0x50, 0x4e, 0x47); + assertThat(generate(FixtureFormat.JPEG)).startsWith(0xff, 0xd8, 0xff); + assertThat(new String(generate(FixtureFormat.PDF), 0, 8, StandardCharsets.US_ASCII)) + .isEqualTo("%PDF-1.4"); + assertThat(new HwpSignatureValidator().isValidHwp(generate(FixtureFormat.HWP))).isTrue(); + assertThat(new HwpxSignatureValidator().isValidHwpx(generate(FixtureFormat.HWPX))).isTrue(); + } + + private byte[] generate(FixtureFormat format) { + DemoDocumentFixture template = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.format() == format) + .findFirst() + .orElseThrow(); + return generator.generate(template); + } +}