From 66c3089816977c0feee99f5e7c10c34d7c8dc07f Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 01:30:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(workerlink):=20=EA=B7=BC=EB=A1=9C?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EC=B6=9C=20=EC=84=9C=EB=A5=98=20=EC=B1=84?= =?UTF-8?q?=ED=83=9D=EA=B3=BC=20=EC=97=85=EB=AC=B4=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 링크 발급 시 업무를 근로자 응답 대기로 전환하고, HR이 제출 파일을 확인한 뒤 공식 근로자 서류로 채택할 수 있도록 구현했습니다. 개인정보 원문은 자동으로 수정하지 않으며 검증 전 서류는 SUBMITTED 상태로 보관합니다. --- .../com/fowoco/server/task/domain/Task.java | 12 + .../port/WorkerDocumentFileLookup.java | 10 + .../server/worker/domain/WorkerDocument.java | 29 ++ .../JpaWorkerDocumentRepository.java | 23 +- .../workerlink/api/WorkerLinkController.java | 12 +- ...WorkerResponseDocumentAdoptionRequest.java | 32 ++ ...orkerResponseDocumentAdoptionResponse.java | 39 ++ .../api/WorkerResponseItemResponse.java | 2 + .../WorkerResponseManagementController.java | 42 +++ .../api/WorkerResponseUploadItemResponse.java | 25 ++ .../application/WorkerLinkService.java | 71 +++- .../WorkerResponseDocumentAdoptionResult.java | 24 ++ ...WorkerResponseDocumentAdoptionService.java | 341 ++++++++++++++++++ .../WorkerResponseManagementService.java | 34 ++ .../WorkerResponseQueryResult.java | 2 + .../WorkerResponseUploadResult.java | 14 + .../error/WorkerLinkErrorCode.java | 13 + .../port/WorkerResponseRepository.java | 6 + .../JpaWorkerResponseRepository.java | 50 ++- 19 files changed, 775 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentFileLookup.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionRequest.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionResponse.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseUploadItemResponse.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionResult.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionService.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseUploadResult.java diff --git a/src/main/java/com/fowoco/server/task/domain/Task.java b/src/main/java/com/fowoco/server/task/domain/Task.java index 89efbbb6..45cd8dc5 100644 --- a/src/main/java/com/fowoco/server/task/domain/Task.java +++ b/src/main/java/com/fowoco/server/task/domain/Task.java @@ -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) { @@ -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, diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentFileLookup.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentFileLookup.java new file mode 100644 index 00000000..d78163e8 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentFileLookup.java @@ -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 findByFileIdAndCompanyId(UUID fileId, UUID companyId); +} diff --git a/src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java b/src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java index f0bbe41a..6f8c53aa 100644 --- a/src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java +++ b/src/main/java/com/fowoco/server/worker/domain/WorkerDocument.java @@ -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; } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java index 5e24c35e..54acf48f 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerDocumentRepository.java @@ -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; @@ -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; @@ -75,6 +76,26 @@ public Optional findByIdAndCompanyId(UUID workerDocumentId, UUID .map(WorkerDocumentJpaEntity::toDomain); } + @Override + public Optional 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"); diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java index 8321e6e6..0cf7b135 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -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; @@ -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; @@ -74,8 +76,8 @@ public WorkerLinkController( public ResponseEntity 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( @@ -84,7 +86,11 @@ public ResponseEntity 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(); diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionRequest.java new file mode 100644 index 00000000..730f1c50 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionRequest.java @@ -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; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionResponse.java new file mode 100644 index 00000000..9a5c73e6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseDocumentAdoptionResponse.java @@ -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 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() + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseItemResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseItemResponse.java index c0ceeb76..d3884b6a 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseItemResponse.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseItemResponse.java @@ -12,6 +12,7 @@ public record WorkerResponseItemResponse( WorkerResponseType responseType, String message, List uploadIds, + List uploads, ConversationStatus conversationStatus, boolean unread, Instant receivedAt @@ -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() diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseManagementController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseManagementController.java index e99cd50e..07d89712 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseManagementController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseManagementController.java @@ -4,6 +4,7 @@ import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.workerlink.application.WorkerResponseManagementService; +import com.fowoco.server.workerlink.application.WorkerResponseDocumentAdoptionService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -11,6 +12,7 @@ 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 jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.util.UUID; @@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -34,13 +37,16 @@ public class WorkerResponseManagementController { private final WorkerResponseManagementService service; + private final WorkerResponseDocumentAdoptionService documentAdoptionService; private final ActorContextProvider actorContextProvider; public WorkerResponseManagementController( WorkerResponseManagementService service, + WorkerResponseDocumentAdoptionService documentAdoptionService, ActorContextProvider actorContextProvider ) { this.service = service; + this.documentAdoptionService = documentAdoptionService; this.actorContextProvider = actorContextProvider; } @@ -87,6 +93,42 @@ public ResponseEntity markReviewed( return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } + @Operation( + operationId = "adoptWorkerResponseDocuments", + summary = "근로자 제출 파일을 공식 서류로 채택", + description = "HR이 제출 파일을 확인한 뒤 WorkerDocument(SUBMITTED)로 등록합니다. " + + "요청 서류가 모두 채택되면 WAITING_WORKER 업무를 APPROVED로 되돌려 다음 행동을 엽니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "공식 서류 채택 성공"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + path = "/{responseId}/documents/adopt", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public WorkerResponseDocumentAdoptionResponse adoptDocuments( + @PathVariable UUID taskId, + @PathVariable UUID responseId, + @Valid @RequestBody WorkerResponseDocumentAdoptionRequest request, + HttpServletRequest servletRequest + ) { + return WorkerResponseDocumentAdoptionResponse.from(documentAdoptionService.adopt( + taskId, + responseId, + request.getExpectedTaskVersion(), + actor(), + RequestMetadata.from(servletRequest) + )); + } + private ActorContext actor() { return actorContextProvider.requireCurrentActor(); } diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseUploadItemResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseUploadItemResponse.java new file mode 100644 index 00000000..249386f7 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseUploadItemResponse.java @@ -0,0 +1,25 @@ +package com.fowoco.server.workerlink.api; + +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.workerlink.application.WorkerResponseUploadResult; +import java.util.UUID; + +public record WorkerResponseUploadItemResponse( + UUID fileId, + String fileName, + String mimeType, + long size, + DocumentType documentType, + boolean adopted +) { + static WorkerResponseUploadItemResponse from(WorkerResponseUploadResult result) { + return new WorkerResponseUploadItemResponse( + result.fileId(), + result.fileName(), + result.mimeType(), + result.size(), + result.documentType(), + result.adopted() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 18a15dcc..12722c7c 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -2,15 +2,24 @@ import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.settings.application.port.CompanySettingsRepository; import com.fowoco.server.settings.domain.CompanySettings; import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.application.port.TaskTransitionRecorder; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; @@ -19,6 +28,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.Comparator; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +40,8 @@ public class WorkerLinkService { private final ApprovalRequestRepository approvalRequestRepository; private final CompanySettingsRepository companySettingsRepository; private final WorkerLinkRepository workerLinkRepository; + private final TaskTransitionRecorder transitionRecorder; + private final AuditEventRepository auditRepository; private final WorkerLinkGenerator workerLinkGenerator; private final WorkerLinkHasher workerLinkHasher; private final TenantDatabaseContext tenantDatabaseContext; @@ -41,6 +53,8 @@ public WorkerLinkService( ApprovalRequestRepository approvalRequestRepository, CompanySettingsRepository companySettingsRepository, WorkerLinkRepository workerLinkRepository, + TaskTransitionRecorder transitionRecorder, + AuditEventRepository auditRepository, WorkerLinkGenerator workerLinkGenerator, WorkerLinkHasher workerLinkHasher, TenantDatabaseContext tenantDatabaseContext, @@ -51,6 +65,8 @@ public WorkerLinkService( this.approvalRequestRepository = approvalRequestRepository; this.companySettingsRepository = companySettingsRepository; this.workerLinkRepository = workerLinkRepository; + this.transitionRecorder = transitionRecorder; + this.auditRepository = auditRepository; this.workerLinkGenerator = workerLinkGenerator; this.workerLinkHasher = workerLinkHasher; this.tenantDatabaseContext = tenantDatabaseContext; @@ -59,7 +75,11 @@ public WorkerLinkService( } @Transactional - public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext actor) { + public WorkerLinkIssueResult issue( + WorkerLinkIssueCommand command, + ActorContext actor, + RequestMetadata metadata + ) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); Task task = taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId()) @@ -91,6 +111,10 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext ); } + if (task.status() != TaskStatus.APPROVED && task.status() != TaskStatus.WAITING_WORKER) { + throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); + } + Instant now = DatabaseTimestamp.now(clock); Optional existingActive = workerLinkRepository .findActiveByTaskIdAndCompanyId(command.taskId(), actor.companyId()); @@ -122,6 +146,37 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext ); workerLinkRepository.insert(workerLink); + if (task.status() == TaskStatus.APPROVED) { + TaskStatus previous = task.waitForWorker(task.version(), actor.actorId(), now); + Task savedTask = taskRepository.save(task); + transitionRecorder.record( + uuidGenerator.generate(), + task.taskId(), + task.companyId(), + previous, + savedTask.status(), + actor.actorId(), + "근로자 보안 링크 발급", + metadata.requestId(), + now + ); + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.TASK_UPDATED, + AuditTargetType.TASK, + task.taskId(), + metadata.requestId(), + metadata.traceId(), + "1", + "근로자 보안 링크를 발급하고 응답 대기로 전환", + now + )); + } + return new WorkerLinkIssueResult( workerLink.workerLinkId(), generated.rawValue(), @@ -132,6 +187,20 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext ); } + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(this::rolePriority)) + .orElseThrow(); + } + + private int rolePriority(UserRole role) { + return switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + }; + } + private long resolveExpiryHours(WorkerLinkIssueCommand command, ActorContext actor) { long hours = command.expiresInHours() != null ? command.expiresInHours() diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionResult.java new file mode 100644 index 00000000..19909145 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionResult.java @@ -0,0 +1,24 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; +import java.util.UUID; + +public record WorkerResponseDocumentAdoptionResult( + UUID responseId, + List adoptedDocuments, + TaskStatus taskStatus, + long taskVersion +) { + public WorkerResponseDocumentAdoptionResult { + adoptedDocuments = List.copyOf(adoptedDocuments); + } + + public record AdoptedDocument( + UUID workerDocumentId, + UUID fileId, + DocumentType documentType + ) { + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionService.java new file mode 100644 index 00000000..918565b5 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseDocumentAdoptionService.java @@ -0,0 +1,341 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.approval.application.ApprovalControlPort; +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.application.port.TaskTransitionRecorder; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +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.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import com.fowoco.server.worker.domain.WorkerDocument; +import com.fowoco.server.workerlink.application.WorkerResponseDocumentAdoptionResult.AdoptedDocument; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerResponseDocumentAdoptionService { + + private static final String AUDIT_EVENT_VERSION = "1"; + private static final String ADOPTION_NOTE = "근로자 모바일 링크 제출 자료를 HR이 채택함"; + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final TaskRepository taskRepository; + private final TaskTransitionRecorder transitionRecorder; + private final WorkerResponseRepository workerResponseRepository; + private final WorkerLinkRepository workerLinkRepository; + private final StoredFileRepository storedFileRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerDocumentFileLookup workerDocumentFileLookup; + private final DocumentRequestDraftRepository documentRequestDraftRepository; + private final ApprovalControlPort approvalControl; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerResponseDocumentAdoptionService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + TaskRepository taskRepository, + TaskTransitionRecorder transitionRecorder, + WorkerResponseRepository workerResponseRepository, + WorkerLinkRepository workerLinkRepository, + StoredFileRepository storedFileRepository, + WorkerDocumentRepository workerDocumentRepository, + WorkerDocumentFileLookup workerDocumentFileLookup, + DocumentRequestDraftRepository documentRequestDraftRepository, + ApprovalControlPort approvalControl, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.taskRepository = taskRepository; + this.transitionRecorder = transitionRecorder; + this.workerResponseRepository = workerResponseRepository; + this.workerLinkRepository = workerLinkRepository; + this.storedFileRepository = storedFileRepository; + this.workerDocumentRepository = workerDocumentRepository; + this.workerDocumentFileLookup = workerDocumentFileLookup; + this.documentRequestDraftRepository = documentRequestDraftRepository; + this.approvalControl = approvalControl; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerResponseDocumentAdoptionResult adopt( + UUID taskId, + UUID responseId, + long expectedTaskVersion, + ActorContext actor, + RequestMetadata metadata + ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + actorAuthorizer.requireHrWrite(actor); + Task task = taskRepository.findByIdAndCompanyId(taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + if (task.workerId() == null) { + throw new ApiException(WorkerLinkErrorCode.TASK_WORKER_TARGET_REQUIRED); + } + + WorkerResponseRepository.WorkerResponseItem responseItem = workerResponseRepository + .findByResponseIdAndTaskIdAndCompanyId(responseId, taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_NOT_FOUND)); + if (responseItem.response().responseType() != WorkerResponseType.DOCUMENT_SUBMITTED) { + throw new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_NOT_DOCUMENT_SUBMISSION); + } + if (responseItem.uploadIds().isEmpty()) { + throw new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_DOCUMENTS_INCOMPLETE); + } + + Instant now = DatabaseTimestamp.now(clock); + List adoptedDocuments = new ArrayList<>(); + Set submittedTypes = EnumSet.noneOf(DocumentType.class); + int newlyAdoptedCount = 0; + for (UUID fileId : responseItem.uploadIds()) { + StoredFile file = requireSubmittedFile(fileId, task, actor.companyId()); + DocumentType documentType = parseDocumentType(file.purpose()); + submittedTypes.add(documentType); + Optional existing = workerDocumentFileLookup + .findByFileIdAndCompanyId(fileId, actor.companyId()); + WorkerDocument document = existing + .map(value -> requireSameAdoption(value, task, documentType)) + .orElseGet(() -> createDocument(task, documentType, fileId, now)); + if (existing.isEmpty()) { + newlyAdoptedCount++; + appendFileAudit(document, actor, metadata, now); + } + adoptedDocuments.add(new AdoptedDocument( + document.workerDocumentId(), + document.fileId(), + document.documentType() + )); + } + + Set requiredTypes = documentRequestDraftRepository + .findByTaskIdAndCompanyId(taskId, actor.companyId()) + .map(draft -> EnumSet.copyOf(draft.documentTypes())) + .orElseGet(() -> EnumSet.copyOf(submittedTypes)); + Set adoptedTypes = officiallyAdoptedTypes(task, actor.companyId()); + if (!adoptedTypes.containsAll(requiredTypes)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_DOCUMENTS_INCOMPLETE); + } + + Task savedTask = advanceTask(task, expectedTaskVersion, actor, metadata, now); + closeWorkerConversation(responseItem.response().workerLinkId(), actor.companyId(), now); + + if (newlyAdoptedCount > 0) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.WORKER_LINK_RESPONSES_REVIEWED, + AuditTargetType.TASK, + taskId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 제출 파일을 공식 서류로 채택: " + newlyAdoptedCount + "개", + now + )); + } + + return new WorkerResponseDocumentAdoptionResult( + responseId, + adoptedDocuments, + savedTask.status(), + savedTask.version() + ); + } + + private StoredFile requireSubmittedFile(UUID fileId, Task task, UUID companyId) { + StoredFile file = storedFileRepository.findByIdAndCompanyId(fileId, companyId) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE)); + if (!file.verified() || !Objects.equals(file.taskId(), task.taskId())) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + return file; + } + + private DocumentType parseDocumentType(String value) { + try { + return DocumentType.valueOf(value); + } catch (IllegalArgumentException exception) { + throw new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_DOCUMENT_TYPE_INVALID); + } + } + + private WorkerDocument requireSameAdoption( + WorkerDocument existing, + Task task, + DocumentType documentType + ) { + if (!Objects.equals(existing.workerId(), task.workerId()) + || !Objects.equals(existing.taskId(), task.taskId()) + || existing.documentType() != documentType) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + return existing; + } + + private WorkerDocument createDocument( + Task task, + DocumentType documentType, + UUID fileId, + Instant now + ) { + WorkerDocument document = WorkerDocument.createSubmittedWithFile( + uuidGenerator.generate(), + task.workerId(), + task.companyId(), + task.taskId(), + documentType, + ADOPTION_NOTE, + fileId, + now + ); + workerDocumentRepository.insert(document); + return document; + } + + private Set officiallyAdoptedTypes(Task task, UUID companyId) { + Set result = EnumSet.noneOf(DocumentType.class); + workerDocumentRepository.findPage( + companyId, + new WorkerDocumentSearchQuery(task.workerId(), task.taskId(), null, null, null, 0, 100) + ) + .stream() + .filter(document -> document.fileId() != null) + .filter(document -> document.submissionStatus() == SubmissionStatus.SUBMITTED + || document.submissionStatus() == SubmissionStatus.VERIFIED) + .forEach(document -> result.add(document.documentType())); + return result; + } + + private Task advanceTask( + Task task, + long expectedTaskVersion, + ActorContext actor, + RequestMetadata metadata, + Instant now + ) { + if (task.status() == TaskStatus.WAITING_WORKER) { + if (!approvalControl.hasValidApproval( + task.taskId(), + task.companyId(), + task.contentRevision(), + task.criticalFingerprint() + )) { + throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); + } + TaskStatus previous = task.resumeAfterWorkerSubmission( + expectedTaskVersion, + actor.actorId(), + now + ); + Task saved = taskRepository.save(task); + transitionRecorder.record( + uuidGenerator.generate(), + task.taskId(), + task.companyId(), + previous, + saved.status(), + actor.actorId(), + "근로자 제출 서류를 HR이 채택함", + metadata.requestId(), + now + ); + return saved; + } + if (task.status() != TaskStatus.APPROVED) { + throw new ApiException(TaskErrorCode.TASK_TRANSITION_NOT_ALLOWED); + } + if (task.version() != expectedTaskVersion) { + throw new ApiException(TaskErrorCode.CONCURRENT_MODIFICATION); + } + return task; + } + + private void closeWorkerConversation(UUID workerLinkId, UUID companyId, Instant now) { + WorkerLink link = workerLinkRepository.findByIdAndCompanyId(workerLinkId, companyId) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_RESPONSE_NOT_FOUND)); + workerLinkRepository.update(link.markReviewed(now).revoke(now)); + } + + private void appendFileAudit( + WorkerDocument document, + ActorContext actor, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.WORKER_DOCUMENT_FILE_LINKED, + AuditTargetType.WORKER_DOCUMENT, + document.workerDocumentId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 제출 파일을 공식 서류에 연결", + now + )); + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(this::rolePriority)) + .orElseThrow(); + } + + private int rolePriority(UserRole role) { + return switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + }; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java index 24781e97..8c2439a8 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseManagementService.java @@ -12,8 +12,12 @@ import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.worker.application.port.WorkerDocumentFileLookup; +import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; import com.fowoco.server.workerlink.domain.ConversationStatus; @@ -34,6 +38,8 @@ public class WorkerResponseManagementService { private final TaskRepository taskRepository; private final WorkerResponseRepository workerResponseRepository; private final WorkerLinkRepository workerLinkRepository; + private final StoredFileRepository storedFileRepository; + private final WorkerDocumentFileLookup workerDocumentFileLookup; private final AuditEventRepository auditRepository; private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; @@ -43,6 +49,8 @@ public WorkerResponseManagementService( TaskRepository taskRepository, WorkerResponseRepository workerResponseRepository, WorkerLinkRepository workerLinkRepository, + StoredFileRepository storedFileRepository, + WorkerDocumentFileLookup workerDocumentFileLookup, AuditEventRepository auditRepository, TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, @@ -51,6 +59,8 @@ public WorkerResponseManagementService( this.taskRepository = taskRepository; this.workerResponseRepository = workerResponseRepository; this.workerLinkRepository = workerLinkRepository; + this.storedFileRepository = storedFileRepository; + this.workerDocumentFileLookup = workerDocumentFileLookup; this.auditRepository = auditRepository; this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; @@ -69,6 +79,9 @@ public WorkerResponsePageResult findAll(UUID taskId, int page, int size, ActorCo item.response().responseType(), item.response().message(), item.uploadIds(), + item.uploadIds().stream() + .map(fileId -> toUploadResult(fileId, actor.companyId())) + .toList(), item.conversationStatus(), item.conversationStatus() == ConversationStatus.NEEDS_FOLLOWUP, item.response().receivedAt() @@ -83,6 +96,27 @@ public WorkerResponsePageResult findAll(UUID taskId, int page, int size, ActorCo ); } + private WorkerResponseUploadResult toUploadResult(UUID fileId, UUID companyId) { + StoredFile file = storedFileRepository.findByIdAndCompanyId(fileId, companyId) + .orElseThrow(() -> new IllegalStateException("worker response upload file is missing")); + return new WorkerResponseUploadResult( + file.storedFileId(), + file.name(), + file.mimeType(), + file.size(), + parseDocumentType(file.purpose()), + workerDocumentFileLookup.findByFileIdAndCompanyId(fileId, companyId).isPresent() + ); + } + + private DocumentType parseDocumentType(String value) { + try { + return DocumentType.valueOf(value); + } catch (IllegalArgumentException exception) { + return null; + } + } + @Transactional public void markReviewed(UUID taskId, ActorContext actor, RequestMetadata metadata) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseQueryResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseQueryResult.java index b12d448f..d81df8d4 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseQueryResult.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseQueryResult.java @@ -11,11 +11,13 @@ public record WorkerResponseQueryResult( WorkerResponseType responseType, String message, List uploadIds, + List uploads, ConversationStatus conversationStatus, boolean unread, Instant receivedAt ) { public WorkerResponseQueryResult { uploadIds = List.copyOf(uploadIds); + uploads = List.copyOf(uploads); } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseUploadResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseUploadResult.java new file mode 100644 index 00000000..cbd08662 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseUploadResult.java @@ -0,0 +1,14 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.UUID; + +public record WorkerResponseUploadResult( + UUID fileId, + String fileName, + String mimeType, + long size, + DocumentType documentType, + boolean adopted +) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java index ffc9ecee..ee9c6c3b 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -47,6 +47,19 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { UPLOAD_NOT_AVAILABLE( HttpStatus.UNPROCESSABLE_CONTENT, "업로드된 파일을 찾을 수 없거나 이미 사용된 파일입니다." + ), + WORKER_RESPONSE_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자 응답을 찾을 수 없습니다."), + WORKER_RESPONSE_NOT_DOCUMENT_SUBMISSION( + HttpStatus.UNPROCESSABLE_CONTENT, + "서류 제출 응답만 공식 근로자 서류로 등록할 수 있습니다." + ), + WORKER_RESPONSE_DOCUMENT_TYPE_INVALID( + HttpStatus.UNPROCESSABLE_CONTENT, + "제출 파일의 서류 유형을 확인할 수 없습니다." + ), + WORKER_RESPONSE_DOCUMENTS_INCOMPLETE( + HttpStatus.UNPROCESSABLE_CONTENT, + "요청한 서류가 모두 제출된 뒤 공식 서류로 등록할 수 있습니다." ); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java index 73220a5a..060c944c 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -12,6 +12,12 @@ public interface WorkerResponseRepository { Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); + Optional findByResponseIdAndTaskIdAndCompanyId( + UUID responseId, + UUID taskId, + UUID companyId + ); + void linkUpload(UUID responseId, UUID storedFileId, UUID companyId); boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId); diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index 9b6aca9d..506affa2 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -5,6 +5,7 @@ import com.fowoco.server.workerlink.domain.WorkerResponse; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.nio.ByteBuffer; import java.sql.SQLException; import java.util.Locale; import java.util.List; @@ -53,6 +54,42 @@ public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerL .map(WorkerResponseJpaEntity::toDomain); } + @Override + public Optional findByResponseIdAndTaskIdAndCompanyId( + UUID responseId, + UUID taskId, + UUID companyId + ) { + Objects.requireNonNull(responseId, "responseId must not be null"); + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select response, link.conversationStatus + from WorkerResponseJpaEntity response, WorkerLinkJpaEntity link + where response.workerLinkId = link.workerLinkId + and response.responseId = :responseId + and response.companyId = :companyId + and link.companyId = :companyId + and link.taskId = :taskId + """, + Object[].class + ) + .setParameter("responseId", responseId) + .setParameter("taskId", taskId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(row -> { + WorkerResponse response = ((WorkerResponseJpaEntity) row[0]).toDomain(); + return new WorkerResponseItem( + response, + (com.fowoco.server.workerlink.domain.ConversationStatus) row[1], + findUploadIds(response.responseId(), companyId) + ); + }); + } + @Override public void linkUpload(UUID responseId, UUID storedFileId, UUID companyId) { Objects.requireNonNull(responseId, "responseId must not be null"); @@ -160,10 +197,21 @@ private List findUploadIds(UUID responseId, UUID companyId) { .setParameter(2, companyId) .getResultList() .stream() - .map(value -> value instanceof UUID uuid ? uuid : UUID.fromString(value.toString())) + .map(this::toUuid) .toList(); } + private UUID toUuid(Object value) { + if (value instanceof UUID uuid) { + return uuid; + } + if (value instanceof byte[] bytes && bytes.length == 16) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + return new UUID(buffer.getLong(), buffer.getLong()); + } + return UUID.fromString(value.toString()); + } + static boolean isUniqueUploadFileViolation(Throwable failure) { Throwable current = failure; while (current != null) { From 5fe56d57bab488d7012ed1cb623ab88ff051d2b9 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 01:30:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(workerlink):=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=99=95=EB=B3=B5=EA=B3=BC=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=A9=EB=A6=AC=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 링크 발급부터 파일 제출, HR 채택, 업무 재개까지의 왕복 흐름과 타 사업장 접근 차단 및 반복 요청 멱등성을 검증합니다. --- .../fowoco/server/task/domain/TaskTest.java | 12 ++ .../WorkerLinkSecurityIntegrationTest.java | 140 ++++++++++++++++++ .../application/WorkerLinkServiceTest.java | 23 ++- 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/fowoco/server/task/domain/TaskTest.java b/src/test/java/com/fowoco/server/task/domain/TaskTest.java index 30d65223..c6393eb4 100644 --- a/src/test/java/com/fowoco/server/task/domain/TaskTest.java +++ b/src/test/java/com/fowoco/server/task/domain/TaskTest.java @@ -49,6 +49,18 @@ void followsApprovalSubmissionAndCompletionSequence() { assertThat(task.status()).isEqualTo(TaskStatus.COMPLETED); } + @Test + void waitsForWorkerAndResumesOnlyAfterReviewedSubmission() { + Task task = task(TaskStatus.APPROVED); + + TaskStatus beforeWaiting = task.waitForWorker(0, ACTOR_ID, NOW.plusSeconds(1)); + TaskStatus beforeResume = task.resumeAfterWorkerSubmission(0, ACTOR_ID, NOW.plusSeconds(2)); + + assertThat(beforeWaiting).isEqualTo(TaskStatus.APPROVED); + assertThat(beforeResume).isEqualTo(TaskStatus.WAITING_WORKER); + assertThat(task.status()).isEqualTo(TaskStatus.APPROVED); + } + @Test void requiresEvidenceForCompletion() { Task task = task(TaskStatus.WAITING_EXTERNAL); diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index 05453ae6..ff715232 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -332,6 +332,126 @@ void hrCanListAndMarkWorkerResponsesReviewed() throws Exception { )).isEqualTo(1); } + @Test + void hrCanInspectAndAdoptSubmittedFilesThenResumeTask() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "제출서류채택테스트근로자"); + String taskId = createApprovedTask(hrToken, workerId); + saveDocumentRequestDraft(hrToken, taskId); + String rawToken = issueWorkerLink(hrToken, taskId, "document-adoption-link-key"); + + HttpResponse passportUpload = uploadFileAsType( + rawToken, + "passport.pdf", + "application/pdf", + "passport-content".getBytes(StandardCharsets.UTF_8), + "PASSPORT_COPY" + ); + HttpResponse contractUpload = uploadFileAsType( + rawToken, + "contract.pdf", + "application/pdf", + "contract-content".getBytes(StandardCharsets.UTF_8), + "CONTRACT" + ); + assertThat(passportUpload.statusCode()).isEqualTo(201); + assertThat(contractUpload.statusCode()).isEqualTo(201); + String passportFileId = JsonPath.read(passportUpload.body(), "$.upload_id"); + String contractFileId = JsonPath.read(contractUpload.body(), "$.upload_id"); + + HttpResponse submitResponse = postJson( + "/public/worker-links/" + rawToken + "/responses", + """ + { + "response_type":"DOCUMENT_SUBMITTED", + "message":"요청하신 서류를 제출합니다.", + "upload_ids":["%s","%s"], + "idempotency_key":"document-adoption-response-key" + } + """.formatted(passportFileId, contractFileId), + null + ); + assertThat(submitResponse.statusCode()).isEqualTo(201); + String responseId = JsonPath.read(submitResponse.body(), "$.response_id"); + + HttpResponse beforeAdoption = getJson( + "/api/v1/tasks/" + taskId + "/worker-responses", + hrToken + ); + assertThat(beforeAdoption.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(beforeAdoption.body(), "$.items[0].message")) + .isEqualTo("요청하신 서류를 제출합니다."); + assertThat(JsonPath.>read(beforeAdoption.body(), "$.items[0].uploads[*].file_name")) + .containsExactlyInAnyOrder("passport.pdf", "contract.pdf"); + assertThat(JsonPath.>read(beforeAdoption.body(), "$.items[0].uploads[*].adopted")) + .containsOnly(false); + + HttpResponse taskBeforeAdoption = getJson("/api/v1/tasks/" + taskId, hrToken); + assertThat(JsonPath.read(taskBeforeAdoption.body(), "$.status")) + .isEqualTo("WAITING_WORKER"); + int expectedTaskVersion = ((Number) JsonPath.read(taskBeforeAdoption.body(), "$.version")).intValue(); + + String otherCompanyHrToken = accessToken(login(HR_B_EMAIL)); + HttpResponse crossTenantAdoption = postJson( + "/api/v1/tasks/" + taskId + "/worker-responses/" + responseId + "/documents/adopt", + """ + {"expected_task_version":%d} + """.formatted(expectedTaskVersion), + otherCompanyHrToken + ); + assertThat(crossTenantAdoption.statusCode()).isEqualTo(404); + + HttpResponse adoption = postJson( + "/api/v1/tasks/" + taskId + "/worker-responses/" + responseId + "/documents/adopt", + """ + {"expected_task_version":%d} + """.formatted(expectedTaskVersion), + hrToken + ); + + assertThat(adoption.statusCode()).as("adoption response body: %s", adoption.body()).isEqualTo(200); + assertThat(JsonPath.read(adoption.body(), "$.task_status")).isEqualTo("APPROVED"); + assertThat(JsonPath.>read(adoption.body(), "$.adopted_documents[*].document_type")) + .containsExactlyInAnyOrder("PASSPORT_COPY", "CONTRACT"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE task_id = ? AND submission_status = 'SUBMITTED'", + Integer.class, + UUID.fromString(taskId) + )).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT status FROM worker_link WHERE task_id = ?", + String.class, + UUID.fromString(taskId) + )).isEqualTo("REVOKED"); + assertThat(jdbcTemplate.queryForObject( + "SELECT conversation_status FROM worker_link WHERE task_id = ?", + String.class, + UUID.fromString(taskId) + )).isEqualTo("REOPENED"); + + HttpResponse afterAdoption = getJson( + "/api/v1/tasks/" + taskId + "/worker-responses", + hrToken + ); + assertThat(JsonPath.>read(afterAdoption.body(), "$.items[0].uploads[*].adopted")) + .containsOnly(true); + + int currentTaskVersion = ((Number) JsonPath.read(adoption.body(), "$.task_version")).intValue(); + HttpResponse repeated = postJson( + "/api/v1/tasks/" + taskId + "/worker-responses/" + responseId + "/documents/adopt", + """ + {"expected_task_version":%d} + """.formatted(currentTaskVersion), + hrToken + ); + assertThat(repeated.statusCode()).isEqualTo(200); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE task_id = ?", + Integer.class, + UUID.fromString(taskId) + )).isEqualTo(2); + } + @Test void workerResponsesAreHiddenFromOtherCompany() throws Exception { String hrTokenA = accessToken(login(HR_A_EMAIL)); @@ -895,6 +1015,26 @@ private HttpResponse uploadFile(String token, String filename, String mi return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } + private HttpResponse uploadFileAsType( + String token, + String filename, + String mimeType, + byte[] content, + String documentType + ) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "clientRequestId", UUID.randomUUID().toString()); + writeFieldPart(out, "documentType", documentType); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/public/worker-links/" + token + "/documents")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse uploadFileWithFixedClientRequestId( String token, String filename, String mimeType, byte[] content, String clientRequestId ) throws Exception { diff --git a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java index 31c8b036..2e25e9ff 100644 --- a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java +++ b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java @@ -10,15 +10,19 @@ import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.audit.application.port.AuditEventRepository; import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.settings.application.port.CompanySettingsRepository; import com.fowoco.server.settings.domain.CompanySettings; import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.application.port.TaskTransitionRecorder; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; @@ -49,6 +53,7 @@ class WorkerLinkServiceTest { "50000000-0000-0000-0000-000000000005" ); private static final Instant NOW = Instant.parse("2026-08-08T12:00:00Z"); + private static final RequestMetadata METADATA = new RequestMetadata("request-1", "trace-1"); @Test void bindsActorTenantBeforeFirstRepositoryAccess() { @@ -66,6 +71,8 @@ void bindsActorTenantBeforeFirstRepositoryAccess() { approvalRepository, companySettingsRepository, workerLinkRepository, + mock(TaskTransitionRecorder.class), + mock(AuditEventRepository.class), mock(WorkerLinkGenerator.class), mock(WorkerLinkHasher.class), tenantDatabaseContext, @@ -84,7 +91,7 @@ void bindsActorTenantBeforeFirstRepositoryAccess() { Set.of(UserRole.HR) ); - assertThatThrownBy(() -> service.issue(command, actor)) + assertThatThrownBy(() -> service.issue(command, actor, METADATA)) .isInstanceOf(ApiException.class); InOrder order = inOrder(tenantDatabaseContext, taskRepository); @@ -98,7 +105,7 @@ void usesCompanySettingWhenRequestExpiryIsOmitted() { when(fixture.companySettingsRepository().findByCompanyId(COMPANY_ID)) .thenReturn(Optional.of(companySettings(24L))); - WorkerLinkIssueResult result = fixture.service().issue(command(null), actor()); + WorkerLinkIssueResult result = fixture.service().issue(command(null), actor(), METADATA); assertThat(result.expiresAt()).isEqualTo(NOW.plusSeconds(24L * 60L * 60L)); verify(fixture.companySettingsRepository()).findByCompanyId(COMPANY_ID); @@ -108,7 +115,7 @@ void usesCompanySettingWhenRequestExpiryIsOmitted() { void explicitRequestExpiryTakesPrecedenceWithoutReadingCompanySetting() { ServiceFixture fixture = validFixture(); - WorkerLinkIssueResult result = fixture.service().issue(command(12L), actor()); + WorkerLinkIssueResult result = fixture.service().issue(command(12L), actor(), METADATA); assertThat(result.expiresAt()).isEqualTo(NOW.plusSeconds(12L * 60L * 60L)); verifyNoInteractions(fixture.companySettingsRepository()); @@ -120,6 +127,8 @@ private ServiceFixture validFixture() { CompanySettingsRepository companySettingsRepository = mock(CompanySettingsRepository.class); WorkerLinkRepository workerLinkRepository = mock(WorkerLinkRepository.class); + TaskTransitionRecorder transitionRecorder = mock(TaskTransitionRecorder.class); + AuditEventRepository auditRepository = mock(AuditEventRepository.class); WorkerLinkGenerator workerLinkGenerator = mock(WorkerLinkGenerator.class); WorkerLinkHasher workerLinkHasher = mock(WorkerLinkHasher.class); TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); @@ -128,11 +137,17 @@ private ServiceFixture validFixture() { ApprovalRequest approval = mock(ApprovalRequest.class); when(task.workerId()).thenReturn(WORKER_ID); + when(task.taskId()).thenReturn(TASK_ID); + when(task.companyId()).thenReturn(COMPANY_ID); + when(task.status()).thenReturn(TaskStatus.APPROVED); + when(task.version()).thenReturn(0L); + when(task.waitForWorker(0L, ACTOR_ID, NOW)).thenReturn(TaskStatus.APPROVED); when(task.contentRevision()).thenReturn(2L); when(task.criticalFingerprint()).thenReturn("approved-fingerprint"); when(approval.isValidFor(2L, "approved-fingerprint")).thenReturn(true); when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)) .thenReturn(Optional.of(task)); + when(taskRepository.save(task)).thenReturn(task); when(approvalRepository.findLatestApprovedByTaskIdAndCompanyId(TASK_ID, COMPANY_ID)) .thenReturn(Optional.of(approval)); when(workerLinkHasher.hash("worker-link-issue-1")).thenReturn("idempotency-hash"); @@ -150,6 +165,8 @@ private ServiceFixture validFixture() { approvalRepository, companySettingsRepository, workerLinkRepository, + transitionRecorder, + auditRepository, workerLinkGenerator, workerLinkHasher, tenantDatabaseContext, From d51548cbe0e55c5f2317a03d14eb3f1b8d50d1bc Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 03:34:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=B5=9C=EC=A2=85=20=EC=97=85?= =?UTF-8?q?=EB=AC=B4=20=ED=9D=90=EB=A6=84=EA=B3=BC=20Prometheus=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=20=EA=B2=B0=EA=B3=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74c51a4d..6752daba 100644 --- a/README.md +++ b/README.md @@ -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 검증 | ## 왜 이 기술과 구조를 선택했는가 @@ -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 @@ -63,9 +68,9 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos - CSV/XLSX 근로자 명단 가져오기와 OCR 검토 - 업무카드·체크리스트·상태 전이 관리 - HR 승인·반려·외부 제출·증빙·완료와 감사로그 -- 만료되는 근로자 보안 링크 +- 만료되는 근로자 보안 링크, 모바일 응답·서류 제출과 HR 공식 서류 채택 - AI Runtime PLAN·ANALYZE 요청, Slot 보충, 응답 검증과 영속 실행 이력 -- Today Dashboard·알림·사업장 설정 조회 +- Today Dashboard·이벤트 기반 알림·읽음 상태·사업장 설정 조회 - 실패해도 유실되지 않는 후속 이벤트 처리 Provider SDK, Prompt와 모델 라우팅은 Server에 구현하지 않습니다. @@ -90,6 +95,10 @@ HR 로그인 → 업무카드 후보 검토·확정 → 필요정보와 문서 초안 확인 → HR 승인 +→ 근로자 보안 링크 발급·모바일 안내 +→ 근로자 응답·서류 제출 +→ HR 제출자료 확인·공식 서류 채택 +→ Task 재개 → 외부 제출·처리결과 기록 → 완료·감사로그 ``` @@ -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로 배포하는 @@ -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·로그·문서에 남기지 않습니다.