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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ DB_PASSWORD=
# React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다.
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

# Workflow Catalog는 fowoco/knowledge release로부터 만든 Server용 read-only projection입니다.
# local/test는 저장소의 개발용 DRAFT projection을 사용합니다.
# prod에서는 RELEASED projection 파일 위치를 반드시 지정해야 합니다.
# WORKFLOW_CATALOG_LOCATION=file:/app/config/catalog-projection.json
# local/dev에서 DRAFT projection 실험이 필요할 때만 true로 둡니다. prod는 항상 false입니다.
WORKFLOW_CATALOG_ALLOW_UNRELEASED=true

# Access Token 서명 키입니다. dev/prod에서는 반드시 32바이트 이상의 난수를 Base64로 넣습니다.
# 생성 예시: openssl rand -base64 32
# local 프로필은 로컬 전용 기본 키를 사용합니다. dev/prod로 바꿀 때 아래 줄을 활성화하세요.
Expand Down
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,16 @@ docs/23-architecture-adr
### Commit과 PR 작성 규칙

- Commit message는 Conventional Commits 형식을 사용합니다.
- type과 scope는 영문으로 쓰고, commit subject도 간결한 영문으로 작성합니다.
- type과 scope는 영문으로 쓰고, commit subject와 필요한 설명은 팀이 읽기 쉬운 한국어로 작성합니다.
- class·method·API·JWT·Workflow 같은 코드 식별자와 일반 기술 용어는 억지로 번역하지 않습니다.
- PR 제목은 한국어로 핵심을 설명합니다. 코드 식별자와 일반적인 기술 용어는 영어를 그대로 사용해도 됩니다.
- PR 본문은 변경 이유와 영향, 검증 결과가 명확하게 전달되는 것을 우선합니다. 코드 식별자와 기술 용어는 억지로 번역하지 않습니다.

```text
feat(auth): implement access token refresh
fix(worker-link): validate token expiration
chore: configure server development environment
docs: update local setup guide
feat(auth): Access Token 재발급 구현
fix(worker-link): token 만료 검증 보완
chore: 서버 개발환경 구성
docs: 로컬 실행 가이드 갱신

PR title: feat: 인증 API와 Refresh Token rotation 구현
```
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신
| 항목 | 현재 |
| --- | --- |
| 기술 | Java 17, Spring Boot 4.1.0, Gradle |
| 구현 API | Health·Auth 5개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 |
| 구현 API | Health, Auth 5개, Task Workflow 7개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 |
| 계획 API | Wiki API 카탈로그와 관련 Issue에서 설계·추적 |
| 로컬 DB | H2 + Flyway Auth·Company·Worker core·Task core·Approval·Audit schema |
| 개발·배포 DB | PostgreSQL + Flyway |
| 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext |
| 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 |
| AI·Workflow | Task 상태 전이 core와 승인·감사 구현, Task CRUD·AI 연동은 후속 Issue |
| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사 구현. AiRun 연동은 후속 Issue |

계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다.

Expand Down Expand Up @@ -70,6 +70,30 @@ local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습

로그아웃은 새 Access Token 발급 수단을 폐기하지만 이미 발급된 stateless JWT를 즉시 삭제하지는 못합니다. 현재 기본 설정에서는 기존 Access Token이 만료까지 최대 15분간 유효하므로 Client는 로그아웃 응답을 받는 즉시 메모리나 상태 저장소의 Access Token을 삭제해야 합니다.

### 업무카드·체크리스트 흐름

Task API는 `ADMIN`과 `HR`이 업무카드를 만들고 수정하게 하며, `VIEWER`는 같은 사업장의 업무만 조회할 수 있습니다.

```text
GET /api/v1/workflow-catalogs
POST /api/v1/tasks
GET /api/v1/tasks
GET /api/v1/tasks/{taskId}
PATCH /api/v1/tasks/{taskId}
PATCH /api/v1/tasks/{taskId}/checklist-items/{itemId}
POST /api/v1/tasks/{taskId}/cancel
```

1. Server는 `fowoco/knowledge`가 소유한 Workflow release를 read-only projection으로 읽습니다.
2. Task를 만들 때 `workflow_id`와 `workflow_catalog_version`을 함께 고정합니다.
3. Workflow의 필수 slot이 부족하면 `NEEDS_INFO`, 충분하면 `DRAFT`로 생성합니다.
4. Checklist template은 Task별 항목으로 복사되며 Client가 임의 항목을 추가하거나 필수 여부를 바꾸지 못합니다.
5. 수정·체크·취소 요청은 응답에 있는 최신 `version`을 `expected_version`으로 보내야 합니다. 오래된 화면의 값이면 `409 CONCURRENT_MODIFICATION`입니다.
6. 승인된 날짜·금액·설명 같은 중요값을 바꾸면 기존 승인을 무효화합니다. 필수정보와 checklist가 충분하면 수정본 승인 snapshot을 새로 만들고 `READY_FOR_REVIEW`, 부족하면 `NEEDS_INFO`가 됩니다.
7. `status`와 `company_id`는 쓰기 요청으로 받지 않습니다. 상태는 명시적인 Server command가, 사업장은 JWT의 ActorContext가 결정합니다.

로컬·테스트에서는 저장소의 `catalog-projection.local.json`으로 개발할 수 있습니다. 이 파일은 Knowledge `0.2.0 DRAFT`의 개발용 projection이며 원본 Catalog가 아닙니다. 운영 `prod` Profile은 `WORKFLOW_CATALOG_LOCATION`에 배포된 `RELEASED` projection을 반드시 지정해야 하고 DRAFT bundle이면 서버 시작을 거부합니다.

### 승인·감사 흐름

승인 API는 `ADMIN` 또는 `HR` 역할만 변경할 수 있고, 조회용 업무 활동은 `VIEWER`도 볼 수 있습니다. 사업장 전체 감사 검색은 `ADMIN`만 가능합니다.
Expand Down Expand Up @@ -122,6 +146,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값
| --- | --- | --- |
| Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` |
| Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` |
| Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` |
| Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` |
| Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` |
| 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` |
Expand Down Expand Up @@ -240,6 +265,8 @@ server/
│ │ └── reliability/ # Outbox와 event 복구
│ └── resources/
│ ├── application.yaml
│ ├── workflow/
│ │ └── catalog-projection.local.json # 개발용 Knowledge projection
│ └── db/migration/
│ ├── V1__baseline.sql
│ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token
Expand Down Expand Up @@ -270,6 +297,8 @@ server/
- 다른 기능의 `infrastructure`와 JPA Entity를 직접 import하지 않습니다.
- `task` 이외의 기능은 Task 상태를 직접 변경하지 않습니다.
- `aiintegration`은 AI Runtime 연결만 담당하며 Prompt와 Provider SDK는 `ai` 저장소에 둡니다.
- `workflow`은 Knowledge가 배포한 projection을 읽을 뿐 원본 Workflow 정의를 수정하지 않습니다.
- `worker`의 `WorkerTaskContextReader`는 #6이 Worker API·도메인을 대신 구현하지 않고 Task 판단에 필요한 최소 상태·날짜만 읽는 내부 경계입니다.
- 최상위 `package-info.java`는 기능 경계와 책임을 Git에 남기기 위한 뼈대입니다. 빈 하위 패키지는 미리 만들지 않고 실제 코드가 추가될 때 생성합니다.
- Flyway migration은 적용 후 수정할 수 없으므로 `V2`와 `V3` 빈 파일을 미리 만들지 않습니다. 각각 #4와 #5의 실제 스키마와 함께 추가합니다.
- 테스트 패키지는 구현 패키지를 따라가고, `architecture`에는 향후 ArchUnit 또는 Spring Modulith 경계 검증을 둡니다.
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.common.web.RequestMetadata;
import com.fowoco.server.task.domain.Task;
import java.time.Instant;
import java.util.UUID;

Expand All @@ -21,4 +22,12 @@ void invalidateForCriticalChange(
Instant occurredAt,
RequestMetadata metadata
);

Task replaceReviewAfterCriticalChange(
UUID taskId,
ActorContext actorContext,
String reason,
Instant occurredAt,
RequestMetadata metadata
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -355,14 +357,12 @@ public void invalidateForCriticalChange(
) {
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
List<ApprovalRequest> active = approvalRepository.findActiveByTaskIdAndCompanyId(
List<ApprovalRequest> active = invalidateActiveApprovals(
taskId,
actor.companyId()
actor.companyId(),
reason,
occurredAt
);
active.forEach(approval -> {
approval.invalidate(reason, occurredAt);
approvalRepository.save(approval);
});
if (!active.isEmpty()) {
appendAudit(
task,
Expand All @@ -375,6 +375,108 @@ public void invalidateForCriticalChange(
}
}

@Override
@Transactional
public Task replaceReviewAfterCriticalChange(
UUID taskId,
ActorContext actor,
String reason,
Instant occurredAt,
RequestMetadata metadata
) {
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
List<ApprovalRequest> invalidated = invalidateActiveApprovals(
taskId,
actor.companyId(),
reason,
occurredAt
);
if (!invalidated.isEmpty()) {
appendAudit(
task,
actor,
AuditAction.APPROVAL_INVALIDATED,
"중요 업무값 변경으로 기존 승인을 무효화함",
metadata,
occurredAt
);
}

TaskStatus previous = task.requestReview(
taskReadinessChecker.isReadyForReview(task),
task.version(),
actor.actorId(),
occurredAt
);
Task savedTask = taskRepository.save(task);
recordTransition(
savedTask,
previous,
actor.actorId(),
"중요값 수정 후 재승인 요청",
metadata,
occurredAt
);

Map<String, Object> hrSnapshot = new LinkedHashMap<>();
hrSnapshot.put("worker_id", savedTask.workerId().toString());
hrSnapshot.put("task_type", savedTask.taskType().name());
hrSnapshot.put("workflow_id", savedTask.workflowId());
hrSnapshot.put("title", savedTask.title());
hrSnapshot.put("description", savedTask.description());
hrSnapshot.put(
"due_date",
savedTask.dueDate() == null ? null : savedTask.dueDate().toString()
);
hrSnapshot.put("business_data_json", savedTask.businessDataJson());
ApprovalRequest replacement = ApprovalRequest.create(
uuidGenerator.generate(),
taskId,
actor.companyId(),
savedTask.version(),
savedTask.contentRevision(),
savedTask.criticalFingerprint(),
null,
safeJsonService.write(hrSnapshot, true),
safeJsonService.write(List.of("task_content"), true),
safeJsonService.write(
Map.of(
"workflow_catalog_version",
savedTask.workflowCatalogVersion()
),
true
),
actor.actorId(),
occurredAt
);
approvalRepository.save(replacement);
appendAudit(
savedTask,
actor,
AuditAction.APPROVAL_REQUESTED,
"수정된 현재 Task version의 재승인을 요청함",
metadata,
occurredAt
);
return savedTask;
}

private List<ApprovalRequest> invalidateActiveApprovals(
UUID taskId,
UUID companyId,
String reason,
Instant occurredAt
) {
List<ApprovalRequest> active =
approvalRepository.findActiveByTaskIdAndCompanyId(taskId, companyId);
active.forEach(approval -> {
approval.invalidate(reason, occurredAt);
approvalRepository.save(approval);
});
return active;
}

private Task requireTask(UUID taskId, UUID companyId) {
return taskRepository.findByIdAndCompanyId(taskId, companyId)
.orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,23 @@
public class SafeJsonService {

private static final Set<String> FORBIDDEN_KEY_PARTS = Set.of(
"passport",
"registration",
"resident",
"passportnumber",
"passportno",
"alienregistrationnumber",
"registrationnumber",
"residentnumber",
"rrn",
"phone",
"account",
"accountnumber",
"bankaccount",
"token",
"password",
"secret",
"authorization",
"prompt",
"여권",
"외국인등록",
"주민등록",
"여권번호",
"외국인등록번호",
"주민등록번호",
"전화",
"계좌",
"비밀번호"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public enum AuditAction {
TASK_CREATED,
TASK_UPDATED,
CHECKLIST_ITEM_UPDATED,
TASK_CANCELLED,
APPROVAL_REQUESTED,
TASK_APPROVED,
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/fowoco/server/task/api/CancelTaskRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.fowoco.server.task.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.application.CancelTaskCommand;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CancelTaskRequest(
@NotNull @Min(0) Long expectedVersion,
@NotBlank @Size(max = 500) String reason
) {
CancelTaskCommand toCommand() {
return new CancelTaskCommand(expectedVersion, reason);
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.fowoco.server.task.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.application.CreateTaskCommand;
import com.fowoco.server.task.domain.TaskType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.Map;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CreateTaskRequest(
@NotNull UUID workerId,
UUID caseId,
@NotNull TaskType taskType,
@NotBlank @Size(max = 100) String workflowId,
@NotBlank @Size(max = 160) String title,
@Size(max = 2000) String description,
LocalDate dueDate,
Map<String, Object> businessData
) {
CreateTaskCommand toCommand() {
return new CreateTaskCommand(
workerId,
caseId,
taskType,
workflowId,
title,
description,
dueDate,
businessData
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.fowoco.server.task.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.domain.TaskChecklistItem;
import java.time.Instant;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record TaskChecklistItemResponse(
UUID checklistItemId,
String itemCode,
String label,
boolean required,
boolean completed,
UUID completedBy,
Instant completedAt,
long version
) {
static TaskChecklistItemResponse from(TaskChecklistItem item) {
return new TaskChecklistItemResponse(
item.checklistItemId(),
item.itemCode(),
item.label(),
item.required(),
item.completed(),
item.completedBy(),
item.completedAt(),
item.version()
);
}
}
Loading