From 611e51cb0facc8ea4d5484e2e594d81f82551595 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 15:12:53 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(document):=20DocumentItemResponse?= =?UTF-8?q?=EC=97=90=20version,=20task=5Fid=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/api/DocumentItemResponse.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java index c8cb486..3a6c0e0 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java @@ -19,6 +19,10 @@ public final class DocumentItemResponse { @Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) private final UUID workerId; + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID (없으면 null)") + private final UUID taskId; + @JsonProperty("display_name") @Schema( name = "display_name", @@ -43,33 +47,43 @@ public final class DocumentItemResponse { @Schema(name = "file_id", format = "uuid") private final UUID fileId; + @JsonProperty("version") + @Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + private DocumentItemResponse( UUID workerDocumentId, UUID workerId, + UUID taskId, String displayName, DocumentType documentType, SubmissionStatus submissionStatus, LocalDate expiryDate, - UUID fileId + UUID fileId, + long version ) { this.workerDocumentId = workerDocumentId; this.workerId = workerId; + this.taskId = taskId; this.displayName = displayName; this.documentType = documentType; this.submissionStatus = submissionStatus; this.expiryDate = expiryDate; this.fileId = fileId; + this.version = version; } public static DocumentItemResponse from(WorkerDocument document, String displayName) { return new DocumentItemResponse( document.workerDocumentId(), document.workerId(), + document.taskId(), displayName, document.documentType(), document.submissionStatus(), document.expiryDate(), - document.fileId() + document.fileId(), + document.version() ); } @@ -81,6 +95,10 @@ public UUID getWorkerId() { return workerId; } + public UUID getTaskId() { + return taskId; + } + public String getWorkerDisplayName() { return displayName; } @@ -100,4 +118,8 @@ public LocalDate getExpiryDate() { public UUID getFileId() { return fileId; } + + public long getVersion() { + return version; + } } From efb495a3dfd5f2d5ea97483ffdcb57d6bab21c03 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 15:41:07 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(worker):=20findByIdAndCompanyId=20?= =?UTF-8?q?=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/api/DocumentItemResponse.java | 2 +- .../port/WorkerDocumentRepository.java | 2 ++ .../JpaWorkerDocumentRepository.java | 20 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java index 3a6c0e0..3586641 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java @@ -20,7 +20,7 @@ public final class DocumentItemResponse { private final UUID workerId; @JsonProperty("task_id") - @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID (없으면 null)") + @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID") private final UUID taskId; @JsonProperty("display_name") diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java index 7991d9f..62d3c28 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerDocumentRepository.java @@ -16,6 +16,8 @@ Optional findByIdAndWorkerIdAndCompanyId( UUID companyId ); + Optional findByIdAndCompanyId(UUID workerDocumentId, UUID companyId); + WorkerDocument update(WorkerDocument document); List findPage(UUID companyId, WorkerDocumentSearchQuery query); 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 56be632..5e24c35 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 @@ -55,6 +55,26 @@ public Optional findByIdAndWorkerIdAndCompanyId( .map(WorkerDocumentJpaEntity::toDomain); } + @Override + public Optional findByIdAndCompanyId(UUID workerDocumentId, UUID companyId) { + Objects.requireNonNull(workerDocumentId, "workerDocumentId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select document + from WorkerDocumentJpaEntity document + where document.workerDocumentId = :workerDocumentId + and document.companyId = :companyId + """, + WorkerDocumentJpaEntity.class + ) + .setParameter("workerDocumentId", workerDocumentId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(WorkerDocumentJpaEntity::toDomain); + } + @Override public WorkerDocument update(WorkerDocument document) { Objects.requireNonNull(document, "document must not be null"); From 8041403163cab16c3b629bb62a0b1318c88c8e73 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 16:04:55 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(document):=20DocumentService?= =?UTF-8?q?=EC=97=90=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/DocumentDetailResult.java | 7 +++++ .../document/application/DocumentService.java | 27 +++++++++++++++++++ .../application/error/DocumentErrorCode.java | 4 +++ 3 files changed, 38 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java diff --git a/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java b/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java new file mode 100644 index 0000000..c6db6c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentDetailResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.worker.domain.WorkerDocument; + +public record DocumentDetailResult(WorkerDocument document, String workerDisplayName, StoredFile storedFile) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java index 93c13b6..515eab1 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -1,7 +1,11 @@ package com.fowoco.server.document.application; import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.document.application.error.DocumentErrorCode; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -9,6 +13,7 @@ import com.fowoco.server.worker.domain.WorkerDocument; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Function; @@ -21,18 +26,40 @@ public class DocumentService { private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final StoredFileRepository storedFileRepository; private final TenantDatabaseContext tenantDatabaseContext; public DocumentService( WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, + StoredFileRepository storedFileRepository, TenantDatabaseContext tenantDatabaseContext ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; + this.storedFileRepository = storedFileRepository; this.tenantDatabaseContext = tenantDatabaseContext; } + @Transactional(readOnly = true) + public DocumentDetailResult findById(UUID workerDocumentId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + WorkerDocument document = workerDocumentRepository + .findByIdAndCompanyId(workerDocumentId, companyId) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND)); + String displayName = workerRepository + .findAllByWorkerIdsAndCompanyId(Set.of(document.workerId()), companyId) + .stream() + .findFirst() + .map(Worker::displayName) + .orElse(null); + StoredFile storedFile = document.fileId() == null + ? null + : storedFileRepository.findByIdAndCompanyId(document.fileId(), companyId).orElse(null); + return new DocumentDetailResult(document, displayName, storedFile); + } + @Transactional(readOnly = true) public DocumentPageResult findPage(ActorContext actor, WorkerDocumentSearchQuery query) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); diff --git a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java index 312f1c8..a361e8f 100644 --- a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -7,6 +7,10 @@ public enum DocumentErrorCode implements ApiErrorCode { DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT( HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요." + ), + DOCUMENT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "문서를 찾을 수 없습니다." ); private final HttpStatus status; From b725afe5c94e199218df7f087f5ca71e40cf203b Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 16:34:53 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat(document):=20DocumentDetailResponse?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20=EA=B8=B0=ED=83=80=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/api/DocumentDetailResponse.java | 168 ++++++++++++++++++ .../document/application/DocumentService.java | 5 +- 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java new file mode 100644 index 0000000..9250cb6 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentDetailResponse.java @@ -0,0 +1,168 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.document.application.DocumentDetailResult; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DocumentDetailResponse", description = "서류 단건 상세 응답 (연결된 파일 정보 포함)") +public final class DocumentDetailResponse { + + @JsonProperty("worker_document_id") + @Schema(name = "worker_document_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerDocumentId; + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID workerId; + + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID") + private final UUID taskId; + + @JsonProperty("display_name") + @Schema(name = "display_name", description = "근로자 화면 표시 이름") + private final String displayName; + + @JsonProperty("document_type") + @Schema(name = "document_type", requiredMode = Schema.RequiredMode.REQUIRED) + private final DocumentType documentType; + + @JsonProperty("submission_status") + @Schema(name = "submission_status", requiredMode = Schema.RequiredMode.REQUIRED) + private final SubmissionStatus submissionStatus; + + @JsonProperty("expiry_date") + @Schema(name = "expiry_date", format = "date") + private final LocalDate expiryDate; + + @JsonProperty("version") + @Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + + @JsonProperty("file_id") + @Schema(name = "file_id", format = "uuid") + private final UUID fileId; + + @JsonProperty("file_name") + @Schema(name = "file_name", description = "연결된 파일의 표시 파일명 (파일 없으면 null)") + private final String fileName; + + @JsonProperty("file_mime_type") + @Schema(name = "file_mime_type", description = "연결된 파일의 MIME 타입 (파일 없으면 null)") + private final String fileMimeType; + + @JsonProperty("file_size") + @Schema(name = "file_size", description = "연결된 파일의 크기(byte) (파일 없으면 null)") + private final Long fileSize; + + @JsonProperty("file_scan_status") + @Schema(name = "file_scan_status", description = "연결된 파일의 검사 상태 (파일 없으면 null)") + private final ScanStatus fileScanStatus; + + private DocumentDetailResponse( + UUID workerDocumentId, + UUID workerId, + UUID taskId, + String displayName, + DocumentType documentType, + SubmissionStatus submissionStatus, + LocalDate expiryDate, + long version, + UUID fileId, + String fileName, + String fileMimeType, + Long fileSize, + ScanStatus fileScanStatus + ) { + this.workerDocumentId = workerDocumentId; + this.workerId = workerId; + this.taskId = taskId; + this.displayName = displayName; + this.documentType = documentType; + this.submissionStatus = submissionStatus; + this.expiryDate = expiryDate; + this.version = version; + this.fileId = fileId; + this.fileName = fileName; + this.fileMimeType = fileMimeType; + this.fileSize = fileSize; + this.fileScanStatus = fileScanStatus; + } + + public static DocumentDetailResponse from(DocumentDetailResult result) { + var document = result.document(); + var storedFile = result.storedFile(); + return new DocumentDetailResponse( + document.workerDocumentId(), + document.workerId(), + document.taskId(), + result.workerDisplayName(), + document.documentType(), + document.submissionStatus(), + document.expiryDate(), + document.version(), + document.fileId(), + storedFile == null ? null : storedFile.name(), + storedFile == null ? null : storedFile.mimeType(), + storedFile == null ? null : storedFile.size(), + storedFile == null ? null : storedFile.scanStatus() + ); + } + + public UUID getWorkerDocumentId() { + return workerDocumentId; + } + + public UUID getWorkerId() { + return workerId; + } + + public UUID getTaskId() { + return taskId; + } + + public String getWorkerDisplayName() { + return displayName; + } + + public DocumentType getDocumentType() { + return documentType; + } + + public SubmissionStatus getSubmissionStatus() { + return submissionStatus; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public long getVersion() { + return version; + } + + public UUID getFileId() { + return fileId; + } + + public String getFileName() { + return fileName; + } + + public String getFileMimeType() { + return fileMimeType; + } + + public Long getFileSize() { + return fileSize; + } + + public ScanStatus getFileScanStatus() { + return fileScanStatus; + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java index 515eab1..427f1f3 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -13,7 +13,6 @@ import com.fowoco.server.worker.domain.WorkerDocument; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Function; @@ -49,9 +48,7 @@ public DocumentDetailResult findById(UUID workerDocumentId, ActorContext actor) .findByIdAndCompanyId(workerDocumentId, companyId) .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND)); String displayName = workerRepository - .findAllByWorkerIdsAndCompanyId(Set.of(document.workerId()), companyId) - .stream() - .findFirst() + .findByWorkerIdAndCompanyId(document.workerId(), companyId) .map(Worker::displayName) .orElse(null); StoredFile storedFile = document.fileId() == null From 9a74b1c831ddbe1b8841070457458cd4b7a28bfd Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 17:00:34 +0900 Subject: [PATCH 05/13] =?UTF-8?q?test(document):=20=EB=8B=A8=EA=B1=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B2=80=EC=A6=9D=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(=EC=A0=95=EC=83=81?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C,=20=ED=83=80=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EC=B0=A8=EB=8B=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/api/DocumentController.java | 28 +++++++++++++++++++ ...WorkerDocumentSecurityIntegrationTest.java | 25 +++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/main/java/com/fowoco/server/document/api/DocumentController.java b/src/main/java/com/fowoco/server/document/api/DocumentController.java index 56bffc4..3b313f0 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentController.java @@ -2,6 +2,7 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentDetailResult; import com.fowoco.server.document.application.DocumentPageResult; import com.fowoco.server.document.application.DocumentService; import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; @@ -23,6 +24,7 @@ import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -96,4 +98,30 @@ public DocumentPageResponse list( .toList(); return new DocumentPageResponse(items, result.page(), result.size(), result.totalElements()); } + + @Operation( + operationId = "getDocument", + summary = "서류 단건 상세 조회", + description = "연결된 파일 정보와 expected_version 기준값을 함께 반환합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentDetailResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping(path = "/{documentId}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentDetailResponse findById(@Parameter(description = "서류 ID") @PathVariable UUID documentId) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + DocumentDetailResult result = documentService.findById(documentId, actor); + return DocumentDetailResponse.from(result); + } } diff --git a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java index 48c87c3..2dfbb07 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java @@ -175,6 +175,31 @@ void listDocumentsFiltersByTaskId() throws Exception { assertThat(ids).doesNotContain(documentIdWithoutTask); } + @Test + void getDocumentReturnsDetailWithVersionAndFileInfo() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String documentId = registerDocument(accessToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.worker_document_id")).isEqualTo(documentId); + assertThat(JsonPath.read(response.body(), "$.worker_id")).isEqualTo(workerIdInCompanyA); + assertThat(JsonPath.read(response.body(), "$.version").longValue()).isZero(); + assertThat(JsonPath.read(response.body(), "$.file_id")).isNull(); + } + + @Test + void getDocumentFromAnotherCompanyReturnsNotFound() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String documentId = registerDocument(companyAToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, companyBToken); + + assertThat(response.statusCode()).isEqualTo(404); + } + @Test void documentFromAnotherCompanyIsReturnedAsNotFoundOnPatch() throws Exception { String companyAToken = accessToken(login(HR_A_EMAIL)); From 7377fad7f090dac80ac553ddefdc992f0d7b1757 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 5 Aug 2026 20:04:20 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat(file):=20HWPX(application/hwp+zip)?= =?UTF-8?q?=20MIME=20=ED=83=80=EC=9E=85=20=EC=A7=80=EC=9B=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/fowoco/server/file/application/FileService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java index 1a9b0b1..b25f12c 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -41,7 +41,8 @@ public class FileService { "image/jpeg", "image/png", "image/webp", - "application/pdf" + "application/pdf", + "application/hwp+zip" ); private final StoredFileRepository storedFileRepository; From 18a080c8912c1223a1fd66996b9bc2c9856b9f50 Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 10:39:19 +0900 Subject: [PATCH 07/13] =?UTF-8?q?test(file):=20FileSecurityIntegrationTest?= =?UTF-8?q?=EC=97=90=20@AfterAll=20=EC=A0=95=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/FileSecurityIntegrationTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index 5de3af7..69d0698 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -88,6 +88,11 @@ void resetFileState() { jdbcTemplate.update("DELETE FROM stored_file"); } + @org.junit.jupiter.api.AfterAll + void cleanupFileState() { + jdbcTemplate.update("DELETE FROM stored_file"); + } + @Test void uploadSucceedsAndAppendsAuditEvent() throws Exception { String token = accessToken(login(HR_A_EMAIL)); @@ -121,6 +126,18 @@ void uploadRejectsUnsupportedMimeType() throws Exception { assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); } + @Test + void uploadAcceptsHwpxMimeType() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "contract.hwpx", "application/hwp+zip", "hwpx content".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwpx"); + } + @Test void uploadRejectsNonExistentTaskId() throws Exception { String token = accessToken(login(HR_A_EMAIL)); From 2d96cf11468149ec5b2027ff27ea17b24870431a Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 17:32:51 +0900 Subject: [PATCH 08/13] =?UTF-8?q?build:=20HWP=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=8B=88=EC=B2=98=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Apache=20POI(POIFS)=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index da5a982..465b2f0 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' compileOnly 'org.projectlombok:lombok' + implementation 'org.apache.poi:poi:5.4.0' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.flywaydb:flyway-database-postgresql' runtimeOnly 'org.postgresql:postgresql' From ea0adc77be1e5f5fa1db9ab878c681a512fb0a76 Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 20:19:00 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(file):=20HwpSignatureValidator=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(OLE=20FileHeader=20=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BC=20signature=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/HwpSignatureValidator.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java diff --git a/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java b/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java new file mode 100644 index 0000000..79b7f02 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/validation/HwpSignatureValidator.java @@ -0,0 +1,36 @@ +package com.fowoco.server.file.application.validation; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.springframework.stereotype.Component; + +/** + * HWP 파일은 OLE Compound File 구조이며, 정식 MIME 타입이 없다. + * 파일 내부의 "FileHeader" 스트림 앞부분에 있는 "HWP Document File" 문자열로 + * 실제 HWP 문서인지 확인한다. + */ +@Component +public class HwpSignatureValidator { + + private static final String FILE_HEADER_STREAM_NAME = "FileHeader"; + private static final String HWP_SIGNATURE = "HWP Document File"; + + public boolean isValidHwp(byte[] content) { + try (POIFSFileSystem fileSystem = new POIFSFileSystem(new ByteArrayInputStream(content))) { + if (!fileSystem.getRoot().hasEntry(FILE_HEADER_STREAM_NAME)) { + return false; + } + byte[] header = fileSystem.createDocumentInputStream(FILE_HEADER_STREAM_NAME) + .readAllBytes(); + if (header.length < HWP_SIGNATURE.length()) { + return false; + } + String signature = new String(header, 0, HWP_SIGNATURE.length(), StandardCharsets.US_ASCII); + return HWP_SIGNATURE.equals(signature); + } catch (IOException | RuntimeException exception) { + return false; + } + } +} From 5aefdbf93d6bf135230ef547deed6b8b4f13e7e9 Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 21:41:44 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(file):=20HWP=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EC=9E=90=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=8B=88=EC=B2=98=20=EA=B2=80=EC=A6=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/file/application/FileService.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java index b25f12c..4a70971 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -14,6 +14,7 @@ import com.fowoco.server.file.application.error.FileErrorCode; import com.fowoco.server.file.application.port.FileStorage; import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.application.validation.HwpSignatureValidator; import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; @@ -44,8 +45,10 @@ public class FileService { "application/pdf", "application/hwp+zip" ); + private static final String HWP_EXTENSION = ".hwp"; private final StoredFileRepository storedFileRepository; + private final HwpSignatureValidator hwpSignatureValidator; private final FileStorage fileStorage; private final TaskRepository taskRepository; private final WorkerRepository workerRepository; @@ -56,6 +59,7 @@ public class FileService { public FileService( StoredFileRepository storedFileRepository, + HwpSignatureValidator hwpSignatureValidator, FileStorage fileStorage, TaskRepository taskRepository, WorkerRepository workerRepository, @@ -65,6 +69,7 @@ public FileService( Clock clock ) { this.storedFileRepository = storedFileRepository; + this.hwpSignatureValidator = hwpSignatureValidator; this.fileStorage = fileStorage; this.taskRepository = taskRepository; this.workerRepository = workerRepository; @@ -81,7 +86,12 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } - if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { + byte[] contentBytes = readAllBytes(command.content()); + if (isHwpExtension(command.name())) { + if (!hwpSignatureValidator.isValidHwp(contentBytes)) { + throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + } else if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } if (command.taskId() != null) { @@ -110,7 +120,7 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM now ); - fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + fileStorage.store(storageKey, new java.io.ByteArrayInputStream(contentBytes), command.size(), command.mimeType()); storedFileRepository.insert(storedFile); appendAudit( @@ -165,4 +175,16 @@ private int rolePriority(UserRole role) { case VIEWER -> 2; }; } + + private boolean isHwpExtension(String name) { + return name != null && name.toLowerCase(java.util.Locale.ROOT).endsWith(HWP_EXTENSION); + } + + private byte[] readAllBytes(java.io.InputStream content) { + try { + return content.readAllBytes(); + } catch (java.io.IOException exception) { + throw new IllegalStateException("파일 내용을 읽을 수 없습니다.", exception); + } + } } From b6c43071ec32e3b9c3a3740002376ea40d179ec8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 22:13:14 +0900 Subject: [PATCH 11/13] =?UTF-8?q?test(document):=20merge=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EC=9C=A0=EC=8B=A4=EB=90=9C=20?= =?UTF-8?q?=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...WorkerDocumentSecurityIntegrationTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java index e52fe64..53e3225 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java @@ -174,6 +174,31 @@ void listDocumentsFiltersByTaskId() throws Exception { assertThat(ids).contains(documentIdWithTask); assertThat(ids).doesNotContain(documentIdWithoutTask); } + + @Test + void getDocumentReturnsDetailWithVersionAndFileInfo() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String documentId = registerDocument(accessToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.worker_document_id")).isEqualTo(documentId); + assertThat(JsonPath.read(response.body(), "$.worker_id")).isEqualTo(workerIdInCompanyA); + assertThat(JsonPath.read(response.body(), "$.version").longValue()).isZero(); + assertThat(JsonPath.read(response.body(), "$.file_id")).isNull(); + } + + @Test + void getDocumentFromAnotherCompanyReturnsNotFound() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String documentId = registerDocument(companyAToken, workerIdInCompanyA); + + HttpResponse response = getJson("/api/v1/documents/" + documentId, companyBToken); + + assertThat(response.statusCode()).isEqualTo(404); + } @Test void registerRejectsTaskOwnedByAnotherWorkerInSameCompany() throws Exception { From dff91aa339d185f8c237219cc47823df22bda296 Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 22:32:47 +0900 Subject: [PATCH 12/13] =?UTF-8?q?test(file):=20HwpSignatureValidator=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=8B=A4=EC=A0=9C=20=ED=86=B5=EA=B3=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EB=90=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/HwpSignatureValidatorTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java diff --git a/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java b/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java new file mode 100644 index 0000000..459fe94 --- /dev/null +++ b/src/test/java/com/fowoco/server/file/application/validation/HwpSignatureValidatorTest.java @@ -0,0 +1,52 @@ +package com.fowoco.server.file.application.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.junit.jupiter.api.Test; + +class HwpSignatureValidatorTest { + + private final HwpSignatureValidator validator = new HwpSignatureValidator(); + + @Test + void acceptsValidHwpSignature() throws Exception { + byte[] content = buildOleFile("HWP Document File"); + + assertThat(validator.isValidHwp(content)).isTrue(); + } + + @Test + void rejectsOleFileWithoutHwpSignature() throws Exception { + byte[] content = buildOleFile("Not A HWP Document"); + + assertThat(validator.isValidHwp(content)).isFalse(); + } + + @Test + void rejectsNonOleFile() { + byte[] content = "plain text content, not an OLE file".getBytes(StandardCharsets.UTF_8); + + assertThat(validator.isValidHwp(content)).isFalse(); + } + + @Test + void rejectsEmptyContent() { + assertThat(validator.isValidHwp(new byte[0])).isFalse(); + } + + private byte[] buildOleFile(String signatureText) throws Exception { + try (POIFSFileSystem fileSystem = new POIFSFileSystem()) { + byte[] header = new byte[256]; + byte[] signatureBytes = signatureText.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(signatureBytes, 0, header, 0, signatureBytes.length); + fileSystem.createDocument(new java.io.ByteArrayInputStream(header), "FileHeader"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + fileSystem.writeFilesystem(out); + return out.toByteArray(); + } + } +} From 492dea0e9d2c48fa2cf62638c8f39115b3de483d Mon Sep 17 00:00:00 2001 From: chaelin Date: Thu, 6 Aug 2026 22:37:07 +0900 Subject: [PATCH 13/13] =?UTF-8?q?test(file):=20HWP/HWPX=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=8B=9C=EA=B7=B8=EB=8B=88?= =?UTF-8?q?=EC=B2=98=20=EA=B2=80=EC=A6=9D,=20MIME=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=ED=99=95=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/FileSecurityIntegrationTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index 69d0698..944bee0 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -138,6 +138,45 @@ void uploadAcceptsHwpxMimeType() throws Exception { assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwpx"); } + @Test + void uploadAcceptsValidHwpFileBySignature() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + byte[] hwpContent = buildValidHwpOleFile(); + + HttpResponse response = uploadFile( + token, "contract.hwp", "application/octet-stream", hwpContent, "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwp"); + } + + @Test + void uploadRejectsHwpExtensionWithInvalidSignature() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "fake.hwp", "application/octet-stream", + "this is not a real hwp file".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + private byte[] buildValidHwpOleFile() throws Exception { + try (org.apache.poi.poifs.filesystem.POIFSFileSystem fileSystem = + new org.apache.poi.poifs.filesystem.POIFSFileSystem()) { + byte[] header = new byte[256]; + byte[] signatureBytes = "HWP Document File".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(signatureBytes, 0, header, 0, signatureBytes.length); + fileSystem.createDocument(new java.io.ByteArrayInputStream(header), "FileHeader"); + + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + fileSystem.writeFilesystem(out); + return out.toByteArray(); + } + } + @Test void uploadRejectsNonExistentTaskId() throws Exception { String token = accessToken(login(HR_A_EMAIL));