Skip to content
Open
212 changes: 125 additions & 87 deletions docs/demo-seed-fixture-manifest.md

Large diffs are not rendered by default.

370 changes: 191 additions & 179 deletions docs/demo-seed.md

Large diffs are not rendered by default.

27 changes: 22 additions & 5 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,14 @@ DB pool은 기본 최대 10개입니다. 클러스터 규모에 따라 `DB_MAX_P
```bash
export DEMO_DB_PASSWORD='local-demo-password'
export JWT_SECRET_BASE64="$(openssl rand -base64 32)"
export DEMO_SEED_ENABLED=true
export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값'
docker compose -f compose.demo.yml up --build
```

PostgreSQL Demo Seed는 #94 검증이 끝날 때까지 기본적으로 꺼져 있습니다. 실행 후
`POST /api/v1/auth/signup`으로 가상 사업장 계정을 만들거나, #94 완료 뒤에만
`DEMO_SEED_ENABLED=true`와 `DEMO_SEED_ADMIN_PASSWORD`를 추가합니다.
`DEMO_SEED_ENABLED`의 Compose 기본값은 안전하게 `false`입니다. 개인 Demo DB에서 Seed가
필요한 경우에만 위와 같이 활성화하고 12자 이상의 합성 비밀번호를 지정합니다. 첫 기동은
빈 PostgreSQL 17 DB에 Flyway와 전체 Demo Seed를 적용합니다.

확인:

Expand All @@ -88,8 +90,23 @@ curl --fail http://127.0.0.1:8080/actuator/health/readiness
curl --fail http://127.0.0.1:8080/health
```

종료 시 `docker compose -f compose.demo.yml down`을 사용합니다. DB 데이터를 지우려는 경우에만
영향을 확인한 뒤 별도로 volume 삭제를 결정합니다.
멱등성 Smoke는 서버를 중지하되 volume을 유지하고 같은 설정으로 다시 기동합니다.

```bash
docker compose -f compose.demo.yml stop server
docker compose -f compose.demo.yml up --build server
```

두 번째 기동도 성공하고 응웬반A Worker
`92000000-0000-0000-0000-000000000006`가 한 건 유지되며, 응웬반A의 Golden Flow
Case·Task는 0건이어야 합니다. WorkerDocument는 Task·StoredFile 연결이 없는
`PASSPORT_COPY/VERIFIED` 1건과 `ARC/MISSING` 1건만 유지되어야 합니다. 다른 Showcase
Seed의 수량과 고정 ID도 첫 기동과 같아야 합니다.

종료 시 `docker compose -f compose.demo.yml down`을 사용합니다. DB 데이터를 지우려는
경우에만 정확한 Compose project와 전용 volume인지 확인한 뒤 별도로 volume 삭제를
결정합니다. 구버전 Golden Flow 예약 ID 감지로 기동이 중단된 개인 Demo DB만 초기화
대상이며, Seed가 기존 데이터를 자동 삭제하거나 Flyway로 정리하지 않습니다.

## 배포 후 Smoke

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
Expand All @@ -25,6 +26,13 @@
@Component
public class AiRuntimeContractValidator {

private static final Set<String> SERVER_OWNED_DOCUMENT_FIELDS = Set.of(
"passport_copy_status",
"passport_copy_expiry_date",
"arc_status",
"arc_expiry_date"
);

private static final long MIN_DEADLINE_MS = 100;
private static final long MAX_DEADLINE_MS = 60_000;
private static final int MAX_WORKERS = 1;
Expand Down Expand Up @@ -335,6 +343,16 @@ private void validateCoreValues(WorkerContext worker, AiCandidate candidate) {
"AI Runtime changed a Server-owned core value."
);
}
for (String fieldKey : SERVER_OWNED_DOCUMENT_FIELDS) {
String returnedValue = candidate.extractedSlots().get(fieldKey);
if (returnedValue != null
&& !Objects.equals(worker.requestedFields().get(fieldKey), returnedValue)) {
reject(
AiRuntimeFailureCode.CORE_VALUE_MISMATCH,
"AI Runtime changed a Server-owned document value."
);
}
}
}

private void validateAllowedSlot(String slot, Set<String> allowedSlots) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.worker.application.WorkerAiContextSnapshot;
import com.fowoco.server.worker.application.port.WorkerAiContextReader;
import com.fowoco.server.worker.domain.DocumentType;
import com.fowoco.server.workflow.application.WorkflowCatalogService;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
Expand Down Expand Up @@ -144,10 +145,27 @@ private String resolveWorkerField(String fieldKey, WorkerAiContextSnapshot worke
case "worker_id" -> worker.workerId().toString();
case "stay_expiry_date" -> formatDate(worker.stayExpiryDate());
case "contract_end_date" -> formatDate(worker.contractEndDate());
case "passport_copy_status" -> documentStatus(worker, DocumentType.PASSPORT_COPY);
case "passport_copy_expiry_date" -> documentExpiryDate(
worker,
DocumentType.PASSPORT_COPY
);
case "arc_status" -> documentStatus(worker, DocumentType.ARC);
case "arc_expiry_date" -> documentExpiryDate(worker, DocumentType.ARC);
default -> null;
};
}

private String documentStatus(WorkerAiContextSnapshot worker, DocumentType documentType) {
var document = worker.documents().get(documentType);
return document == null ? null : document.submissionStatus().name();
}

private String documentExpiryDate(WorkerAiContextSnapshot worker, DocumentType documentType) {
var document = worker.documents().get(documentType);
return document == null ? null : formatDate(document.expiryDate());
}

private String formatDate(java.time.LocalDate date) {
return date == null ? null : date.toString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed;
import com.fowoco.server.task.domain.TaskStatus;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
Expand Down Expand Up @@ -67,8 +68,8 @@ INSERT INTO workflow_case (
DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION,
snapshot(tasks),
context.actorId(),
createdAt,
context.now()
Timestamp.from(createdAt),
Timestamp.from(context.now())
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ private static SubmissionStatus scenarioStatus(
) {
if (workerNumber == 6) {
return documentType == DocumentType.PASSPORT_COPY
? SubmissionStatus.MISSING
: SubmissionStatus.VERIFIED;
? SubmissionStatus.VERIFIED
: SubmissionStatus.MISSING;
}
return additionStatus(additionIndex);
}
Expand All @@ -155,8 +155,8 @@ private static Integer scenarioExpiryDays(
) {
if (workerNumber == 6) {
return switch (documentType) {
case PASSPORT_COPY -> null;
case ARC -> 365;
case PASSPORT_COPY -> 365;
case ARC -> null;
case CONTRACT -> 180;
case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed");
};
Expand All @@ -167,7 +167,7 @@ private static Integer scenarioExpiryDays(
private static String scenarioDestination(int workerNumber, DocumentType documentType) {
if (workerNumber == 6) {
return documentType == DocumentType.PASSPORT_COPY
? "근로자 문서 요청"
? "체류기간 연장"
: "재계약·연장 준비";
}
return destination(documentType);
Expand All @@ -180,8 +180,8 @@ private static String scenarioNote(
) {
if (workerNumber == 6) {
return switch (documentType) {
case PASSPORT_COPY -> "여권 사본 미보유 · 베트남어 요청 필요";
case ARC -> "외국인등록증 확인 완료";
case PASSPORT_COPY -> "검증된 유효 여권 사본";
case ARC -> "외국인등록증 사본 요청 필요";
case CONTRACT -> "현재 근로계약서 확인 완료";
case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed");
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.fowoco.server.demo.infrastructure.seed;

import java.util.List;
import java.util.Objects;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;

final class DemoGoldenFlowSeedStateGuard {

private static final String RESET_REQUIRED_MESSAGE =
"legacy Golden Flow demo seed rows detected; reset the personal demo database or "
+ "volume before starting with the Issue #94 seed contract";

private final JdbcTemplate jdbcTemplate;

DemoGoldenFlowSeedStateGuard(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null");
}

void verifyNoLegacyRows(DemoOperationalSeedContext context) {
int legacyRowCount = count(
"workflow_case",
"case_id",
context.companyId(),
List.of(DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_CASE_ID)
) + count(
"task",
"task_id",
context.companyId(),
DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_TASK_IDS.stream().toList()
) + count(
"worker_document",
"worker_document_id",
context.companyId(),
DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_DOCUMENT_IDS.stream().toList()
) + count(
"audit_event",
"audit_event_id",
context.companyId(),
DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_AUDIT_IDS.stream().toList()
);
if (legacyRowCount > 0) {
throw new IllegalStateException(RESET_REQUIRED_MESSAGE);
}
}

private int count(String table, String idColumn, UUID companyId, List<UUID> ids) {
String placeholders = String.join(", ", java.util.Collections.nCopies(ids.size(), "?"));
Object[] parameters = new Object[ids.size() + 1];
parameters[0] = companyId;
for (int index = 0; index < ids.size(); index++) {
parameters[index + 1] = ids.get(index);
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM " + table + " WHERE company_id = ? AND "
+ idColumn + " IN (" + placeholders + ")",
Integer.class,
parameters
);
return count == null ? 0 : count;
}
}
Loading
Loading