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
30 changes: 25 additions & 5 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 | `GET /health`, `POST /api/v1/auth/login`, `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me` |
| 구현 API | Health·Auth 5개, Approval·Audit 8개. 전체 계약은 실행 중인 Swagger에서 확인 |
| 계획 API | Wiki API 카탈로그와 관련 Issue에서 설계·추적 |
| 로컬 DB | H2 + Flyway Auth·Company schema |
| 로컬 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 | 후속 Issue에서 구현 예정 |
| AI·Workflow | Task 상태 전이 core와 승인·감사 구현, Task CRUD·AI 연동은 후속 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,24 @@ local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습

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

### 승인·감사 흐름

승인 API는 `ADMIN` 또는 `HR` 역할만 변경할 수 있고, 조회용 업무 활동은 `VIEWER`도 볼 수 있습니다. 사업장 전체 감사 검색은 `ADMIN`만 가능합니다.

```text
POST /api/v1/tasks/{taskId}/approval-requests
→ POST /api/v1/tasks/{taskId}/approve 또는 /reject
→ POST /api/v1/tasks/{taskId}/external-submissions (필요한 업무)
→ POST /api/v1/tasks/{taskId}/evidence
→ POST /api/v1/tasks/{taskId}/complete
```

- 승인 요청은 AI 원본, HR 최종본, 변경 필드, source version을 snapshot으로 보존합니다.
- 주민·외국인등록번호, 여권번호, 전화번호, 계좌번호, 토큰, 비밀번호, 전체 Prompt가 snapshot에 섞이면 요청 전체를 취소합니다.
- `task.version`은 동시에 수정한 요청의 충돌을 찾고, `content_revision + critical_fingerprint`는 현재 내용에 기존 승인을 재사용할 수 있는지 판단합니다.
- 상태 변경, 승인 기록, 감사 이벤트는 같은 DB transaction에 기록되므로 중간 하나가 실패하면 함께 되돌아갑니다.
- `GET /api/v1/tasks/{taskId}/activities`는 화면용 안전 타임라인이고, `GET /api/v1/audit-events`는 ADMIN용 필터·cursor 조회입니다. 내부 snapshot 원문은 두 API에 노출하지 않습니다.

### PostgreSQL 개발 Profile

```bash
Expand Down Expand Up @@ -224,8 +242,10 @@ server/
│ ├── application.yaml
│ └── db/migration/
│ ├── V1__baseline.sql
│ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token schema
│ └── V3__create_worker_document.sql # #5 구현 시 추가 예정
│ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token
│ ├── V3__create_worker_document.sql # Worker·Document metadata
│ ├── V4__create_task_workflow_core.sql # Task·Checklist·전이 이력
│ └── V5__create_approval_audit.sql # 승인·제출·증빙·감사
└── test/
└── java/com/fowoco/server/
├── architecture/
Expand Down
214 changes: 214 additions & 0 deletions src/main/java/com/fowoco/server/approval/api/ApprovalController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package com.fowoco.server.approval.api;

import com.fowoco.server.approval.application.ApprovalResult;
import com.fowoco.server.approval.application.ApprovalService;
import com.fowoco.server.approval.application.TaskActionResult;
import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.common.web.RequestMetadata;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Approval", description = "업무 승인·반려·제출·증빙·완료")
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequestMapping("/api/v1/tasks/{taskId}")
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public class ApprovalController {

private final ApprovalService approvalService;
private final ActorContextProvider actorContextProvider;

public ApprovalController(
ApprovalService approvalService,
ActorContextProvider actorContextProvider
) {
this.approvalService = approvalService;
this.actorContextProvider = actorContextProvider;
}

@Operation(
operationId = "requestTaskApproval",
summary = "업무 승인 요청",
description = "필수정보를 확인하고 AI 원본·HR 최종본·출처 버전을 snapshot으로 고정합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "승인 요청 생성"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/approval-requests",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<ApprovalResponse> requestApproval(
@PathVariable UUID taskId,
@Valid @RequestBody ApprovalRequestBody request,
HttpServletRequest servletRequest
) {
ApprovalResult result = approvalService.requestApproval(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
);
return ResponseEntity.status(201)
.body(ApprovalResponse.from(result));
}

@Operation(operationId = "approveTask", summary = "업무 승인")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "현재 Task version 승인"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/approve",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ApprovalResponse approve(
@PathVariable UUID taskId,
@Valid @RequestBody ApproveTaskRequest request,
HttpServletRequest servletRequest
) {
return ApprovalResponse.from(approvalService.approve(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
));
}

@Operation(
operationId = "rejectTask",
summary = "업무 반려",
description = "승인 요청을 종료하고 Task를 DRAFT로 되돌립니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "반려 완료"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/reject",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ApprovalResponse reject(
@PathVariable UUID taskId,
@Valid @RequestBody RejectTaskRequest request,
HttpServletRequest servletRequest
) {
return ApprovalResponse.from(approvalService.reject(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
));
}

@Operation(
operationId = "recordExternalSubmission",
summary = "외부기관 제출 기록",
description = "서버가 기관에 대신 제출하지 않고 HR이 수행한 제출 결과만 기록합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "외부 제출 기록 생성"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/external-submissions",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<TaskActionResponse> recordExternalSubmission(
@PathVariable UUID taskId,
@Valid @RequestBody ExternalSubmissionRequest request,
HttpServletRequest servletRequest
) {
TaskActionResult result = approvalService.recordExternalSubmission(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
);
return ResponseEntity.status(201)
.body(TaskActionResponse.from(result));
}

@Operation(operationId = "recordTaskEvidence", summary = "업무 증빙 기록")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "증빙 기록 생성"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/evidence",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<TaskActionResponse> recordEvidence(
@PathVariable UUID taskId,
@Valid @RequestBody EvidenceRequest request,
HttpServletRequest servletRequest
) {
TaskActionResult result = approvalService.recordEvidence(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
);
return ResponseEntity.status(201)
.body(TaskActionResponse.from(result));
}

@Operation(operationId = "completeTask", summary = "업무 완료")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "업무 완료"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity")
})
@PostMapping(
path = "/complete",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public TaskActionResponse complete(
@PathVariable UUID taskId,
@Valid @RequestBody CompleteTaskRequest request,
HttpServletRequest servletRequest
) {
return TaskActionResponse.from(approvalService.complete(
taskId,
request.toCommand(),
actor(),
RequestMetadata.from(servletRequest)
));
}

private ActorContext actor() {
return actorContextProvider.requireCurrentActor();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.fowoco.server.approval.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.approval.application.RequestApprovalCommand;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.Map;

public record ApprovalRequestBody(
@JsonProperty("expected_version") @PositiveOrZero long expectedVersion,
@JsonProperty("ai_snapshot") Map<String, Object> aiSnapshot,
@JsonProperty("hr_snapshot") @NotNull Map<String, Object> hrSnapshot,
@JsonProperty("changed_fields")
@NotNull @Size(max = 100) List<@Size(max = 120) String> changedFields,
@JsonProperty("source_versions") @NotNull Map<String, Object> sourceVersions
) {

public RequestApprovalCommand toCommand() {
return new RequestApprovalCommand(
expectedVersion,
aiSnapshot,
hrSnapshot,
changedFields,
sourceVersions
);
}
}
33 changes: 33 additions & 0 deletions src/main/java/com/fowoco/server/approval/api/ApprovalResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.fowoco.server.approval.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.approval.application.ApprovalResult;
import com.fowoco.server.approval.domain.ApprovalStatus;
import com.fowoco.server.task.domain.TaskStatus;
import java.time.Instant;
import java.util.UUID;

public record ApprovalResponse(
@JsonProperty("approval_request_id") UUID approvalRequestId,
@JsonProperty("task_id") UUID taskId,
@JsonProperty("approval_status") ApprovalStatus approvalStatus,
@JsonProperty("task_status") TaskStatus taskStatus,
@JsonProperty("content_revision") long contentRevision,
@JsonProperty("task_version") long taskVersion,
@JsonProperty("requested_at") Instant requestedAt,
@JsonProperty("decided_at") Instant decidedAt
) {

public static ApprovalResponse from(ApprovalResult result) {
return new ApprovalResponse(
result.approvalRequestId(),
result.taskId(),
result.approvalStatus(),
result.taskStatus(),
result.contentRevision(),
result.taskVersion(),
result.requestedAt(),
result.decidedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fowoco.server.approval.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.approval.application.DecideApprovalCommand;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;

public record ApproveTaskRequest(
@JsonProperty("expected_version") @PositiveOrZero long expectedVersion,
@Size(max = 500) String reason
) {

public DecideApprovalCommand toCommand() {
return new DecideApprovalCommand(expectedVersion, reason);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fowoco.server.approval.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.approval.application.CompleteTaskCommand;
import jakarta.validation.constraints.PositiveOrZero;

public record CompleteTaskRequest(
@JsonProperty("expected_version") @PositiveOrZero long expectedVersion
) {

public CompleteTaskCommand toCommand() {
return new CompleteTaskCommand(expectedVersion);
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/fowoco/server/approval/api/EvidenceRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.fowoco.server.approval.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.approval.application.RecordEvidenceCommand;
import com.fowoco.server.approval.domain.EvidenceType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.time.Instant;

public record EvidenceRequest(
@JsonProperty("evidence_type") @NotNull EvidenceType evidenceType,
@JsonProperty("file_reference") @Size(max = 300) String fileReference,
@Size(max = 500) String note,
@JsonProperty("recorded_at") @PastOrPresent Instant recordedAt
) {

public RecordEvidenceCommand toCommand() {
return new RecordEvidenceCommand(evidenceType, fileReference, note, recordedAt);
}
}
Loading