Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -76,3 +80,4 @@ services:

volumes:
fowoco-postgres-data:
fowoco-server-data:
98 changes: 98 additions & 0 deletions docs/demo-document-data.md
Original file line number Diff line number Diff line change
@@ -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='<local-secret>'
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에서만
실행한다.
4 changes: 4 additions & 0 deletions docs/demo-seed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)에서 확인한다.

Expand Down
53 changes: 53 additions & 0 deletions scripts/demo-data
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
);
}
}
}
}
Loading
Loading