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
92 changes: 88 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카
FOWOCO Server는 세 명의 백엔드 개발자가 기능 경계를 나누되, 공통 계약과 PR을
서로 검토하며 만든 **modular monolith**입니다. 인증부터 근로자·문서, 업무카드,
AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 PostgreSQL 기반 업무
흐름으로 연결합니다.
흐름으로 연결합니다. 핵심은 API 개수가 아니라 **AI의 제안을 HR이 검토하고,
근로자 응답을 다시 공식 업무와 다음 행동으로 회수하는 전체 흐름**입니다.

| 구분 | 현재 상태 |
| --- | --- |
| 핵심 업무 API | Auth·Worker·Document·Task·Approval·Worker Link·Case·Dashboard·Notification 구현 |
| AI 연동 | PLAN에서 대표 Intent·Workflow를 한 번 결정하고, 허용 Slot을 보충한 뒤 같은 결정을 ANALYZE에 재사용하는 AiRun·SSE 흐름 구현 |
| 문서 처리 | 파일 저장·다운로드, HWP/HWPX 검증·생성 결과 연계, OCR 실행·HR 검토 구현 |
| 운영 기반 | Flyway, PostgreSQL 16, RLS, Transactional Outbox, 감사로그, Docker·Kubernetes·HTTPS 배포와 제품 E2E 검증 |
| 근로자 협업 | 만료형 보안 링크 발급, 모바일 안내·응답·서류 제출, HR 공식 서류 채택과 Task 재개 구현 |
| 알림 | 업무 Domain Event와 Outbox를 이용한 알림 생성, 읽음 상태, 마감 임박 배치 구현 |
| 운영 기반 | Flyway, PostgreSQL 16, RLS, Transactional Outbox, 감사로그, Micrometer·Prometheus, Docker·Kubernetes·HTTPS 배포와 제품 E2E 검증 |

## 왜 이 기술과 구조를 선택했는가

Expand All @@ -41,6 +44,8 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos
| Server–AI HTTP 경계와 allow-list Resolver | AI가 업무 DB를 직접 조회하면 권한 우회와 개인정보 과다 전달 위험이 있음 | AI Runtime의 DB 직접 접근을 허용하지 않고, PLAN에서 요청한 허용 field만 Server가 tenant 범위로 조회해 ANALYZE에 보충하도록 설계했습니다. PLAN의 대표 Intent·Workflow를 저장·재사용해 같은 발화문을 두 번 분류할 때 생길 수 있는 결과 불일치와 지연도 줄였습니다. 판단·권한·영속 상태는 Server가 소유합니다. |
| REST + AiRun + SSE | AI 실행은 오래 걸리지만 Client가 보내야 하는 실시간 메시지는 없음 | 요청과 결과는 재조회 가능한 AiRun resource로 저장하고, 단방향 상태 알림은 WebSocket보다 단순한 SSE를 사용했습니다. 연결이 끊겨도 DB 상태를 다시 조회할 수 있게 했습니다. |
| PostgreSQL Transactional Outbox | DB 변경 성공 후 알림·OCR 같은 후속 실행이 실패하면 업무가 유실될 수 있음 | 별도 Broker를 먼저 운영하는 대신 업무 변경과 Event를 같은 DB 트랜잭션에 저장했습니다. lease·backoff·멱등성·수동 재처리로 장애 후에도 처리 상태가 수렴하도록 했습니다. |
| Domain Event 알림 + 예약 배치 | 승인·근로자 제출처럼 즉시 알려야 하는 사건과 마감 임박처럼 시간 기준으로 찾는 사건이 함께 존재함 | 업무 코드가 알림 테이블을 직접 조작하지 않고 Domain Event를 발행하게 했습니다. Outbox 소비 이력으로 중복 생성을 막고, 마감 임박 알림만 회사별 tenant context를 적용한 예약 배치로 분리했습니다. |
| Micrometer + Prometheus | 데모에서도 AI가 느린지, DB 보충이 느린지, 어느 단계가 실패했는지 수치로 설명해야 함 | PLAN·Slot 조회·ANALYZE·결과 저장·Renewal 단계를 Timer와 Counter로 분리했습니다. 원문과 개인정보를 label에 넣지 않으면서 p50·p95·Outcome·오류 코드·Outbox backlog를 조회할 수 있게 했습니다. |

### Backend Team

Expand All @@ -63,9 +68,9 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos
- CSV/XLSX 근로자 명단 가져오기와 OCR 검토
- 업무카드·체크리스트·상태 전이 관리
- HR 승인·반려·외부 제출·증빙·완료와 감사로그
- 만료되는 근로자 보안 링크
- 만료되는 근로자 보안 링크, 모바일 응답·서류 제출과 HR 공식 서류 채택
- AI Runtime PLAN·ANALYZE 요청, Slot 보충, 응답 검증과 영속 실행 이력
- Today Dashboard·알림·사업장 설정 조회
- Today Dashboard·이벤트 기반 알림·읽음 상태·사업장 설정 조회
- 실패해도 유실되지 않는 후속 이벤트 처리

Provider SDK, Prompt와 모델 라우팅은 Server에 구현하지 않습니다.
Expand All @@ -90,6 +95,10 @@ HR 로그인
→ 업무카드 후보 검토·확정
→ 필요정보와 문서 초안 확인
→ HR 승인
→ 근로자 보안 링크 발급·모바일 안내
→ 근로자 응답·서류 제출
→ HR 제출자료 확인·공식 서류 채택
→ Task 재개
→ 외부 제출·처리결과 기록
→ 완료·감사로그
```
Expand Down Expand Up @@ -121,6 +130,80 @@ ANALYZE에는 `plannedIntent`와 `plannedWorkflowId`를 전달합니다. 확률
않습니다. 상세 요청·응답과 검증 기준은
[AI Runtime 계약](docs/ai-runtime-contract.md)을 확인합니다.

### 근로자 모바일 응답이 업무로 돌아오는 흐름

```text
HR이 승인된 Task의 Worker Link 발급
→ Task: APPROVED → WAITING_WORKER
→ 근로자가 로그인 없이 안내 확인·질문·파일 제출
→ Server가 WorkerResponse와 StoredFile로 보관
→ HR이 제출 파일의 이름·형식·크기·서류 유형 확인
→ HR이 채택한 파일만 WorkerDocument(SUBMITTED)로 등록
→ 요청 서류가 모두 채택되면 Task: WAITING_WORKER → APPROVED
→ 다음 제출·승인 단계 진행
```

근로자의 제출만으로 개인정보와 공식 서류 상태를 자동 확정하지 않습니다.
`SUBMITTED`와 `VERIFIED`를 분리하고, HR이 확인한 파일만 기존 업무와 연결합니다.
같은 파일을 다시 채택해도 문서가 중복 생성되지 않으며, 링크 발급·상태 전이·채택은
감사로그와 Task 전이 이력으로 추적합니다.

### 업무 이벤트가 알림으로 이어지는 흐름

```text
Task·Approval·Worker Link 업무 변경
→ Domain Event 발행
→ 같은 DB 트랜잭션의 Outbox에 저장
→ Event handler가 사용자 알림 생성
→ Client가 알림 목록·미읽음 수 조회
→ 사용자가 읽음 처리
```

- Agent 후보 채택 후 생성된 업무
- 승인 요청 도착
- 근로자 문서 제출 완료
- 체크리스트 변경으로 발생한 문서 보완 필요
- 7일 이내 마감 임박 업무

즉시 발생하는 네 가지 알림은 Event로 처리하고, 현재 시각을 기준으로 찾아야 하는
마감 임박 알림은 매일 실행되는 배치로 처리합니다. Event 재처리 시에는 기존
`EventConsumption` 기록을 확인해 같은 알림이 중복 생성되지 않게 합니다.

## 운영 가시성과 정량 검증

AI 기능은 성공 여부만 기록하지 않고, Server가 소유한 각 단계의 시간과 결과를
Micrometer metric과 구조화 로그로 남깁니다.

```text
PLAN_RUNTIME_CALL
→ PLAN RESULT_PERSIST
→ SLOT_RESOLUTION
→ ANALYZE_RUNTIME_CALL
→ ANALYZE RESULT_PERSIST
→ PIPELINE TOTAL
```

2026-08-12 로컬 환경에서 실제 Hugging Face BERT Intent 모델과 Demo Seed를 연결해
대표 입력 `응웬반A 체류연장 준비해줘`를 10회 반복 측정했습니다.

| 측정 항목 | 결과 |
| --- | --- |
| PLAN → Slot 보충 → ANALYZE 성공률 | 10/10 · 100% |
| Server 내부 Pipeline | p50 30.8ms · p95 58.7ms |
| 비동기 `202 Accepted` 응답 | 중앙값 40.0ms · p95 51.4ms |
| Client polling 포함 최종 상태 확인 | 중앙값 77.5ms · p95 114.2ms |
| Prometheus 단계 측정 | 10회 × 6단계 = 60건 |
| 의도적 대상 오류 | `TARGET_NOT_FOUND` · `SLOT_RESOLUTION`로 식별 |

PLAN·ANALYZE Runtime 호출이 전체 평균 시간의 약 74%를 차지해, 병목이 Server DB보다
모델 호출 구간에 있다는 점도 확인했습니다. Metric에는 발화문·실명·전화번호를
label로 넣지 않으며, `phase`, `stage`, `outcome`, 제한된 `failure_code`만 사용합니다.

이 값은 로컬 단일 사용자·모델 사전 로딩 이후의 개발 기준선이며 운영 SLA나 HR
업무시간 절감률이 아닙니다. 배포 환경에서는 GPU, A.X routing, cold start, OCR과
HWP 생성까지 포함해 다시 측정합니다. 실행 방법과 PromQL은
[AI 파이프라인 관측·Prometheus 가이드](docs/ai-pipeline-observability.md)를 확인합니다.

## 아키텍처

Server는 하나의 Spring Boot 애플리케이션과 PostgreSQL로 배포하는
Expand Down Expand Up @@ -150,6 +233,7 @@ src/main/java/com/fowoco/server/
데모는 합성 데이터만 사용하며 허용된 문서 업무값을 `***`로 치환하지 않습니다.
- JWT·API Key·비밀번호·Worker Link token 같은 인증정보는 AI 요청에서 항상 차단합니다.
- OCR은 HR이 선택한 서류 파일만 전용 내부 API로 전송합니다. 실행은 Outbox로 복구하고, 원본 추출값과 HR 수정값을 분리해 암호화 저장하며 일반 로그에는 값 대신 수정한 필드명만 남깁니다.
- 근로자가 제출한 파일은 HR 채택 전 공식 서류나 검증 완료 자료로 간주하지 않습니다.
- AI 결과와 요청 초안은 HR 승인 전 자동 발송하지 않습니다.
- 중요한 변경은 actor, 시각, `request_id`와 함께 감사로그에 남깁니다.
- Worker Link 원본 token, JWT, API Key와 비밀번호를 GitHub·로그·문서에 남기지 않습니다.
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/fowoco/server/task/domain/Task.java
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ public TaskStatus reject(long expectedVersion, UUID actorId, Instant now) {
return transition(TaskStatus.DRAFT, actorId, now);
}

public TaskStatus waitForWorker(long expectedVersion, UUID actorId, Instant now) {
requireVersion(expectedVersion);
requireStatus(TaskStatus.APPROVED);
return transition(TaskStatus.WAITING_WORKER, actorId, now);
}

public TaskStatus recordExternalSubmission(long expectedVersion, UUID actorId, Instant now) {
requireVersion(expectedVersion);
if (status != TaskStatus.APPROVED && status != TaskStatus.WAITING_WORKER) {
Expand All @@ -245,6 +251,12 @@ public TaskStatus recordExternalSubmission(long expectedVersion, UUID actorId, I
return transition(TaskStatus.WAITING_EXTERNAL, actorId, now);
}

public TaskStatus resumeAfterWorkerSubmission(long expectedVersion, UUID actorId, Instant now) {
requireVersion(expectedVersion);
requireStatus(TaskStatus.WAITING_WORKER);
return transition(TaskStatus.APPROVED, actorId, now);
}

public TaskStatus complete(
boolean currentVersionApproved,
boolean requiredEvidencePresent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.fowoco.server.worker.application.port;

import com.fowoco.server.worker.domain.WorkerDocument;
import java.util.Optional;
import java.util.UUID;

public interface WorkerDocumentFileLookup {

Optional<WorkerDocument> findByFileIdAndCompanyId(UUID fileId, UUID companyId);
}
29 changes: 29 additions & 0 deletions src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ public static WorkerDocument create(
);
}

public static WorkerDocument createSubmittedWithFile(
UUID workerDocumentId,
UUID workerId,
UUID companyId,
UUID taskId,
DocumentType documentType,
String note,
UUID fileId,
Instant now
) {
Objects.requireNonNull(fileId, "fileId must not be null");
Objects.requireNonNull(now, "now must not be null");
return new WorkerDocument(
workerDocumentId,
workerId,
companyId,
taskId,
documentType,
SubmissionStatus.SUBMITTED,
null,
null,
note,
fileId,
now,
now,
0L
);
}

public UUID workerDocumentId() {
return workerDocumentId;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.fowoco.server.worker.infrastructure.persistence;

import com.fowoco.server.worker.application.WorkerDocumentSearchQuery;
import com.fowoco.server.worker.application.port.WorkerDocumentFileLookup;
import com.fowoco.server.worker.application.port.WorkerDocumentRepository;
import com.fowoco.server.worker.domain.WorkerDocument;
import jakarta.persistence.EntityManager;
Expand All @@ -13,7 +14,7 @@
import org.springframework.stereotype.Repository;

@Repository
public class JpaWorkerDocumentRepository implements WorkerDocumentRepository {
public class JpaWorkerDocumentRepository implements WorkerDocumentRepository, WorkerDocumentFileLookup {

private final EntityManager entityManager;

Expand Down Expand Up @@ -75,6 +76,26 @@ public Optional<WorkerDocument> findByIdAndCompanyId(UUID workerDocumentId, UUID
.map(WorkerDocumentJpaEntity::toDomain);
}

@Override
public Optional<WorkerDocument> findByFileIdAndCompanyId(UUID fileId, UUID companyId) {
Objects.requireNonNull(fileId, "fileId must not be null");
Objects.requireNonNull(companyId, "companyId must not be null");
return entityManager.createQuery(
"""
select document
from WorkerDocumentJpaEntity document
where document.fileId = :fileId
and document.companyId = :companyId
""",
WorkerDocumentJpaEntity.class
)
.setParameter("fileId", fileId)
.setParameter("companyId", companyId)
.getResultStream()
.findFirst()
.map(WorkerDocumentJpaEntity::toDomain);
}

@Override
public WorkerDocument update(WorkerDocument document) {
Objects.requireNonNull(document, "document must not be null");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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.workerlink.application.WorkerLinkIssueCommand;
import com.fowoco.server.workerlink.application.WorkerLinkIssueResult;
import com.fowoco.server.workerlink.application.WorkerLinkService;
Expand All @@ -14,6 +15,7 @@
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 java.util.UUID;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -74,8 +76,8 @@ public WorkerLinkController(
public ResponseEntity<WorkerLinkIssueResponse> issue(
@Parameter(description = "업무 ID") @PathVariable UUID taskId,
@Valid @RequestBody WorkerLinkIssueRequest request,
//: 나중에 확인 필요
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
HttpServletRequest servletRequest
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
WorkerLinkIssueCommand command = new WorkerLinkIssueCommand(
Expand All @@ -84,7 +86,11 @@ public ResponseEntity<WorkerLinkIssueResponse> issue(
request.isRotateExisting(),
idempotencyKey
);
WorkerLinkIssueResult result = workerLinkService.issue(command, actor);
WorkerLinkIssueResult result = workerLinkService.issue(
command,
actor,
RequestMetadata.from(servletRequest)
);
String workerUrl = result.rawToken() == null
? null
: workerPortalUrlFactory.create(result.rawToken()).toString();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.fowoco.server.workerlink.api;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

@Schema(
name = "WorkerResponseDocumentAdoptionRequest",
description = "근로자가 제출한 파일을 공식 근로자 서류로 채택하는 요청"
)
public final class WorkerResponseDocumentAdoptionRequest {

@NotNull(message = "expected_task_version을 입력해 주세요.")
@Schema(
name = "expected_task_version",
description = "마지막으로 조회한 Task version",
requiredMode = Schema.RequiredMode.REQUIRED
)
private final Long expectedTaskVersion;

@JsonCreator
public WorkerResponseDocumentAdoptionRequest(
@JsonProperty("expected_task_version") Long expectedTaskVersion
) {
this.expectedTaskVersion = expectedTaskVersion;
}

public long getExpectedTaskVersion() {
return expectedTaskVersion;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.fowoco.server.workerlink.api;

import com.fowoco.server.task.domain.TaskStatus;
import com.fowoco.server.worker.domain.DocumentType;
import com.fowoco.server.workerlink.application.WorkerResponseDocumentAdoptionResult;
import java.util.List;
import java.util.UUID;

public record WorkerResponseDocumentAdoptionResponse(
UUID responseId,
List<AdoptedDocumentResponse> adoptedDocuments,
TaskStatus taskStatus,
long taskVersion
) {
static WorkerResponseDocumentAdoptionResponse from(WorkerResponseDocumentAdoptionResult result) {
return new WorkerResponseDocumentAdoptionResponse(
result.responseId(),
result.adoptedDocuments().stream().map(AdoptedDocumentResponse::from).toList(),
result.taskStatus(),
result.taskVersion()
);
}

public record AdoptedDocumentResponse(
UUID workerDocumentId,
UUID fileId,
DocumentType documentType
) {
static AdoptedDocumentResponse from(
WorkerResponseDocumentAdoptionResult.AdoptedDocument document
) {
return new AdoptedDocumentResponse(
document.workerDocumentId(),
document.fileId(),
document.documentType()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record WorkerResponseItemResponse(
WorkerResponseType responseType,
String message,
List<UUID> uploadIds,
List<WorkerResponseUploadItemResponse> uploads,
ConversationStatus conversationStatus,
boolean unread,
Instant receivedAt
Expand All @@ -22,6 +23,7 @@ static WorkerResponseItemResponse from(WorkerResponseQueryResult result) {
result.responseType(),
result.message(),
result.uploadIds(),
result.uploads().stream().map(WorkerResponseUploadItemResponse::from).toList(),
result.conversationStatus(),
result.unread(),
result.receivedAt()
Expand Down
Loading
Loading