From b7e8fd0e662a5af7e3bbe4c2b1fc493d8f9e3cb3 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 26 Jul 2026 18:11:17 +0900 Subject: [PATCH 01/36] =?UTF-8?q?feat(document):=20StoredFile=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EA=B3=BC=20FileStorage=20port=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 --- .../application/port/FileStorage.java | 8 + .../server/document/domain/ScanStatus.java | 10 ++ .../server/document/domain/StoredFile.java | 140 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/port/FileStorage.java create mode 100644 src/main/java/com/fowoco/server/document/domain/ScanStatus.java create mode 100644 src/main/java/com/fowoco/server/document/domain/StoredFile.java diff --git a/src/main/java/com/fowoco/server/document/application/port/FileStorage.java b/src/main/java/com/fowoco/server/document/application/port/FileStorage.java new file mode 100644 index 0000000..0929b4e --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/port/FileStorage.java @@ -0,0 +1,8 @@ +package com.fowoco.server.document.application.port; + +import java.io.InputStream; + +public interface FileStorage { + + void store(String storageKey, InputStream content, long size, String mimeType); +} diff --git a/src/main/java/com/fowoco/server/document/domain/ScanStatus.java b/src/main/java/com/fowoco/server/document/domain/ScanStatus.java new file mode 100644 index 0000000..91bf52f --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/ScanStatus.java @@ -0,0 +1,10 @@ +package com.fowoco.server.document.domain; + +/** + * 악성파일 검사 상태. + * 지금은 NOT_SCANNED로 고정하고 파일 연결은 허용한다. 실제 검증은 후속 이슈에서 + * 검사 인프라가 구축된 뒤 CLEAN/INFECTED 등의 실제 판정값을 채우게 될 것이다. + */ +public enum ScanStatus { + NOT_SCANNED +} diff --git a/src/main/java/com/fowoco/server/document/domain/StoredFile.java b/src/main/java/com/fowoco/server/document/domain/StoredFile.java new file mode 100644 index 0000000..ce75c31 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/StoredFile.java @@ -0,0 +1,140 @@ +package com.fowoco.server.document.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class StoredFile { + + private static final int MAX_NAME_LENGTH = 255; + private static final int MAX_MIME_TYPE_LENGTH = 127; + private static final int MAX_PURPOSE_LENGTH = 60; + + private final UUID storedFileId; + private final UUID companyId; + private final String name; + private final String mimeType; + private final long size; + private final String purpose; + private final UUID taskId; + private final UUID workerId; + private final String storageKey; + private final ScanStatus scanStatus; + private final Instant createdAt; + + public StoredFile( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + ScanStatus scanStatus, + Instant createdAt + ) { + this.storedFileId = Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.name = requireBounded(name, MAX_NAME_LENGTH, "name"); + this.mimeType = requireBounded(mimeType, MAX_MIME_TYPE_LENGTH, "mimeType"); + if (size <= 0) { + throw new IllegalArgumentException("size must be positive"); + } + this.size = size; + this.purpose = requireBounded(purpose, MAX_PURPOSE_LENGTH, "purpose"); + this.taskId = taskId; + this.workerId = workerId; + this.storageKey = Objects.requireNonNull(storageKey, "storageKey must not be null"); + this.scanStatus = Objects.requireNonNull(scanStatus, "scanStatus must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + } + + /** + * 파일 업로드 등록. storageKey는 원본 파일명과 무관하게 서버가 생성한 값을 넘겨받는다 + * (파일명으로 저장 경로를 만들지 않는다는 #13 보안 규칙). + * scanStatus는 검사 인프라가 없는 지금은 항상 NOT_SCANNED로 고정한다. + */ + public static StoredFile create( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + Instant now + ) { + return new StoredFile( + storedFileId, + companyId, + name, + mimeType, + size, + purpose, + taskId, + workerId, + storageKey, + ScanStatus.NOT_SCANNED, + now + ); + } + + private static String requireBounded(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + String normalized = value.strip(); + if (normalized.length() > maxLength) { + throw new IllegalArgumentException(fieldName + " must not exceed " + maxLength + " characters"); + } + return normalized; + } + + public UUID storedFileId() { + return storedFileId; + } + + public UUID companyId() { + return companyId; + } + + public String name() { + return name; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String purpose() { + return purpose; + } + + public UUID taskId() { + return taskId; + } + + public UUID workerId() { + return workerId; + } + + public String storageKey() { + return storageKey; + } + + public ScanStatus scanStatus() { + return scanStatus; + } + + public Instant createdAt() { + return createdAt; + } +} From b3140c3970ae0444558b01fb39ed256bc41d1609 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 26 Jul 2026 18:25:26 +0900 Subject: [PATCH 02/36] =?UTF-8?q?feat(document):=20Local=20FileStorage=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/LocalFileStorage.java | 39 +++++++++++++++++++ src/main/resources/application.yaml | 2 + 2 files changed, 41 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java diff --git a/src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java b/src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java new file mode 100644 index 0000000..3b73025 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java @@ -0,0 +1,39 @@ +package com.fowoco.server.document.infrastructure; + +import com.fowoco.server.document.application.port.FileStorage; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 로컬 디스크에 파일을 저장하는 구현체. Controller/Service는 이 클래스의 존재를 모르고 + * FileStorage 인터페이스만 알아야 한다 (#13 보안 규칙 — Local 경로를 상위 계층에 노출하지 않음). + * 나중에 S3 호환 저장소로 교체할 때는 이 클래스만 새 구현체로 바꿔치기하면 된다. + */ +@Component +public class LocalFileStorage implements FileStorage { + + private final Path rootDirectory; + + public LocalFileStorage(@Value("${app.file-storage.local-path}") String localPath) { + this.rootDirectory = Path.of(localPath); + } + + @Override + public void store(String storageKey, InputStream content, long size, String mimeType) { + try { + Files.createDirectories(rootDirectory); + Path target = rootDirectory.resolve(storageKey).normalize(); + if (!target.startsWith(rootDirectory)) { + throw new IllegalArgumentException("storageKey must not escape the storage root"); + } + Files.copy(content, target); + } catch (IOException exception) { + throw new UncheckedIOException("failed to store file: " + storageKey, exception); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa70e39..7c4c88c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,6 +50,8 @@ app: catalog: location: ${WORKFLOW_CATALOG_LOCATION:classpath:workflow/catalog-projection.local.json} allow-unreleased: ${WORKFLOW_CATALOG_ALLOW_UNRELEASED:true} + file-storage: + local-path: ${FILE_STORAGE_LOCAL_PATH:./data/files} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} demo-seed: From 53815ffa8e6124e17741ae7c73b72d2b048daca5 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 26 Jul 2026 19:06:05 +0900 Subject: [PATCH 03/36] =?UTF-8?q?refactor(file):=20document=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EC=97=90=EC=84=9C=20file=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{document => file}/application/port/FileStorage.java | 2 +- .../server/{document => file}/domain/ScanStatus.java | 2 +- .../server/{document => file}/domain/StoredFile.java | 3 +-- .../infrastructure/LocalFileStorage.java | 9 ++++----- 4 files changed, 7 insertions(+), 9 deletions(-) rename src/main/java/com/fowoco/server/{document => file}/application/port/FileStorage.java (73%) rename src/main/java/com/fowoco/server/{document => file}/domain/ScanStatus.java (87%) rename src/main/java/com/fowoco/server/{document => file}/domain/StoredFile.java (96%) rename src/main/java/com/fowoco/server/{document => file}/infrastructure/LocalFileStorage.java (78%) diff --git a/src/main/java/com/fowoco/server/document/application/port/FileStorage.java b/src/main/java/com/fowoco/server/file/application/port/FileStorage.java similarity index 73% rename from src/main/java/com/fowoco/server/document/application/port/FileStorage.java rename to src/main/java/com/fowoco/server/file/application/port/FileStorage.java index 0929b4e..a0348ff 100644 --- a/src/main/java/com/fowoco/server/document/application/port/FileStorage.java +++ b/src/main/java/com/fowoco/server/file/application/port/FileStorage.java @@ -1,4 +1,4 @@ -package com.fowoco.server.document.application.port; +package com.fowoco.server.file.application.port; import java.io.InputStream; diff --git a/src/main/java/com/fowoco/server/document/domain/ScanStatus.java b/src/main/java/com/fowoco/server/file/domain/ScanStatus.java similarity index 87% rename from src/main/java/com/fowoco/server/document/domain/ScanStatus.java rename to src/main/java/com/fowoco/server/file/domain/ScanStatus.java index 91bf52f..e5edd9c 100644 --- a/src/main/java/com/fowoco/server/document/domain/ScanStatus.java +++ b/src/main/java/com/fowoco/server/file/domain/ScanStatus.java @@ -1,4 +1,4 @@ -package com.fowoco.server.document.domain; +package com.fowoco.server.file.domain; /** * 악성파일 검사 상태. diff --git a/src/main/java/com/fowoco/server/document/domain/StoredFile.java b/src/main/java/com/fowoco/server/file/domain/StoredFile.java similarity index 96% rename from src/main/java/com/fowoco/server/document/domain/StoredFile.java rename to src/main/java/com/fowoco/server/file/domain/StoredFile.java index ce75c31..6bb5ed1 100644 --- a/src/main/java/com/fowoco/server/document/domain/StoredFile.java +++ b/src/main/java/com/fowoco/server/file/domain/StoredFile.java @@ -1,4 +1,4 @@ -package com.fowoco.server.document.domain; +package com.fowoco.server.file.domain; import java.time.Instant; import java.util.Objects; @@ -53,7 +53,6 @@ public StoredFile( /** * 파일 업로드 등록. storageKey는 원본 파일명과 무관하게 서버가 생성한 값을 넘겨받는다 - * (파일명으로 저장 경로를 만들지 않는다는 #13 보안 규칙). * scanStatus는 검사 인프라가 없는 지금은 항상 NOT_SCANNED로 고정한다. */ public static StoredFile create( diff --git a/src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java similarity index 78% rename from src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java rename to src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java index 3b73025..73ef157 100644 --- a/src/main/java/com/fowoco/server/document/infrastructure/LocalFileStorage.java +++ b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java @@ -1,6 +1,6 @@ -package com.fowoco.server.document.infrastructure; +package com.fowoco.server.file.infrastructure; -import com.fowoco.server.document.application.port.FileStorage; +import com.fowoco.server.file.application.port.FileStorage; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; @@ -10,9 +10,8 @@ import org.springframework.stereotype.Component; /** - * 로컬 디스크에 파일을 저장하는 구현체. Controller/Service는 이 클래스의 존재를 모르고 - * FileStorage 인터페이스만 알아야 한다 (#13 보안 규칙 — Local 경로를 상위 계층에 노출하지 않음). - * 나중에 S3 호환 저장소로 교체할 때는 이 클래스만 새 구현체로 바꿔치기하면 된다. + * 로컬 디스크에 파일을 저장하는 구현체. Controller/Service는 이 클래스의 존재를 모르고 FileStorage 인터페이스만 알아야 한다 + * 나중에 S3 호환 저장소로 교체할 때는 이 클래스만 새 구현체로 바꿔치기. */ @Component public class LocalFileStorage implements FileStorage { From 03d420eabcf97d954bd44278ae5c35381ec6950d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 26 Jul 2026 19:27:30 +0900 Subject: [PATCH 04/36] =?UTF-8?q?test(file):=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20FakeFileStorage=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/file/support/FakeFileStorage.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/test/java/com/fowoco/server/file/support/FakeFileStorage.java diff --git a/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java b/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java new file mode 100644 index 0000000..4606452 --- /dev/null +++ b/src/test/java/com/fowoco/server/file/support/FakeFileStorage.java @@ -0,0 +1,34 @@ +package com.fowoco.server.file.support; +import com.fowoco.server.file.application.port.FileStorage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 테스트 전용 FileStorage. 실제 디스크에 쓰지 않고 메모리에만 기록. + */ + +public class FakeFileStorage implements FileStorage { + private final Map storedContents = new ConcurrentHashMap<>(); + @Override + public void store(String storageKey, InputStream content, long size, String mimeType) { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + content.transferTo(buffer); + storedContents.put(storageKey, buffer.toByteArray()); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } + public boolean contains(String storageKey) { + return storedContents.containsKey(storageKey); + } + public byte[] contentOf(String storageKey) { + return storedContents.get(storageKey); + } + public void clear() { + storedContents.clear(); + } +} From 33ba966a1ff54dd8c3cbdbac5eeb9e10773d5e28 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 00:26:51 +0900 Subject: [PATCH 05/36] =?UTF-8?q?feat(file):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9D=91=EB=8B=B5=20DTO=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 --- .../server/file/api/FileUploadResponse.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/api/FileUploadResponse.java diff --git a/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java b/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java new file mode 100644 index 0000000..bdf89b7 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/api/FileUploadResponse.java @@ -0,0 +1,73 @@ +package com.fowoco.server.file.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(name = "FileUploadResponse", description = "파일 업로드 응답") +public final class FileUploadResponse { + + @JsonProperty("file_id") + @Schema(name = "file_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID fileId; + + @JsonProperty("name") + @Schema(description = "원본 파일명", requiredMode = Schema.RequiredMode.REQUIRED) + private final String name; + + @JsonProperty("mime_type") + @Schema(name = "mime_type", requiredMode = Schema.RequiredMode.REQUIRED) + private final String mimeType; + + @JsonProperty("size") + @Schema(description = "파일 크기(byte)", requiredMode = Schema.RequiredMode.REQUIRED) + private final long size; + + @JsonProperty("scan_status") + @Schema( + name = "scan_status", + description = "악성파일 검사 상태. 현재는 검사 인프라 미도입으로 항상 NOT_SCANNED.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final ScanStatus scanStatus; + + private FileUploadResponse(UUID fileId, String name, String mimeType, long size, ScanStatus scanStatus) { + this.fileId = fileId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.scanStatus = scanStatus; + } + + public static FileUploadResponse from(StoredFile storedFile) { + return new FileUploadResponse( + storedFile.storedFileId(), + storedFile.name(), + storedFile.mimeType(), + storedFile.size(), + storedFile.scanStatus() + ); + } + + public UUID getFileId() { + return fileId; + } + + public String getName() { + return name; + } + + public String getMimeType() { + return mimeType; + } + + public long getSize() { + return size; + } + + public ScanStatus getScanStatus() { + return scanStatus; + } +} From efe7ffff652cb1479235e596f64b044f772661ba Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 00:30:46 +0900 Subject: [PATCH 06/36] =?UTF-8?q?feat(file):=20StoredFileRepository=20port?= =?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 --- .../file/application/port/StoredFileRepository.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java diff --git a/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java b/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java new file mode 100644 index 0000000..e739091 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/port/StoredFileRepository.java @@ -0,0 +1,12 @@ +package com.fowoco.server.file.application.port; + +import com.fowoco.server.file.domain.StoredFile; +import java.util.Optional; +import java.util.UUID; + +public interface StoredFileRepository { + + void insert(StoredFile storedFile); + + Optional findByIdAndCompanyId(UUID storedFileId, UUID companyId); +} From 6d19223b27a99a926e6ebe6e90b8ead94df4ca0a Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 01:20:47 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat(file):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20Command,=20Service,=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=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 --- .../file/application/FileCreateCommand.java | 68 +++++++++++++++++ .../server/file/application/FileService.java | 76 +++++++++++++++++++ .../file/application/error/FileErrorCode.java | 33 ++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/application/FileCreateCommand.java create mode 100644 src/main/java/com/fowoco/server/file/application/FileService.java create mode 100644 src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java diff --git a/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java b/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java new file mode 100644 index 0000000..57b5754 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/FileCreateCommand.java @@ -0,0 +1,68 @@ +package com.fowoco.server.file.application; + +import java.io.InputStream; +import java.util.UUID; + +public final class FileCreateCommand { + + private final UUID companyId; + private final String name; + private final String mimeType; + private final long size; + private final String purpose; + private final UUID taskId; + private final UUID workerId; + private final InputStream content; + + public FileCreateCommand( + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + InputStream content + ) { + this.companyId = companyId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.purpose = purpose; + this.taskId = taskId; + this.workerId = workerId; + this.content = content; + } + + public UUID companyId() { + return companyId; + } + + public String name() { + return name; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String purpose() { + return purpose; + } + + public UUID taskId() { + return taskId; + } + + public UUID workerId() { + return workerId; + } + + public InputStream content() { + return content; + } +} diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java new file mode 100644 index 0000000..f6b1429 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -0,0 +1,76 @@ +package com.fowoco.server.file.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +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.domain.StoredFile; +import java.time.Clock; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class FileService { + + /** + * 확정된 기준 없음. 20으로 시작하고, + * 실제 사용 파일(신분증 사진, 계약서 PDF 등) 확인되면 조정 + */ + private static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024; + private static final Set ALLOWED_MIME_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/webp", + "application/pdf" + ); + + private final StoredFileRepository storedFileRepository; + private final FileStorage fileStorage; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public FileService( + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.storedFileRepository = storedFileRepository; + this.fileStorage = fileStorage; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public StoredFile upload(FileCreateCommand command) { + if (command.size() > MAX_FILE_SIZE_BYTES) { + throw new ApiException(FileErrorCode.FILE_TOO_LARGE); + } + if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { + throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + + UUID storedFileId = uuidGenerator.generate(); + String storageKey = storedFileId.toString(); + + StoredFile storedFile = StoredFile.create( + storedFileId, + command.companyId(), + command.name(), + command.mimeType(), + command.size(), + command.purpose(), + command.taskId(), + command.workerId(), + storageKey, + clock.instant() + ); + + fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + storedFileRepository.insert(storedFile); + return storedFile; + } +} diff --git a/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java new file mode 100644 index 0000000..ff7696e --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java @@ -0,0 +1,33 @@ +package com.fowoco.server.file.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum FileErrorCode implements ApiErrorCode { + FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 허용 범위를 초과했습니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String defaultMessage; + + FileErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} From 0bbebd2bc0d1eec394b0a8fab15cc72757e2b5a7 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 01:54:02 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat(file):=20POST=20/files=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=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 --- .../server/file/api/FileController.java | 100 ++++++++++++++++++ src/main/resources/application.yaml | 4 + 2 files changed, 104 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/api/FileController.java diff --git a/src/main/java/com/fowoco/server/file/api/FileController.java b/src/main/java/com/fowoco/server/file/api/FileController.java new file mode 100644 index 0000000..c57e0a5 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/api/FileController.java @@ -0,0 +1,100 @@ +package com.fowoco.server.file.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.file.application.FileCreateCommand; +import com.fowoco.server.file.application.FileService; +import com.fowoco.server.file.domain.StoredFile; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "File", description = "공통 파일 업로드") +@RestController +@RequestMapping("/api/v1/files") +@SecurityRequirement(name = "bearerAuth") +public class FileController { + + private final FileService fileService; + private final ActorContextProvider actorContextProvider; + + public FileController(FileService fileService, ActorContextProvider actorContextProvider) { + this.fileService = fileService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "uploadFile", + summary = "파일 업로드", + description = "분석·증빙·근로자 제출에 사용할 파일을 안전하게 저장하고 fileId를 발급합니다. " + + "악성파일 검사 인프라는 아직 없어 scan_status는 항상 NOT_SCANNED로 반환합니다. " + + "허용 크기·형식은 TODO — 확정 기준 없어 상식적인 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "업로드 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = FileUploadResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse( + responseCode = "413", + description = "파일 크기 초과. OpenApiConfig에 공통 413 응답이 아직 없어 인라인으로 정의함" + ), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public ResponseEntity upload( + @Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file, + @Parameter(description = "파일 용도") @RequestParam("purpose") String purpose, + @Parameter(description = "연결할 업무 ID") @RequestParam(value = "taskId", required = false) UUID taskId, + @Parameter(description = "연결할 근로자 ID") @RequestParam(value = "workerId", required = false) UUID workerId + ) { + if (file.isEmpty() || file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "업로드할 파일과 파일명이 필요합니다."); + } + + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + try { + FileCreateCommand command = new FileCreateCommand( + companyId, + file.getOriginalFilename(), + file.getContentType(), + file.getSize(), + purpose, + taskId, + workerId, + file.getInputStream() + ); + StoredFile storedFile = fileService.upload(command); + return ResponseEntity.status(HttpStatus.CREATED).body(FileUploadResponse.from(storedFile)); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read uploaded file", exception); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7c4c88c..ab58411 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,6 +15,10 @@ spring: enabled: true locations: classpath:db/migration clean-disabled: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB jackson: property-naming-strategy: SNAKE_CASE time-zone: UTC From dca98db568e17e4ff7ec0fbcac8432595468a1ab Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 02:01:28 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat(file):=20StoredFile=20JPA=20Entity?= =?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 --- .../persistence/StoredFileJpaEntity.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java diff --git a/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java new file mode 100644 index 0000000..bb916af --- /dev/null +++ b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java @@ -0,0 +1,115 @@ +package com.fowoco.server.file.infrastructure.persistence; + +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "stored_file") +public class StoredFileJpaEntity { + + @Id + @Column(name = "stored_file_id", nullable = false, updatable = false) + private UUID storedFileId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "mime_type", nullable = false, length = 127) + private String mimeType; + + @Column(name = "size", nullable = false) + private long size; + + @Column(name = "purpose", nullable = false, length = 60) + private String purpose; + + @Column(name = "task_id") + private UUID taskId; + + @Column(name = "worker_id") + private UUID workerId; + + @Column(name = "storage_key", nullable = false, updatable = false, length = 255) + private String storageKey; + + @Enumerated(EnumType.STRING) + @Column(name = "scan_status", nullable = false, length = 20) + private ScanStatus scanStatus; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected StoredFileJpaEntity() { + } + + private StoredFileJpaEntity( + UUID storedFileId, + UUID companyId, + String name, + String mimeType, + long size, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + ScanStatus scanStatus, + Instant createdAt + ) { + this.storedFileId = storedFileId; + this.companyId = companyId; + this.name = name; + this.mimeType = mimeType; + this.size = size; + this.purpose = purpose; + this.taskId = taskId; + this.workerId = workerId; + this.storageKey = storageKey; + this.scanStatus = scanStatus; + this.createdAt = createdAt; + } + + public static StoredFileJpaEntity fromDomain(StoredFile storedFile) { + Objects.requireNonNull(storedFile, "storedFile must not be null"); + return new StoredFileJpaEntity( + storedFile.storedFileId(), + storedFile.companyId(), + storedFile.name(), + storedFile.mimeType(), + storedFile.size(), + storedFile.purpose(), + storedFile.taskId(), + storedFile.workerId(), + storedFile.storageKey(), + storedFile.scanStatus(), + storedFile.createdAt() + ); + } + + public StoredFile toDomain() { + return new StoredFile( + storedFileId, + companyId, + name, + mimeType, + size, + purpose, + taskId, + workerId, + storageKey, + scanStatus, + createdAt + ); + } +} From c0fee6c76734feac3dbf5852fad34e7b80d7e6ca Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 02:06:16 +0900 Subject: [PATCH 10/36] =?UTF-8?q?feat(file):=20StoredFile=20JPA=20Reposito?= =?UTF-8?q?ry=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/JpaStoredFileRepository.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java diff --git a/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java b/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java new file mode 100644 index 0000000..c53f566 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/infrastructure/persistence/JpaStoredFileRepository.java @@ -0,0 +1,46 @@ +package com.fowoco.server.file.infrastructure.persistence; + +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaStoredFileRepository implements StoredFileRepository { + + private final EntityManager entityManager; + + public JpaStoredFileRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(StoredFile storedFile) { + Objects.requireNonNull(storedFile, "storedFile must not be null"); + entityManager.persist(StoredFileJpaEntity.fromDomain(storedFile)); + entityManager.flush(); + } + + @Override + public Optional findByIdAndCompanyId(UUID storedFileId, UUID companyId) { + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select storedFile + from StoredFileJpaEntity storedFile + where storedFile.storedFileId = :storedFileId + and storedFile.companyId = :companyId + """, + StoredFileJpaEntity.class + ) + .setParameter("storedFileId", storedFileId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(StoredFileJpaEntity::toDomain); + } +} From f545e4d3b991baff6a9d7f42bb7a034c1208a7fe Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 02:21:47 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat(file):=20StoredFile=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20migration=20=EC=B6=94=EA=B0=80=20(V8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/file/api/FileController.java | 2 +- .../db/migration/V8__create_stored_file.sql | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V8__create_stored_file.sql diff --git a/src/main/java/com/fowoco/server/file/api/FileController.java b/src/main/java/com/fowoco/server/file/api/FileController.java index c57e0a5..fce9840 100644 --- a/src/main/java/com/fowoco/server/file/api/FileController.java +++ b/src/main/java/com/fowoco/server/file/api/FileController.java @@ -46,7 +46,7 @@ public FileController(FileService fileService, ActorContextProvider actorContext summary = "파일 업로드", description = "분석·증빙·근로자 제출에 사용할 파일을 안전하게 저장하고 fileId를 발급합니다. " + "악성파일 검사 인프라는 아직 없어 scan_status는 항상 NOT_SCANNED로 반환합니다. " - + "허용 크기·형식은 TODO — 확정 기준 없어 상식적인 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." + + "허용 크기·형식은 확정 기준 없어 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." ) @ApiResponses({ @ApiResponse( diff --git a/src/main/resources/db/migration/V8__create_stored_file.sql b/src/main/resources/db/migration/V8__create_stored_file.sql new file mode 100644 index 0000000..e632660 --- /dev/null +++ b/src/main/resources/db/migration/V8__create_stored_file.sql @@ -0,0 +1,27 @@ +CREATE TABLE stored_file ( + stored_file_id UUID NOT NULL, + company_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + mime_type VARCHAR(127) NOT NULL, + size BIGINT NOT NULL, + purpose VARCHAR(60) NOT NULL, + task_id UUID, + worker_id UUID, + storage_key VARCHAR(255) NOT NULL, + scan_status VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_stored_file PRIMARY KEY (stored_file_id), + CONSTRAINT uq_stored_file_storage_key UNIQUE (storage_key), + CONSTRAINT fk_stored_file_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT ck_stored_file_name_not_blank CHECK (CHAR_LENGTH(TRIM(name)) > 0), + CONSTRAINT ck_stored_file_mime_type_not_blank CHECK (CHAR_LENGTH(TRIM(mime_type)) > 0), + CONSTRAINT ck_stored_file_purpose_not_blank CHECK (CHAR_LENGTH(TRIM(purpose)) > 0), + CONSTRAINT ck_stored_file_size_positive CHECK (size > 0), + CONSTRAINT ck_stored_file_scan_status + CHECK (scan_status IN ('NOT_SCANNED')) +); + +CREATE INDEX idx_stored_file_company ON stored_file (company_id); +CREATE INDEX idx_stored_file_company_task ON stored_file (company_id, task_id); +CREATE INDEX idx_stored_file_company_worker ON stored_file (company_id, worker_id); From 354d733f910da7e47a340136ee477cec108e0970 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 02:49:28 +0900 Subject: [PATCH 12/36] =?UTF-8?q?fix(file):=20StoredFile=EC=97=90=20task?= =?UTF-8?q?=C2=B7worker=20=EB=B3=B5=ED=95=A9=20FK=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/db/migration/V8__create_stored_file.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/resources/db/migration/V8__create_stored_file.sql b/src/main/resources/db/migration/V8__create_stored_file.sql index e632660..5916ab2 100644 --- a/src/main/resources/db/migration/V8__create_stored_file.sql +++ b/src/main/resources/db/migration/V8__create_stored_file.sql @@ -1,3 +1,5 @@ +-- purpose: #7(Worker Link)도 FileStorage를 재사용할 예정이라 enum 값이 +-- 여러 이슈에 걸쳐 공유됨. 확정 전까지 CHECK 제약 없이 자유 문자열로 둔다. CREATE TABLE stored_file ( stored_file_id UUID NOT NULL, company_id UUID NOT NULL, @@ -14,6 +16,12 @@ CREATE TABLE stored_file ( CONSTRAINT uq_stored_file_storage_key UNIQUE (storage_key), CONSTRAINT fk_stored_file_company FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stored_file_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stored_file_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, CONSTRAINT ck_stored_file_name_not_blank CHECK (CHAR_LENGTH(TRIM(name)) > 0), CONSTRAINT ck_stored_file_mime_type_not_blank CHECK (CHAR_LENGTH(TRIM(mime_type)) > 0), CONSTRAINT ck_stored_file_purpose_not_blank CHECK (CHAR_LENGTH(TRIM(purpose)) > 0), From 3e59d22ba2886d61c71c25b561d19fe17676676c Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 03:21:00 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat(file):=20#7=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=8C=80=EB=B9=84=20verified=20=ED=95=84=EB=93=9C?= =?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 --- .../java/com/fowoco/server/file/domain/StoredFile.java | 10 ++++++++++ .../persistence/StoredFileJpaEntity.java | 7 +++++++ .../resources/db/migration/V8__create_stored_file.sql | 3 +-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/file/domain/StoredFile.java b/src/main/java/com/fowoco/server/file/domain/StoredFile.java index 6bb5ed1..325829d 100644 --- a/src/main/java/com/fowoco/server/file/domain/StoredFile.java +++ b/src/main/java/com/fowoco/server/file/domain/StoredFile.java @@ -20,6 +20,7 @@ public final class StoredFile { private final UUID workerId; private final String storageKey; private final ScanStatus scanStatus; + private final boolean verified; private final Instant createdAt; public StoredFile( @@ -33,6 +34,7 @@ public StoredFile( UUID workerId, String storageKey, ScanStatus scanStatus, + boolean verified, Instant createdAt ) { this.storedFileId = Objects.requireNonNull(storedFileId, "storedFileId must not be null"); @@ -48,12 +50,15 @@ public StoredFile( this.workerId = workerId; this.storageKey = Objects.requireNonNull(storageKey, "storageKey must not be null"); this.scanStatus = Objects.requireNonNull(scanStatus, "scanStatus must not be null"); + this.verified = verified; this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); } /** * 파일 업로드 등록. storageKey는 원본 파일명과 무관하게 서버가 생성한 값을 넘겨받는다 * scanStatus는 검사 인프라가 없는 지금은 항상 NOT_SCANNED로 고정한다. + * verified는 #7(Worker Link)의 "격리 저장 → 검증 → 검증된 ID만 연결" 흐름을 위한 + * 필드로, #13(HR 내부 업로드)은 검증 절차가 없어 항상 false로 시작한다. */ public static StoredFile create( UUID storedFileId, @@ -78,6 +83,7 @@ public static StoredFile create( workerId, storageKey, ScanStatus.NOT_SCANNED, + false, now ); } @@ -133,6 +139,10 @@ public ScanStatus scanStatus() { return scanStatus; } + public boolean verified() { + return verified; + } + public Instant createdAt() { return createdAt; } diff --git a/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java index bb916af..7385595 100644 --- a/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java +++ b/src/main/java/com/fowoco/server/file/infrastructure/persistence/StoredFileJpaEntity.java @@ -48,6 +48,9 @@ public class StoredFileJpaEntity { @Column(name = "scan_status", nullable = false, length = 20) private ScanStatus scanStatus; + @Column(name = "verified", nullable = false) + private boolean verified; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -65,6 +68,7 @@ private StoredFileJpaEntity( UUID workerId, String storageKey, ScanStatus scanStatus, + boolean verified, Instant createdAt ) { this.storedFileId = storedFileId; @@ -77,6 +81,7 @@ private StoredFileJpaEntity( this.workerId = workerId; this.storageKey = storageKey; this.scanStatus = scanStatus; + this.verified = verified; this.createdAt = createdAt; } @@ -93,6 +98,7 @@ public static StoredFileJpaEntity fromDomain(StoredFile storedFile) { storedFile.workerId(), storedFile.storageKey(), storedFile.scanStatus(), + storedFile.verified(), storedFile.createdAt() ); } @@ -109,6 +115,7 @@ public StoredFile toDomain() { workerId, storageKey, scanStatus, + verified, createdAt ); } diff --git a/src/main/resources/db/migration/V8__create_stored_file.sql b/src/main/resources/db/migration/V8__create_stored_file.sql index 5916ab2..4165333 100644 --- a/src/main/resources/db/migration/V8__create_stored_file.sql +++ b/src/main/resources/db/migration/V8__create_stored_file.sql @@ -1,5 +1,3 @@ --- purpose: #7(Worker Link)도 FileStorage를 재사용할 예정이라 enum 값이 --- 여러 이슈에 걸쳐 공유됨. 확정 전까지 CHECK 제약 없이 자유 문자열로 둔다. CREATE TABLE stored_file ( stored_file_id UUID NOT NULL, company_id UUID NOT NULL, @@ -11,6 +9,7 @@ CREATE TABLE stored_file ( worker_id UUID, storage_key VARCHAR(255) NOT NULL, scan_status VARCHAR(20) NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT pk_stored_file PRIMARY KEY (stored_file_id), CONSTRAINT uq_stored_file_storage_key UNIQUE (storage_key), From 6b8c060dfe50c2b0545014d0dfa0c4d5b4ee57fc Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 10:13:17 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat(worker):=20WorkerDocument=EC=97=90?= =?UTF-8?q?=20file=5Fid=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=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 --- .../worker/api/WorkerDocumentController.java | 5 +++-- .../api/WorkerDocumentPatchRequest.java | 15 +++++++++++++ .../WorkerDocumentPatchCommand.java | 7 +++++++ .../application/WorkerDocumentService.java | 21 ++++++++++++++++++- .../application/error/WorkerErrorCode.java | 3 ++- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java index 1932b38..646e65e 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java @@ -85,8 +85,8 @@ public ResponseEntity register( @Operation( operationId = "patchWorkerDocument", summary = "서류 상태 수정", - description = "서류 제출·검증·만료 상태와 유효기간을 수정합니다. " - + "expected_version이 현재 값과 다르면 409로 응답합니다." + description = "서류 제출·검증·만료 상태와 유효기간을 수정하고, 업로드된 파일을 연결합니다. " + + "expected_version이 현재 값과 다르면 409, file_id가 존재하지 않으면 404로 응답합니다." ) @ApiResponses({ @ApiResponse( @@ -132,6 +132,7 @@ public WorkerDocumentResponse patch( request.getExpiryDate(), request.getDestination(), request.getNote(), + request.getFileId(), request.getExpectedVersion() ); WorkerDocument document = workerDocumentService.patch(command); diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java index 5e6fffd..36778e9 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentPatchRequest.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.util.UUID; @Schema( name = "WorkerDocumentPatchRequest", @@ -32,6 +33,14 @@ public final class WorkerDocumentPatchRequest { @Size(max = 500, message = "note는 500자 이하여야 합니다.") private final String note; + @Schema( + name = "file_id", + description = "연결할 파일 ID (#13 POST /files 응답의 file_id). " + + "생략 시 변경하지 않습니다. 존재하지 않거나 다른 사업장 소속이면 404로 거부합니다.", + format = "uuid" + ) + private final UUID fileId; + @Schema( name = "expected_version", description = "낙관적 잠금 버전. 마지막으로 조회한 WorkerDocumentResponse.version을 그대로 보내야 합니다.", @@ -47,6 +56,7 @@ public WorkerDocumentPatchRequest( @JsonProperty("expiry_date") LocalDate expiryDate, @JsonProperty("destination") String destination, @JsonProperty("note") String note, + @JsonProperty("file_id") UUID fileId, @JsonProperty("expected_version") Long expectedVersion ) { this.documentType = documentType; @@ -54,6 +64,7 @@ public WorkerDocumentPatchRequest( this.expiryDate = expiryDate; this.destination = destination; this.note = note; + this.fileId = fileId; this.expectedVersion = expectedVersion; } @@ -77,6 +88,10 @@ public String getNote() { return note; } + public UUID getFileId() { + return fileId; + } + public Long getExpectedVersion() { return expectedVersion; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java index 0d6680f..e66b361 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java @@ -15,6 +15,7 @@ public final class WorkerDocumentPatchCommand { private final LocalDate expiryDate; private final String destination; private final String note; + private final UUID fileId; private final long expectedVersion; public WorkerDocumentPatchCommand( @@ -26,6 +27,7 @@ public WorkerDocumentPatchCommand( LocalDate expiryDate, String destination, String note, + UUID fileId, long expectedVersion ) { this.workerDocumentId = workerDocumentId; @@ -36,6 +38,7 @@ public WorkerDocumentPatchCommand( this.expiryDate = expiryDate; this.destination = destination; this.note = note; + this.fileId = fileId; this.expectedVersion = expectedVersion; } @@ -71,6 +74,10 @@ public String note() { return note; } + public UUID fileId() { + return fileId; + } + public long expectedVersion() { return expectedVersion; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index f716ae5..23eb552 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -3,6 +3,7 @@ import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.domain.WorkerDocument; @@ -15,15 +16,18 @@ public class WorkerDocumentService { private final WorkerDocumentRepository workerDocumentRepository; + private final StoredFileRepository storedFileRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, + StoredFileRepository storedFileRepository, UuidGenerator uuidGenerator, Clock clock ) { this.workerDocumentRepository = workerDocumentRepository; + this.storedFileRepository = storedFileRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -62,6 +66,8 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_VERSION_CONFLICT); } + UUID resolvedFileId = resolveFileId(command.fileId(), command.companyId(), existing.fileId()); + WorkerDocument updated = new WorkerDocument( existing.workerDocumentId(), existing.workerId(), @@ -71,7 +77,7 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { orElseKeep(command.expiryDate(), existing.expiryDate()), orElseKeep(command.destination(), existing.destination()), orElseKeep(command.note(), existing.note()), - existing.fileId(), + resolvedFileId, existing.createdAt(), DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()), existing.version() @@ -80,6 +86,19 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { return workerDocumentRepository.update(updated); } + /** + * fileId가 요청에 포함되면, 그 파일이 같은 사업장 소속으로 실제 존재하는지 검증한 뒤에만 + * 연결을 허용한다 + */ + private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFileId) { + if (requestedFileId == null) { + return existingFileId; + } + storedFileRepository.findByIdAndCompanyId(requestedFileId, companyId) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_DOCUMENT_FILE_NOT_FOUND)); + return requestedFileId; + } + private static T orElseKeep(T newValue, T existingValue) { return newValue != null ? newValue : existingValue; } diff --git a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java index 832f2b8..d1ca6fe 100644 --- a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java +++ b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java @@ -7,7 +7,8 @@ public enum WorkerErrorCode implements ApiErrorCode { WORKER_NOT_FOUND(HttpStatus.NOT_FOUND, "근로자를 찾을 수 없습니다."), WORKER_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), WORKER_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "서류를 찾을 수 없습니다."), - WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."); + WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), + WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."); private final HttpStatus status; private final String defaultMessage; From 2e84df6311d7255a8c6df59bdb7de3c234eae724 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 14:25:36 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat(worker):=20WorkerDocument=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Repository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(document=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerDocumentSearchQuery.java | 65 +++++++++++++++++++ .../port/WorkerDocumentRepository.java | 6 ++ .../JpaWorkerDocumentRepository.java | 65 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java new file mode 100644 index 0000000..887fb4e --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentSearchQuery.java @@ -0,0 +1,65 @@ +package com.fowoco.server.worker.application; + +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.time.LocalDate; +import java.util.UUID; + +public final class WorkerDocumentSearchQuery { + + private static final int MIN_SIZE = 1; + private static final int MAX_SIZE = 100; + + private final UUID workerId; + private final DocumentType documentType; + private final SubmissionStatus status; + private final LocalDate expiryBefore; + private final int page; + private final int size; + + public WorkerDocumentSearchQuery( + UUID workerId, + DocumentType documentType, + SubmissionStatus status, + LocalDate expiryBefore, + Integer page, + Integer size + ) { + this.workerId = workerId; + this.documentType = documentType; + this.status = status; + this.expiryBefore = expiryBefore; + this.page = page == null ? 0 : page; + this.size = size == null ? 20 : size; + if (this.page < 0) { + throw new IllegalArgumentException("page must not be negative"); + } + if (this.size < MIN_SIZE || this.size > MAX_SIZE) { + throw new IllegalArgumentException("size must be between " + MIN_SIZE + " and " + MAX_SIZE); + } + } + + public UUID workerId() { + return workerId; + } + + public DocumentType documentType() { + return documentType; + } + + public SubmissionStatus status() { + return status; + } + + public LocalDate expiryBefore() { + return expiryBefore; + } + + public int page() { + return page; + } + + public int size() { + return size; + } +} 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 adbe24a..7991d9f 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 @@ -1,6 +1,8 @@ package com.fowoco.server.worker.application.port; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,4 +17,8 @@ Optional findByIdAndWorkerIdAndCompanyId( ); WorkerDocument update(WorkerDocument document); + + List findPage(UUID companyId, WorkerDocumentSearchQuery query); + + long countPage(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 9cda775..6f80fc6 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,8 +1,12 @@ package com.fowoco.server.worker.infrastructure.persistence; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.domain.WorkerDocument; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -65,4 +69,65 @@ public WorkerDocument update(WorkerDocument document) { entityManager.flush(); return entity.toDomain(); } + + @Override + public List findPage(UUID companyId, WorkerDocumentSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + String jpql = "select document from WorkerDocumentJpaEntity document" + + buildWhereClause(query) + + " order by document.createdAt desc"; + TypedQuery jpaQuery = entityManager.createQuery(jpql, WorkerDocumentJpaEntity.class); + bindParameters(jpaQuery, companyId, query); + return jpaQuery + .setFirstResult(query.page() * query.size()) + .setMaxResults(query.size()) + .getResultList() + .stream() + .map(WorkerDocumentJpaEntity::toDomain) + .toList(); + } + + @Override + public long countPage(UUID companyId, WorkerDocumentSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + String jpql = "select count(document) from WorkerDocumentJpaEntity document" + buildWhereClause(query); + TypedQuery jpaQuery = entityManager.createQuery(jpql, Long.class); + bindParameters(jpaQuery, companyId, query); + return jpaQuery.getSingleResult(); + } + + private String buildWhereClause(WorkerDocumentSearchQuery query) { + StringBuilder where = new StringBuilder(" where document.companyId = :companyId"); + if (query.workerId() != null) { + where.append(" and document.workerId = :workerId"); + } + if (query.documentType() != null) { + where.append(" and document.documentType = :documentType"); + } + if (query.status() != null) { + where.append(" and document.submissionStatus = :status"); + } + if (query.expiryBefore() != null) { + where.append(" and document.expiryDate < :expiryBefore"); + } + return where.toString(); + } + + private void bindParameters(Query jpaQuery, UUID companyId, WorkerDocumentSearchQuery query) { + jpaQuery.setParameter("companyId", companyId); + if (query.workerId() != null) { + jpaQuery.setParameter("workerId", query.workerId()); + } + if (query.documentType() != null) { + jpaQuery.setParameter("documentType", query.documentType()); + } + if (query.status() != null) { + jpaQuery.setParameter("status", query.status()); + } + if (query.expiryBefore() != null) { + jpaQuery.setParameter("expiryBefore", query.expiryBefore()); + } + } } From a5e00ee379281b91eef3af312791ab1c468ac032 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 14:49:22 +0900 Subject: [PATCH 16/36] =?UTF-8?q?refactor(worker):=20WorkerRepository=20?= =?UTF-8?q?=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EB=B0=B0=EC=B9=98=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20count=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/WorkerRepository.java | 3 + .../persistence/JpaWorkerRepository.java | 56 +++++++++++++------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java index 993de6d..8615920 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java @@ -4,6 +4,7 @@ import com.fowoco.server.worker.domain.Worker; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; public interface WorkerRepository { @@ -17,4 +18,6 @@ public interface WorkerRepository { List findPage(UUID companyId, WorkerSearchQuery query); long countPage(UUID companyId, WorkerSearchQuery query); + + List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID companyId); } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java index 6f4b5a4..624cffa 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java @@ -4,10 +4,12 @@ import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -63,10 +65,10 @@ public Worker update(Worker worker) { public List findPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); - TypedQuery jpaQuery = entityManager.createQuery( - buildWhereClause(query) + " order by worker.createdAt desc", - WorkerJpaEntity.class - ); + String jpql = "select worker from WorkerJpaEntity worker" + + buildWhereClause(query) + + " order by worker.createdAt desc"; + TypedQuery jpaQuery = entityManager.createQuery(jpql, WorkerJpaEntity.class); bindParameters(jpaQuery, companyId, query); return jpaQuery .setFirstResult(query.page() * query.size()) @@ -81,33 +83,51 @@ public List findPage(UUID companyId, WorkerSearchQuery query) { public long countPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); - TypedQuery jpaQuery = entityManager.createQuery( - "select count(worker) " + buildWhereClause(query).replaceFirst("^select worker ", ""), - Long.class - ); + String jpql = "select count(worker) from WorkerJpaEntity worker" + buildWhereClause(query); + TypedQuery jpaQuery = entityManager.createQuery(jpql, Long.class); bindParameters(jpaQuery, companyId, query); return jpaQuery.getSingleResult(); } + @Override + public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID companyId) { + Objects.requireNonNull(workerIds, "workerIds must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + if (workerIds.isEmpty()) { + return List.of(); + } + return entityManager.createQuery( + """ + select worker + from WorkerJpaEntity worker + where worker.workerId in :workerIds + and worker.companyId = :companyId + """, + WorkerJpaEntity.class + ) + .setParameter("workerIds", workerIds) + .setParameter("companyId", companyId) + .getResultList() + .stream() + .map(WorkerJpaEntity::toDomain) + .toList(); + } + private String buildWhereClause(WorkerSearchQuery query) { - StringBuilder jpql = new StringBuilder("select worker from WorkerJpaEntity worker where worker.companyId = :companyId"); + StringBuilder where = new StringBuilder(" where worker.companyId = :companyId"); if (query.status() != null) { - jpql.append(" and worker.workStatus = :status"); + where.append(" and worker.workStatus = :status"); } if (query.language() != null) { - jpql.append(" and worker.preferredLanguage = :language"); + where.append(" and worker.preferredLanguage = :language"); } if (query.expiryBefore() != null) { - jpql.append(" and worker.stayExpiryDate < :expiryBefore"); + where.append(" and worker.stayExpiryDate < :expiryBefore"); } - return jpql.toString(); + return where.toString(); } - private void bindParameters( - jakarta.persistence.Query jpaQuery, - UUID companyId, - WorkerSearchQuery query - ) { + private void bindParameters(Query jpaQuery, UUID companyId, WorkerSearchQuery query) { jpaQuery.setParameter("companyId", companyId); if (query.status() != null) { jpaQuery.setParameter("status", query.status()); From 58014dd1bba171059bb7a8981e7e5b10d99a4079 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 14:55:49 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat(document):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=95=A8=20=ED=95=AD=EB=AA=A9=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=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 | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java new file mode 100644 index 0000000..c8cb486 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentItemResponse.java @@ -0,0 +1,103 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import com.fowoco.server.worker.domain.WorkerDocument; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DocumentItemResponse", description = "통합 문서함의 서류 항목 (근로자 표시 정보 포함)") +public final class DocumentItemResponse { + + @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("display_name") + @Schema( + name = "display_name", + description = "근로자 화면 표시 이름. 근로자가 조회 시점에 삭제된 경우 null.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + 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("file_id") + @Schema(name = "file_id", format = "uuid") + private final UUID fileId; + + private DocumentItemResponse( + UUID workerDocumentId, + UUID workerId, + String displayName, + DocumentType documentType, + SubmissionStatus submissionStatus, + LocalDate expiryDate, + UUID fileId + ) { + this.workerDocumentId = workerDocumentId; + this.workerId = workerId; + this.displayName = displayName; + this.documentType = documentType; + this.submissionStatus = submissionStatus; + this.expiryDate = expiryDate; + this.fileId = fileId; + } + + public static DocumentItemResponse from(WorkerDocument document, String displayName) { + return new DocumentItemResponse( + document.workerDocumentId(), + document.workerId(), + displayName, + document.documentType(), + document.submissionStatus(), + document.expiryDate(), + document.fileId() + ); + } + + public UUID getWorkerDocumentId() { + return workerDocumentId; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getWorkerDisplayName() { + return displayName; + } + + public DocumentType getDocumentType() { + return documentType; + } + + public SubmissionStatus getSubmissionStatus() { + return submissionStatus; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public UUID getFileId() { + return fileId; + } +} From 3144d1328dd45603abe14c456aee32b24054dd14 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 15:00:05 +0900 Subject: [PATCH 18/36] =?UTF-8?q?feat(document):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20Service=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 --- .../application/DocumentPageResult.java | 15 ++++++ .../document/application/DocumentService.java | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentPageResult.java create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentService.java diff --git a/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java b/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java new file mode 100644 index 0000000..124319b --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentPageResult.java @@ -0,0 +1,15 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record DocumentPageResult( + List items, + Map workerDisplayNames, + int page, + int size, + long totalElements +) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java new file mode 100644 index 0000000..9d592c9 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -0,0 +1,46 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentService { + + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; + + public DocumentService( + WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository + ) { + this.workerDocumentRepository = workerDocumentRepository; + this.workerRepository = workerRepository; + } + + @Transactional(readOnly = true) + public DocumentPageResult findPage(UUID companyId, WorkerDocumentSearchQuery query) { + List items = workerDocumentRepository.findPage(companyId, query); + long totalElements = workerDocumentRepository.countPage(companyId, query); + + Set workerIds = items.stream() + .map(WorkerDocument::workerId) + .collect(Collectors.toSet()); + Map workerDisplayNames = workerRepository + .findAllByWorkerIdsAndCompanyId(workerIds, companyId) + .stream() + .collect(Collectors.toMap(Worker::workerId, Worker::displayName)); + + return new DocumentPageResult(items, workerDisplayNames, query.page(), query.size(), totalElements); + } +} From 9839f8a04b2a6b4c509320e6033db96070c271c1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 16:29:26 +0900 Subject: [PATCH 19/36] =?UTF-8?q?feat(document):=20GET=20/documents=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=AC=B8=EC=84=9C=ED=95=A8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/api/DocumentController.java | 96 +++++++++++++++++++ .../document/api/DocumentPageResponse.java | 48 ++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentController.java create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentController.java b/src/main/java/com/fowoco/server/document/api/DocumentController.java new file mode 100644 index 0000000..fd308ee --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentController.java @@ -0,0 +1,96 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentPageResult; +import com.fowoco.server.document.application.DocumentService; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/documents") +@SecurityRequirement(name = "bearerAuth") +@Validated +public class DocumentController { + + private final DocumentService documentService; + private final ActorContextProvider actorContextProvider; + + public DocumentController( + DocumentService documentService, + ActorContextProvider actorContextProvider + ) { + this.documentService = documentService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "listDocuments", + summary = "통합 문서함 조회", + description = "근로자·업무·문서 유형·상태로 사업장 전체 서류를 검색합니다. " + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentPageResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentPageResponse list( + @Parameter(description = "근로자 ID 필터") @RequestParam(required = false) UUID workerId, + @Parameter(description = "서류 유형 필터") @RequestParam(required = false) DocumentType documentType, + @Parameter(description = "제출 상태 필터") @RequestParam(required = false) SubmissionStatus status, + @Parameter(description = "이 날짜 이전 만료 필터") @RequestParam(required = false) LocalDate expiryBefore, + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지당 항목 수 (1~100)") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + WorkerDocumentSearchQuery query = new WorkerDocumentSearchQuery( + workerId, + documentType, + status, + expiryBefore, + page, + size + ); + DocumentPageResult result = documentService.findPage(companyId, query); + List items = result.items().stream() + .map(document -> DocumentItemResponse.from( + document, + result.workerDisplayNames().get(document.workerId()) + )) + .toList(); + return new DocumentPageResponse(items, result.page(), result.size(), result.totalElements()); + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java new file mode 100644 index 0000000..f70e00a --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentPageResponse.java @@ -0,0 +1,48 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DocumentPageResponse", description = "사업장 통합 문서함 목록 페이지 응답") +public final class DocumentPageResponse { + + @JsonProperty("items") + @Schema(description = "이번 페이지의 서류 목록", requiredMode = Schema.RequiredMode.REQUIRED) + private final List items; + + @JsonProperty("page") + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private final int page; + + @JsonProperty("size") + @Schema(description = "페이지당 항목 수", example = "20", requiredMode = Schema.RequiredMode.REQUIRED) + private final int size; + + @JsonProperty("total_elements") + @Schema(name = "total_elements", description = "전체 서류 수", example = "42", requiredMode = Schema.RequiredMode.REQUIRED) + private final long totalElements; + + public DocumentPageResponse(List items, int page, int size, long totalElements) { + this.items = items; + this.page = page; + this.size = size; + this.totalElements = totalElements; + } + + public List getItems() { + return items; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } +} From 11caa6722d8629bdc48503f5024da743ed27338a Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 17:05:41 +0900 Subject: [PATCH 20/36] =?UTF-8?q?feat(document):=20itemCode-DocumentType?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ChecklistItemDocumentMapper.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java diff --git a/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java b/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java new file mode 100644 index 0000000..16421e1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/ChecklistItemDocumentMapper.java @@ -0,0 +1,23 @@ +package com.fowoco.server.document.domain; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.Map; +import java.util.Optional; + +public final class ChecklistItemDocumentMapper { + + private static final Map ITEM_CODE_TO_DOCUMENT_TYPE = Map.of( + "PASSPORT_COPY_VERIFY_CURRENT", DocumentType.PASSPORT_COPY, + "ALIEN_REGISTRATION_CARD_VERIFY_CURRENT", DocumentType.ARC, + "EMPLOYMENT_CONTRACT_VERIFY_PERIOD", DocumentType.CONTRACT, + "EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", DocumentType.CONTRACT, + "EMPLOYMENT_PERMIT_VERIFY_PERIOD", DocumentType.PERMIT + ); + + private ChecklistItemDocumentMapper() { + } + + public static Optional toDocumentType(String itemCode) { + return Optional.ofNullable(ITEM_CODE_TO_DOCUMENT_TYPE.get(itemCode)); + } +} From b920a3c34e2402c8729901b2af3b316af9fc61ec Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 17:19:50 +0900 Subject: [PATCH 21/36] =?UTF-8?q?feat(document):=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=EB=8F=84=20=EC=9D=91=EB=8B=B5=20DTO=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 --- .../api/DocumentReadinessResponse.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java new file mode 100644 index 0000000..a3ce3a1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentReadinessResponse.java @@ -0,0 +1,68 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DocumentReadinessResponse", description = "업무별 서류 준비도") +public final class DocumentReadinessResponse { + + @JsonProperty("required") + @Schema(description = "이 업무에 필요한 전체 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List required; + + @JsonProperty("available") + @Schema(description = "보유 중이고 유효한 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List available; + + @JsonProperty("missing") + @Schema(description = "아예 등록되지 않은 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List missing; + + @JsonProperty("expired") + @Schema(description = "등록됐지만 유효기간이 지난 서류 종류", requiredMode = Schema.RequiredMode.REQUIRED) + private final List expired; + + @JsonProperty("completion_blocked") + @Schema( + name = "completion_blocked", + description = "missing 또는 expired가 하나라도 있으면 true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final boolean completionBlocked; + + public DocumentReadinessResponse( + List required, + List available, + List missing, + List expired, + boolean completionBlocked + ) { + this.required = required; + this.available = available; + this.missing = missing; + this.expired = expired; + this.completionBlocked = completionBlocked; + } + + public List getRequired() { + return required; + } + + public List getAvailable() { + return available; + } + + public List getMissing() { + return missing; + } + + public List getExpired() { + return expired; + } + + public boolean isCompletionBlocked() { + return completionBlocked; + } +} From 742503438248372293da8c8fa157b21e2deaf574 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 17:30:41 +0900 Subject: [PATCH 22/36] =?UTF-8?q?feat(document):=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=EB=8F=84=20=EA=B3=84=EC=82=B0=20Service=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 --- .../application/DocumentReadinessResult.java | 13 +++ .../application/DocumentReadinessService.java | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java new file mode 100644 index 0000000..e96a077 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessResult.java @@ -0,0 +1,13 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; + +public record DocumentReadinessResult( + List required, + List available, + List missing, + List expired, + boolean completionBlocked +) { +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java new file mode 100644 index 0000000..84c7830 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java @@ -0,0 +1,98 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.document.domain.ChecklistItemDocumentMapper; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.time.Clock; +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentReadinessService { + + private final TaskRepository taskRepository; + private final TaskChecklistRepository taskChecklistRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final Clock clock; + + public DocumentReadinessService( + TaskRepository taskRepository, + TaskChecklistRepository taskChecklistRepository, + WorkerDocumentRepository workerDocumentRepository, + Clock clock + ) { + this.taskRepository = taskRepository; + this.taskChecklistRepository = taskChecklistRepository; + this.workerDocumentRepository = workerDocumentRepository; + this.clock = clock; + } + + @Transactional(readOnly = true) + public DocumentReadinessResult calculate(UUID taskId, UUID companyId) { + Task task = taskRepository.findByIdAndCompanyId(taskId, companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + + Set requiredTypes = requiredDocumentTypes(taskId, companyId); + + LocalDate today = LocalDate.now(clock); + WorkerDocumentSearchQuery allDocumentsQuery = new WorkerDocumentSearchQuery( + task.workerId(), null, null, null, 0, 100 + ); + List workerDocuments = workerDocumentRepository.findPage(companyId, allDocumentsQuery); + + Set available = EnumSet.noneOf(DocumentType.class); + Set expired = EnumSet.noneOf(DocumentType.class); + for (WorkerDocument document : workerDocuments) { + if (!requiredTypes.contains(document.documentType())) { + continue; + } + boolean isExpired = document.expiryDate() != null && document.expiryDate().isBefore(today); + if (isExpired) { + expired.add(document.documentType()); + } else { + available.add(document.documentType()); + } + } + + Set missing = EnumSet.copyOf(requiredTypes); + missing.removeAll(available); + missing.removeAll(expired); + + boolean completionBlocked = !missing.isEmpty() || !expired.isEmpty(); + + return new DocumentReadinessResult( + List.copyOf(requiredTypes), + List.copyOf(available), + List.copyOf(missing), + List.copyOf(expired), + completionBlocked + ); + } + + private Set requiredDocumentTypes(UUID taskId, UUID companyId) { + List checklistItems = + taskChecklistRepository.findAllByTaskIdAndCompanyId(taskId, companyId); + Set requiredTypes = EnumSet.noneOf(DocumentType.class); + for (TaskChecklistItem item : checklistItems) { + if (!item.required()) { + continue; + } + ChecklistItemDocumentMapper.toDocumentType(item.itemCode()) + .ifPresent(requiredTypes::add); + } + return requiredTypes; + } +} From 79e137a2cf1cbaa6bed9c8f191530d66cf61d332 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 27 Jul 2026 17:31:56 +0900 Subject: [PATCH 23/36] =?UTF-8?q?feat(document):=20GET=20document-readines?= =?UTF-8?q?s=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DocumentReadinessController.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java new file mode 100644 index 0000000..718b125 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java @@ -0,0 +1,73 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentReadinessResult; +import com.fowoco.server.document.application.DocumentReadinessService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/document-readiness") +@SecurityRequirement(name = "bearerAuth") +public class DocumentReadinessController { + + private final DocumentReadinessService documentReadinessService; + private final ActorContextProvider actorContextProvider; + + public DocumentReadinessController( + DocumentReadinessService documentReadinessService, + ActorContextProvider actorContextProvider + ) { + this.documentReadinessService = documentReadinessService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getDocumentReadiness", + summary = "업무별 서류 준비도 확인", + description = "Task 생성 시 고정된 체크리스트 snapshot을 기준으로 현재 업무에 필요한 " + + "서류가 준비됐는지 확인합니다. Workflow Catalog를 실시간으로 다시 읽지 않습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentReadinessResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DocumentReadinessResponse get( + @Parameter(description = "업무 ID") @PathVariable UUID taskId + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + DocumentReadinessResult result = documentReadinessService.calculate(taskId, companyId); + return new DocumentReadinessResponse( + result.required(), + result.available(), + result.missing(), + result.expired(), + result.completionBlocked() + ); + } +} From 6229bfb32718bfdcaecdc6360ac7d9419a41e8e4 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 02:25:05 +0900 Subject: [PATCH 24/36] =?UTF-8?q?feat(document):=20DocumentRequestDraft=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8,=20port,=20JPA=20Entity=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 --- .../port/DocumentRequestDraftRepository.java | 14 ++ .../document/domain/DocumentRequestDraft.java | 151 ++++++++++++++++++ .../domain/DocumentRequestReviewStatus.java | 5 + .../DocumentRequestDraftJpaEntity.java | 137 ++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java create mode 100644 src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java create mode 100644 src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java create mode 100644 src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java diff --git a/src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java b/src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java new file mode 100644 index 0000000..e8eb96e --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/port/DocumentRequestDraftRepository.java @@ -0,0 +1,14 @@ +package com.fowoco.server.document.application.port; + +import com.fowoco.server.document.domain.DocumentRequestDraft; +import java.util.Optional; +import java.util.UUID; + +public interface DocumentRequestDraftRepository { + + void insert(DocumentRequestDraft draft); + + Optional findByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + DocumentRequestDraft update(DocumentRequestDraft draft); +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java b/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java new file mode 100644 index 0000000..5e086b1 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentRequestDraft.java @@ -0,0 +1,151 @@ +package com.fowoco.server.document.domain; + +import com.fowoco.server.worker.domain.DocumentType; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class DocumentRequestDraft { + + private static final int MAX_LANGUAGE_LENGTH = 20; + private static final int MAX_MESSAGE_LENGTH = 1000; + + private final UUID draftId; + private final UUID taskId; + private final UUID companyId; + private final String language; + private final List documentTypes; + private final String message; + private final DocumentRequestReviewStatus reviewStatus; + private final Instant createdAt; + private final Instant updatedAt; + private final long version; + + public DocumentRequestDraft( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + DocumentRequestReviewStatus reviewStatus, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.draftId = Objects.requireNonNull(draftId, "draftId must not be null"); + this.taskId = Objects.requireNonNull(taskId, "taskId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.language = requireBounded(language, MAX_LANGUAGE_LENGTH, "language"); + if (documentTypes == null || documentTypes.isEmpty()) { + throw new IllegalArgumentException("documentTypes must not be empty"); + } + this.documentTypes = List.copyOf(documentTypes); + this.message = message == null ? null : requireBounded(message, MAX_MESSAGE_LENGTH, "message"); + this.reviewStatus = Objects.requireNonNull(reviewStatus, "reviewStatus must not be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null"); + if (updatedAt.isBefore(createdAt)) { + throw new IllegalArgumentException("updatedAt must not be before createdAt"); + } + if (version < 0) { + throw new IllegalArgumentException("version must not be negative"); + } + this.version = version; + } + + public static DocumentRequestDraft create( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + Instant now + ) { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + DocumentRequestReviewStatus.DRAFT, + now, + now, + 0L + ); + } + + public DocumentRequestDraft withUpdatedContent( + String language, + List documentTypes, + String message, + Instant now + ) { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + reviewStatus, + createdAt, + now, + version + ); + } + + private static String requireBounded(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + String normalized = value.strip(); + if (normalized.length() > maxLength) { + throw new IllegalArgumentException(fieldName + " must not exceed " + maxLength + " characters"); + } + return normalized; + } + + public UUID draftId() { + return draftId; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String language() { + return language; + } + + public List documentTypes() { + return documentTypes; + } + + public String message() { + return message; + } + + public DocumentRequestReviewStatus reviewStatus() { + return reviewStatus; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java b/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java new file mode 100644 index 0000000..95ef927 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/domain/DocumentRequestReviewStatus.java @@ -0,0 +1,5 @@ +package com.fowoco.server.document.domain; + +public enum DocumentRequestReviewStatus { + DRAFT +} diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java new file mode 100644 index 0000000..bcf0ae0 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java @@ -0,0 +1,137 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import com.fowoco.server.worker.domain.DocumentType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "document_request_draft") +public class DocumentRequestDraftJpaEntity { + + @Id + @Column(name = "draft_id", nullable = false, updatable = false) + private UUID draftId; + + @Column(name = "task_id", nullable = false, updatable = false) + private UUID taskId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "language", nullable = false, length = 20) + private String language; + + @ElementCollection(fetch = jakarta.persistence.FetchType.EAGER) + @CollectionTable( + name = "document_request_draft_type", + joinColumns = @JoinColumn(name = "draft_id") + ) + @Enumerated(EnumType.STRING) + @Column(name = "document_type", nullable = false, length = 40) + private List documentTypes; + + @Column(name = "message", length = 1000) + private String message; + + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false, length = 20) + private DocumentRequestReviewStatus reviewStatus; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "version", nullable = false) + private long version; + + protected DocumentRequestDraftJpaEntity() { + } + + private DocumentRequestDraftJpaEntity( + UUID draftId, + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + DocumentRequestReviewStatus reviewStatus, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.draftId = draftId; + this.taskId = taskId; + this.companyId = companyId; + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.reviewStatus = reviewStatus; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static DocumentRequestDraftJpaEntity fromDomain(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + return new DocumentRequestDraftJpaEntity( + draft.draftId(), + draft.taskId(), + draft.companyId(), + draft.language(), + draft.documentTypes(), + draft.message(), + draft.reviewStatus(), + draft.createdAt(), + draft.updatedAt(), + draft.version() + ); + } + + public DocumentRequestDraft toDomain() { + return new DocumentRequestDraft( + draftId, + taskId, + companyId, + language, + documentTypes, + message, + reviewStatus, + createdAt, + updatedAt, + version + ); + } + + public void applyState(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + if (!draftId.equals(draft.draftId()) + || !taskId.equals(draft.taskId()) + || !companyId.equals(draft.companyId()) + || !createdAt.equals(draft.createdAt())) { + throw new IllegalArgumentException("immutable document request draft fields must not change"); + } + if (version != draft.version()) { + throw new IllegalArgumentException("document request draft version does not match"); + } + this.language = draft.language(); + this.documentTypes = draft.documentTypes(); + this.message = draft.message(); + this.reviewStatus = draft.reviewStatus(); + this.updatedAt = draft.updatedAt(); + } +} From 01bc3d2941724fe0ce38d928babed6cacff120f2 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 02:33:38 +0900 Subject: [PATCH 25/36] =?UTF-8?q?feat(document):=20DocumentRequestDraft=20?= =?UTF-8?q?JPA=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JpaDocumentRequestDraftRepository.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java new file mode 100644 index 0000000..aed1833 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/JpaDocumentRequestDraftRepository.java @@ -0,0 +1,61 @@ +package com.fowoco.server.document.infrastructure.persistence; + +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaDocumentRequestDraftRepository implements DocumentRequestDraftRepository { + + private final EntityManager entityManager; + + public JpaDocumentRequestDraftRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + entityManager.persist(DocumentRequestDraftJpaEntity.fromDomain(draft)); + entityManager.flush(); + } + + @Override + public Optional findByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select draft + from DocumentRequestDraftJpaEntity draft + where draft.taskId = :taskId + and draft.companyId = :companyId + """, + DocumentRequestDraftJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("companyId", companyId) + .getResultStream() + .findFirst() + .map(DocumentRequestDraftJpaEntity::toDomain); + } + + @Override + public DocumentRequestDraft update(DocumentRequestDraft draft) { + Objects.requireNonNull(draft, "draft must not be null"); + DocumentRequestDraftJpaEntity entity = entityManager.find( + DocumentRequestDraftJpaEntity.class, + draft.draftId() + ); + if (entity == null) { + throw new IllegalStateException("document request draft to update was not found"); + } + entity.applyState(draft); + entityManager.flush(); + return entity.toDomain(); + } +} From 42a3dd0216a8db87951478bb0bbd08b9c10c4ae0 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 02:57:49 +0900 Subject: [PATCH 26/36] =?UTF-8?q?feat(document):=20DocumentRequestDraft=20?= =?UTF-8?q?DTO=20=EB=B0=8F=20migration=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DocumentRequestDraftResponse.java | 45 +++++++++++++ .../api/DocumentRequestUpsertRequest.java | 63 +++++++++++++++++++ .../V11__create_document_request_draft.sql | 36 +++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java create mode 100644 src/main/resources/db/migration/V11__create_document_request_draft.sql diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java new file mode 100644 index 0000000..ee98c8e --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java @@ -0,0 +1,45 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.UUID; + +@Schema(name = "DocumentRequestDraftResponse", description = "업무별 문서 요청 초안 저장 결과") +public final class DocumentRequestDraftResponse { + + @JsonProperty("draft_id") + @Schema(name = "draft_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID draftId; + + @JsonProperty("version") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final long version; + + @JsonProperty("review_status") + @Schema(name = "review_status", requiredMode = Schema.RequiredMode.REQUIRED) + private final DocumentRequestReviewStatus reviewStatus; + + private DocumentRequestDraftResponse(UUID draftId, long version, DocumentRequestReviewStatus reviewStatus) { + this.draftId = draftId; + this.version = version; + this.reviewStatus = reviewStatus; + } + + public static DocumentRequestDraftResponse from(DocumentRequestDraft draft) { + return new DocumentRequestDraftResponse(draft.draftId(), draft.version(), draft.reviewStatus()); + } + + public UUID getDraftId() { + return draftId; + } + + public long getVersion() { + return version; + } + + public DocumentRequestReviewStatus getReviewStatus() { + return reviewStatus; + } +} diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java new file mode 100644 index 0000000..ba263cc --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestUpsertRequest.java @@ -0,0 +1,63 @@ +package com.fowoco.server.document.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +@Schema(name = "DocumentRequestUpsertRequest", description = "업무별 문서 요청 초안 저장·갱신 요청") +public final class DocumentRequestUpsertRequest { + + @Schema(description = "안내 언어", example = "vi", maxLength = 20, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "language를 입력해 주세요.") + @Size(max = 20, message = "language는 20자 이하여야 합니다.") + private final String language; + + @Schema(name = "document_types", description = "요청할 서류 종류 목록", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "document_types는 최소 1개 이상이어야 합니다.") + private final List documentTypes; + + @Schema(description = "근로자에게 보여줄 안내 메시지", maxLength = 1000) + @Size(max = 1000, message = "message는 1000자 이하여야 합니다.") + private final String message; + + @Schema( + name = "expected_version", + description = "최초 생성 시에는 0을 보냅니다.", + requiredMode = Schema.RequiredMode.REQUIRED + ) + private final Long expectedVersion; + + @JsonCreator + public DocumentRequestUpsertRequest( + @JsonProperty("language") String language, + @JsonProperty("document_types") List documentTypes, + @JsonProperty("message") String message, + @JsonProperty("expected_version") Long expectedVersion + ) { + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.expectedVersion = expectedVersion; + } + + public String getLanguage() { + return language; + } + + public List getDocumentTypes() { + return documentTypes; + } + + public String getMessage() { + return message; + } + + public Long getExpectedVersion() { + return expectedVersion; + } +} diff --git a/src/main/resources/db/migration/V11__create_document_request_draft.sql b/src/main/resources/db/migration/V11__create_document_request_draft.sql new file mode 100644 index 0000000..9f928fd --- /dev/null +++ b/src/main/resources/db/migration/V11__create_document_request_draft.sql @@ -0,0 +1,36 @@ +CREATE TABLE document_request_draft ( + draft_id UUID NOT NULL, + task_id UUID NOT NULL, + company_id UUID NOT NULL, + language VARCHAR(20) NOT NULL, + message VARCHAR(1000), + review_status VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_document_request_draft PRIMARY KEY (draft_id), + CONSTRAINT uq_document_request_draft_task UNIQUE (task_id, company_id), + CONSTRAINT fk_document_request_draft_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_document_request_draft_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_document_request_draft_language_not_blank + CHECK (CHAR_LENGTH(TRIM(language)) > 0), + CONSTRAINT ck_document_request_draft_review_status + CHECK (review_status IN ('DRAFT')), + CONSTRAINT ck_document_request_draft_version CHECK (version >= 0), + CONSTRAINT ck_document_request_draft_updated_at CHECK (updated_at >= created_at) +); + +CREATE TABLE document_request_draft_type ( + draft_id UUID NOT NULL, + document_type VARCHAR(40) NOT NULL, + CONSTRAINT fk_document_request_draft_type_draft + FOREIGN KEY (draft_id) REFERENCES document_request_draft (draft_id) ON DELETE CASCADE, + CONSTRAINT ck_document_request_draft_type_value + CHECK (document_type IN ('PASSPORT_COPY', 'ARC', 'CONTRACT', 'PERMIT')), + CONSTRAINT uq_document_request_draft_type UNIQUE (draft_id, document_type) +); + +CREATE INDEX idx_document_request_draft_company ON document_request_draft (company_id); From e5421d05b3cdcf2319c28dee56b726f7ae26879d Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 03:11:15 +0900 Subject: [PATCH 27/36] =?UTF-8?q?feat(document):=20DocumentRequestDraft=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=C2=B7=EA=B0=B1=EC=8B=A0=20Service=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 --- .../DocumentRequestDraftCommand.java | 55 +++++++++++++++++ .../DocumentRequestDraftService.java | 61 +++++++++++++++++++ .../application/error/DocumentErrorCode.java | 34 +++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java create mode 100644 src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java create mode 100644 src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java new file mode 100644 index 0000000..d46a5b6 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftCommand.java @@ -0,0 +1,55 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; +import java.util.UUID; + +public final class DocumentRequestDraftCommand { + + private final UUID taskId; + private final UUID companyId; + private final String language; + private final List documentTypes; + private final String message; + private final long expectedVersion; + + public DocumentRequestDraftCommand( + UUID taskId, + UUID companyId, + String language, + List documentTypes, + String message, + long expectedVersion + ) { + this.taskId = taskId; + this.companyId = companyId; + this.language = language; + this.documentTypes = documentTypes; + this.message = message; + this.expectedVersion = expectedVersion; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String language() { + return language; + } + + public List documentTypes() { + return documentTypes; + } + + public String message() { + return message; + } + + public long expectedVersion() { + return expectedVersion; + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java new file mode 100644 index 0000000..0f57a42 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -0,0 +1,61 @@ +package com.fowoco.server.document.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.document.application.error.DocumentErrorCode; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import java.time.Clock; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentRequestDraftService { + + private final DocumentRequestDraftRepository documentRequestDraftRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public DocumentRequestDraftService( + DocumentRequestDraftRepository documentRequestDraftRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.documentRequestDraftRepository = documentRequestDraftRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public DocumentRequestDraft upsert(DocumentRequestDraftCommand command) { + Optional existing = documentRequestDraftRepository + .findByTaskIdAndCompanyId(command.taskId(), command.companyId()); + + if (existing.isEmpty()) { + DocumentRequestDraft draft = DocumentRequestDraft.create( + uuidGenerator.generate(), + command.taskId(), + command.companyId(), + command.language(), + command.documentTypes(), + command.message(), + clock.instant() + ); + documentRequestDraftRepository.insert(draft); + return draft; + } + + DocumentRequestDraft current = existing.get(); + if (current.version() != command.expectedVersion()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT); + } + DocumentRequestDraft updated = current.withUpdatedContent( + command.language(), + command.documentTypes(), + command.message(), + clock.instant() + ); + return documentRequestDraftRepository.update(updated); + } +} 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 new file mode 100644 index 0000000..312f1c8 --- /dev/null +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.document.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum DocumentErrorCode implements ApiErrorCode { + DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT( + HttpStatus.CONFLICT, + "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요." + ); + + private final HttpStatus status; + private final String defaultMessage; + + DocumentErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} From 8c9f33714011cff070db79f3d719872478cd68c1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 03:25:16 +0900 Subject: [PATCH 28/36] =?UTF-8?q?feat(document):=20PUT=20document-request-?= =?UTF-8?q?draft=20API=20=EB=B0=8F=20taskId=20=EC=A1=B4=EC=9E=AC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DocumentRequestDraftController.java | 81 +++++++++++++++++++ .../DocumentRequestDraftService.java | 8 ++ 2 files changed, 89 insertions(+) create mode 100644 src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java new file mode 100644 index 0000000..212a01d --- /dev/null +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java @@ -0,0 +1,81 @@ +package com.fowoco.server.document.api; + +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.document.application.DocumentRequestDraftCommand; +import com.fowoco.server.document.application.DocumentRequestDraftService; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Document", description = "사업장 통합 문서함 조회") +@RestController +@RequestMapping("/api/v1/tasks/{taskId}/document-request-draft") +@SecurityRequirement(name = "bearerAuth") +public class DocumentRequestDraftController { + + private final DocumentRequestDraftService documentRequestDraftService; + private final ActorContextProvider actorContextProvider; + + public DocumentRequestDraftController( + DocumentRequestDraftService documentRequestDraftService, + ActorContextProvider actorContextProvider + ) { + this.documentRequestDraftService = documentRequestDraftService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "upsertDocumentRequestDraft", + summary = "문서 요청 초안 저장", + description = "누락 문서를 근로자에게 요청할 안내문 초안을 만들거나 갱신합니다. " + + "최초 생성 시 expected_version은 0이어야 합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "저장 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentRequestDraftResponse.class) + ) + ), + @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") + }) + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentRequestDraftResponse upsert( + @Parameter(description = "업무 ID") @PathVariable UUID taskId, + @Valid @RequestBody DocumentRequestUpsertRequest request + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + DocumentRequestDraftCommand command = new DocumentRequestDraftCommand( + taskId, + companyId, + request.getLanguage(), + request.getDocumentTypes(), + request.getMessage(), + request.getExpectedVersion() + ); + DocumentRequestDraft draft = documentRequestDraftService.upsert(command); + return DocumentRequestDraftResponse.from(draft); + } +} diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java index 0f57a42..f8a8f99 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -5,6 +5,8 @@ import com.fowoco.server.document.application.error.DocumentErrorCode; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; import java.time.Clock; import java.util.Optional; import org.springframework.stereotype.Service; @@ -13,15 +15,18 @@ @Service public class DocumentRequestDraftService { + private final TaskRepository taskRepository; private final DocumentRequestDraftRepository documentRequestDraftRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public DocumentRequestDraftService( + TaskRepository taskRepository, DocumentRequestDraftRepository documentRequestDraftRepository, UuidGenerator uuidGenerator, Clock clock ) { + this.taskRepository = taskRepository; this.documentRequestDraftRepository = documentRequestDraftRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; @@ -29,6 +34,9 @@ public DocumentRequestDraftService( @Transactional public DocumentRequestDraft upsert(DocumentRequestDraftCommand command) { + taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + Optional existing = documentRequestDraftRepository .findByTaskIdAndCompanyId(command.taskId(), command.companyId()); From 762cc8b6d6c1433242e03e5a9adffcb5fece93d0 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 03:26:21 +0900 Subject: [PATCH 29/36] =?UTF-8?q?fix(file):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20taskId,=20workerId=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/file/application/FileService.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 f6b1429..7801acf 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -6,6 +6,10 @@ import com.fowoco.server.file.application.port.FileStorage; 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.error.WorkerErrorCode; +import com.fowoco.server.worker.application.port.WorkerRepository; import java.time.Clock; import java.util.Set; import java.util.UUID; @@ -29,17 +33,23 @@ public class FileService { private final StoredFileRepository storedFileRepository; private final FileStorage fileStorage; + private final TaskRepository taskRepository; + private final WorkerRepository workerRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public FileService( StoredFileRepository storedFileRepository, FileStorage fileStorage, + TaskRepository taskRepository, + WorkerRepository workerRepository, UuidGenerator uuidGenerator, Clock clock ) { this.storedFileRepository = storedFileRepository; this.fileStorage = fileStorage; + this.taskRepository = taskRepository; + this.workerRepository = workerRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -52,6 +62,14 @@ public StoredFile upload(FileCreateCommand command) { if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } + if (command.taskId() != null) { + taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + } + if (command.workerId() != null) { + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + } UUID storedFileId = uuidGenerator.generate(); String storageKey = storedFileId.toString(); From bab0ceaed1a77a73dcbc652227bfa8aabfb1a738 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 09:33:16 +0900 Subject: [PATCH 30/36] =?UTF-8?q?fix(worker):=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20workerId=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/application/WorkerDocumentService.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index 23eb552..db06b0a 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -6,6 +6,7 @@ import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; import java.util.UUID; @@ -16,17 +17,20 @@ public class WorkerDocumentService { private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; private final StoredFileRepository storedFileRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, StoredFileRepository storedFileRepository, UuidGenerator uuidGenerator, Clock clock ) { this.workerDocumentRepository = workerDocumentRepository; + this.workerRepository = workerRepository; this.storedFileRepository = storedFileRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; @@ -34,6 +38,9 @@ public WorkerDocumentService( @Transactional public WorkerDocument register(WorkerDocumentCreateCommand command) { + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), command.workerId(), @@ -86,10 +93,6 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { return workerDocumentRepository.update(updated); } - /** - * fileId가 요청에 포함되면, 그 파일이 같은 사업장 소속으로 실제 존재하는지 검증한 뒤에만 - * 연결을 허용한다 - */ private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFileId) { if (requestedFileId == null) { return existingFileId; From 5133acdace5bdefd07ba19d1ab0d12ab5f50cbaa Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 20:57:46 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat(file):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B0=90=EC=82=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/audit/domain/AuditAction.java | 5 +- .../server/audit/domain/AuditTargetType.java | 5 +- .../server/file/api/FileController.java | 13 ++-- .../server/file/application/FileService.java | 71 ++++++++++++++++++- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index 7ccfcb1..b9001d0 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -11,5 +11,8 @@ public enum AuditAction { APPROVAL_INVALIDATED, EXTERNAL_SUBMISSION_RECORDED, EVIDENCE_RECORDED, - TASK_COMPLETED + TASK_COMPLETED, + FILE_UPLOADED, + WORKER_DOCUMENT_FILE_LINKED, + DOCUMENT_REQUEST_DRAFT_SAVED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index b8ad1db..bd21915 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -4,5 +4,8 @@ public enum AuditTargetType { TASK, APPROVAL_REQUEST, EXTERNAL_SUBMISSION, - EVIDENCE + EVIDENCE, + FILE, + WORKER_DOCUMENT, + DOCUMENT_REQUEST_DRAFT } diff --git a/src/main/java/com/fowoco/server/file/api/FileController.java b/src/main/java/com/fowoco/server/file/api/FileController.java index fce9840..e755a53 100644 --- a/src/main/java/com/fowoco/server/file/api/FileController.java +++ b/src/main/java/com/fowoco/server/file/api/FileController.java @@ -1,8 +1,10 @@ package com.fowoco.server.file.api; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.FileCreateCommand; import com.fowoco.server.file.application.FileService; import com.fowoco.server.file.domain.StoredFile; @@ -14,6 +16,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 java.io.IOException; import java.io.UncheckedIOException; import java.util.UUID; @@ -46,7 +49,7 @@ public FileController(FileService fileService, ActorContextProvider actorContext summary = "파일 업로드", description = "분석·증빙·근로자 제출에 사용할 파일을 안전하게 저장하고 fileId를 발급합니다. " + "악성파일 검사 인프라는 아직 없어 scan_status는 항상 NOT_SCANNED로 반환합니다. " - + "허용 크기·형식은 확정 기준 없어 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." + + "허용 크기·형식은 TODO — 확정 기준 없어 상식적인 기본값(20MB, image/jpeg·png·webp, application/pdf) 사용 중." ) @ApiResponses({ @ApiResponse( @@ -73,13 +76,15 @@ public ResponseEntity upload( @Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file, @Parameter(description = "파일 용도") @RequestParam("purpose") String purpose, @Parameter(description = "연결할 업무 ID") @RequestParam(value = "taskId", required = false) UUID taskId, - @Parameter(description = "연결할 근로자 ID") @RequestParam(value = "workerId", required = false) UUID workerId + @Parameter(description = "연결할 근로자 ID") @RequestParam(value = "workerId", required = false) UUID workerId, + HttpServletRequest servletRequest ) { if (file.isEmpty() || file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) { throw new ApiException(ErrorCode.VALIDATION_FAILED, "업로드할 파일과 파일명이 필요합니다."); } - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); try { FileCreateCommand command = new FileCreateCommand( companyId, @@ -91,7 +96,7 @@ public ResponseEntity upload( workerId, file.getInputStream() ); - StoredFile storedFile = fileService.upload(command); + StoredFile storedFile = fileService.upload(command, actor, RequestMetadata.from(servletRequest)); return ResponseEntity.status(HttpStatus.CREATED).body(FileUploadResponse.from(storedFile)); } catch (IOException exception) { throw new UncheckedIOException("failed to read uploaded file", exception); 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 7801acf..61fdfd4 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -1,7 +1,15 @@ package com.fowoco.server.file.application; +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.web.RequestMetadata; import com.fowoco.server.file.application.error.FileErrorCode; import com.fowoco.server.file.application.port.FileStorage; import com.fowoco.server.file.application.port.StoredFileRepository; @@ -11,6 +19,8 @@ import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerRepository; import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; @@ -19,6 +29,8 @@ @Service public class FileService { + private static final String AUDIT_EVENT_VERSION = "1"; + /** * 확정된 기준 없음. 20으로 시작하고, * 실제 사용 파일(신분증 사진, 계약서 PDF 등) 확인되면 조정 @@ -35,6 +47,7 @@ public class FileService { private final FileStorage fileStorage; private final TaskRepository taskRepository; private final WorkerRepository workerRepository; + private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -43,6 +56,7 @@ public FileService( FileStorage fileStorage, TaskRepository taskRepository, WorkerRepository workerRepository, + AuditEventRepository auditRepository, UuidGenerator uuidGenerator, Clock clock ) { @@ -50,12 +64,13 @@ public FileService( this.fileStorage = fileStorage; this.taskRepository = taskRepository; this.workerRepository = workerRepository; + this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public StoredFile upload(FileCreateCommand command) { + public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestMetadata metadata) { if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } @@ -73,6 +88,7 @@ public StoredFile upload(FileCreateCommand command) { UUID storedFileId = uuidGenerator.generate(); String storageKey = storedFileId.toString(); + Instant now = clock.instant(); StoredFile storedFile = StoredFile.create( storedFileId, @@ -84,11 +100,62 @@ public StoredFile upload(FileCreateCommand command) { command.taskId(), command.workerId(), storageKey, - clock.instant() + now ); fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); storedFileRepository.insert(storedFile); + + appendAudit( + actor, + AuditAction.FILE_UPLOADED, + AuditTargetType.FILE, + storedFileId, + "파일 업로드: " + command.purpose(), + metadata, + now + ); + return storedFile; } + + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + 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; + }; + } } From 1b5ab16653bd7d0215ea375dd43a71a5f6d026a4 Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 21:25:58 +0900 Subject: [PATCH 32/36] =?UTF-8?q?feat(worker):=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=B0=EA=B2=B0=20=EC=8B=9C=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/api/WorkerDocumentController.java | 15 +++- .../application/WorkerDocumentService.java | 78 ++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java index 646e65e..006dca6 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java @@ -1,6 +1,8 @@ package com.fowoco.server.worker.api; +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.worker.application.WorkerDocumentCreateCommand; import com.fowoco.server.worker.application.WorkerDocumentPatchCommand; import com.fowoco.server.worker.application.WorkerDocumentService; @@ -13,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; @@ -120,9 +123,11 @@ public ResponseEntity register( public WorkerDocumentResponse patch( @Parameter(description = "근로자 ID") @PathVariable UUID workerId, @Parameter(description = "서류 ID") @PathVariable UUID documentId, - @Valid @RequestBody WorkerDocumentPatchRequest request + @Valid @RequestBody WorkerDocumentPatchRequest request, + HttpServletRequest servletRequest ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); WorkerDocumentPatchCommand command = new WorkerDocumentPatchCommand( documentId, workerId, @@ -135,7 +140,11 @@ public WorkerDocumentResponse patch( request.getFileId(), request.getExpectedVersion() ); - WorkerDocument document = workerDocumentService.patch(command); + WorkerDocument document = workerDocumentService.patch( + command, + actor, + RequestMetadata.from(servletRequest) + ); return WorkerDocumentResponse.from(document); } } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index db06b0a..d7e60ed 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -1,14 +1,25 @@ package com.fowoco.server.worker.application; +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.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.Objects; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,9 +27,12 @@ @Service public class WorkerDocumentService { + private static final String AUDIT_EVENT_VERSION = "1"; + private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; private final StoredFileRepository storedFileRepository; + private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -26,12 +40,14 @@ public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, StoredFileRepository storedFileRepository, + AuditEventRepository auditRepository, UuidGenerator uuidGenerator, Clock clock ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; this.storedFileRepository = storedFileRepository; + this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -63,7 +79,7 @@ public WorkerDocument findDetail(UUID workerDocumentId, UUID workerId, UUID comp } @Transactional - public WorkerDocument patch(WorkerDocumentPatchCommand command) { + public WorkerDocument patch(WorkerDocumentPatchCommand command, ActorContext actor, RequestMetadata metadata) { WorkerDocument existing = findDetail( command.workerDocumentId(), command.workerId(), @@ -74,7 +90,9 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { } UUID resolvedFileId = resolveFileId(command.fileId(), command.companyId(), existing.fileId()); + boolean fileNewlyLinked = command.fileId() != null && !command.fileId().equals(existing.fileId()); + Instant now = DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()); WorkerDocument updated = new WorkerDocument( existing.workerDocumentId(), existing.workerId(), @@ -86,11 +104,25 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { orElseKeep(command.note(), existing.note()), resolvedFileId, existing.createdAt(), - DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()), + now, existing.version() ); - return workerDocumentRepository.update(updated); + WorkerDocument saved = workerDocumentRepository.update(updated); + + if (fileNewlyLinked) { + appendAudit( + actor, + AuditAction.WORKER_DOCUMENT_FILE_LINKED, + AuditTargetType.WORKER_DOCUMENT, + saved.workerDocumentId(), + "서류에 파일 연결: file_id=" + resolvedFileId, + metadata, + now + ); + } + + return saved; } private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFileId) { @@ -102,6 +134,46 @@ private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFi return requestedFileId; } + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + 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; + }; + } + private static T orElseKeep(T newValue, T existingValue) { return newValue != null ? newValue : existingValue; } From 52f1e284b3f3f7e99ac5d6dafd3b9295a2eccf6c Mon Sep 17 00:00:00 2001 From: chaelin Date: Tue, 28 Jul 2026 21:57:18 +0900 Subject: [PATCH 33/36] =?UTF-8?q?feat(document):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=B4=88=EC=95=88=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DocumentRequestDraftController.java | 16 ++- .../DocumentRequestDraftService.java | 100 +++++++++++++++--- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java index 212a01d..ae89743 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java @@ -1,6 +1,8 @@ package com.fowoco.server.document.api; +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.document.application.DocumentRequestDraftCommand; import com.fowoco.server.document.application.DocumentRequestDraftService; import com.fowoco.server.document.domain.DocumentRequestDraft; @@ -12,6 +14,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.MediaType; @@ -43,6 +46,7 @@ public DocumentRequestDraftController( operationId = "upsertDocumentRequestDraft", summary = "문서 요청 초안 저장", description = "누락 문서를 근로자에게 요청할 안내문 초안을 만들거나 갱신합니다. " + + "이 저장만으로는 Worker Link 생성이나 메시지 발송이 되지 않습니다. " + "최초 생성 시 expected_version은 0이어야 합니다." ) @ApiResponses({ @@ -64,9 +68,11 @@ public DocumentRequestDraftController( @PreAuthorize("hasAnyRole('ADMIN', 'HR')") public DocumentRequestDraftResponse upsert( @Parameter(description = "업무 ID") @PathVariable UUID taskId, - @Valid @RequestBody DocumentRequestUpsertRequest request + @Valid @RequestBody DocumentRequestUpsertRequest request, + HttpServletRequest servletRequest ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); + UUID companyId = actor.companyId(); DocumentRequestDraftCommand command = new DocumentRequestDraftCommand( taskId, companyId, @@ -75,7 +81,11 @@ public DocumentRequestDraftResponse upsert( request.getMessage(), request.getExpectedVersion() ); - DocumentRequestDraft draft = documentRequestDraftService.upsert(command); + DocumentRequestDraft draft = documentRequestDraftService.upsert( + command, + actor, + RequestMetadata.from(servletRequest) + ); return DocumentRequestDraftResponse.from(draft); } } diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java index f8a8f99..dcc6fc5 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -1,13 +1,23 @@ package com.fowoco.server.document.application; +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.web.RequestMetadata; import com.fowoco.server.document.application.error.DocumentErrorCode; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; import com.fowoco.server.document.domain.DocumentRequestDraft; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,31 +25,43 @@ @Service public class DocumentRequestDraftService { + private static final String AUDIT_EVENT_VERSION = "1"; + private final TaskRepository taskRepository; private final DocumentRequestDraftRepository documentRequestDraftRepository; + private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; private final Clock clock; public DocumentRequestDraftService( TaskRepository taskRepository, DocumentRequestDraftRepository documentRequestDraftRepository, + AuditEventRepository auditRepository, UuidGenerator uuidGenerator, Clock clock ) { this.taskRepository = taskRepository; this.documentRequestDraftRepository = documentRequestDraftRepository; + this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public DocumentRequestDraft upsert(DocumentRequestDraftCommand command) { + public DocumentRequestDraft upsert( + DocumentRequestDraftCommand command, + ActorContext actor, + RequestMetadata metadata + ) { taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); Optional existing = documentRequestDraftRepository .findByTaskIdAndCompanyId(command.taskId(), command.companyId()); + Instant now = clock.instant(); + DocumentRequestDraft saved; + if (existing.isEmpty()) { DocumentRequestDraft draft = DocumentRequestDraft.create( uuidGenerator.generate(), @@ -48,22 +70,74 @@ public DocumentRequestDraft upsert(DocumentRequestDraftCommand command) { command.language(), command.documentTypes(), command.message(), - clock.instant() + now ); documentRequestDraftRepository.insert(draft); - return draft; + saved = draft; + } else { + DocumentRequestDraft current = existing.get(); + if (current.version() != command.expectedVersion()) { + throw new ApiException(DocumentErrorCode.DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT); + } + DocumentRequestDraft updated = current.withUpdatedContent( + command.language(), + command.documentTypes(), + command.message(), + now + ); + saved = documentRequestDraftRepository.update(updated); } - DocumentRequestDraft current = existing.get(); - if (current.version() != command.expectedVersion()) { - throw new ApiException(DocumentErrorCode.DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT); - } - DocumentRequestDraft updated = current.withUpdatedContent( - command.language(), - command.documentTypes(), - command.message(), - clock.instant() + appendAudit( + actor, + AuditAction.DOCUMENT_REQUEST_DRAFT_SAVED, + AuditTargetType.DOCUMENT_REQUEST_DRAFT, + saved.draftId(), + "문서 요청 초안 저장: task_id=" + command.taskId(), + metadata, + now ); - return documentRequestDraftRepository.update(updated); + + return saved; + } + + private void appendAudit( + ActorContext actor, + AuditAction action, + AuditTargetType targetType, + java.util.UUID targetId, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + targetType, + targetId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + 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; + }; } } From 6059c62250e24e9680a635e222bf6bd799a096de Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 29 Jul 2026 00:48:42 +0900 Subject: [PATCH 34/36] =?UTF-8?q?fix(file):=20LocalFileStorage=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + .../file/infrastructure/LocalFileStorage.java | 6 +- .../file/FileSecurityIntegrationTest.java | 201 ++++++++++++++++++ 3 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java diff --git a/.gitignore b/.gitignore index 344ef7c..0607470 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ out/ .env .env.* !.env.example + +### Local file storage ### +/data/ +*.log diff --git a/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java index 73ef157..a485393 100644 --- a/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java +++ b/src/main/java/com/fowoco/server/file/infrastructure/LocalFileStorage.java @@ -9,17 +9,13 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -/** - * 로컬 디스크에 파일을 저장하는 구현체. Controller/Service는 이 클래스의 존재를 모르고 FileStorage 인터페이스만 알아야 한다 - * 나중에 S3 호환 저장소로 교체할 때는 이 클래스만 새 구현체로 바꿔치기. - */ @Component public class LocalFileStorage implements FileStorage { private final Path rootDirectory; public LocalFileStorage(@Value("${app.file-storage.local-path}") String localPath) { - this.rootDirectory = Path.of(localPath); + this.rootDirectory = Path.of(localPath).normalize(); } @Override diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java new file mode 100644 index 0000000..ebf607d --- /dev/null +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -0,0 +1,201 @@ +package com.fowoco.server.file; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FileSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("70000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("71000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.file.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoTestBoundary1234"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompanyAndUser() { + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_A, + "사업장 A" + ); + String passwordHash = passwordEncoder.encode(PASSWORD); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + HR_A, + COMPANY_A, + HR_A_EMAIL, + HR_A_EMAIL, + passwordHash + ); + } + + @BeforeEach + void resetFileState() { + jdbcTemplate.update("DELETE FROM stored_file"); + } + + @Test + void uploadSucceedsAndAppendsAuditEvent() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile(token, "note.pdf", "application/pdf", "test file content".getBytes(StandardCharsets.UTF_8), "GENERAL"); + + assertThat(response.statusCode()).isEqualTo(201); + String fileId = JsonPath.read(response.body(), "$.file_id"); + assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("note.pdf"); + assertThat(JsonPath.read(response.body(), "$.scan_status")).isEqualTo("NOT_SCANNED"); + + java.util.List> allAuditRows = jdbcTemplate.queryForList("SELECT * FROM audit_event"); + System.out.println("DEBUG audit_event rows: " + allAuditRows); + System.out.println("DEBUG looking for fileId: " + fileId); + Integer auditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'FILE_UPLOADED'", + Integer.class, + UUID.fromString(fileId) + ); + assertThat(auditCount).isEqualTo(1); + } + + @Test + void uploadRejectsUnsupportedMimeType() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "script.exe", "application/x-msdownload", "malicious".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + @Test + void uploadRejectsNonExistentTaskId() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + UUID nonExistentTaskId = UUID.randomUUID(); + + HttpResponse response = uploadFileWithTask( + token, "note.pdf", "application/pdf", "content".getBytes(StandardCharsets.UTF_8), nonExistentTaskId + ); + + assertThat(response.statusCode()).isEqualTo(404); + } + + private HttpResponse uploadFile( + String token, String filename, String mimeType, byte[] content, String purpose + ) throws Exception { + return uploadFile(token, filename, mimeType, content, purpose, null); + } + + private HttpResponse uploadFile( + String token, String filename, String mimeType, byte[] content, String purpose, UUID taskId + ) throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "purpose", purpose); + if (taskId != null) { + writeFieldPart(out, "taskId", taskId.toString()); + } + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/files")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse uploadFileWithTask( + String token, String filename, String mimeType, byte[] content, UUID taskId + ) throws Exception { + return uploadFile(token, filename, mimeType, content, "TASK_EVIDENCE", taskId); + } + + + private void writePart( + java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content + ) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write( + ("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n") + .getBytes(StandardCharsets.UTF_8) + ); + out.write(("Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void writeFieldPart(java.io.ByteArrayOutputStream out, String name, String value) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(value.getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} From 5a64b0cf4ef8ce6388a7186d00803211b3021c0e Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 29 Jul 2026 01:38:17 +0900 Subject: [PATCH 35/36] =?UTF-8?q?fix(document):=20DocumentRequestDraft=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EC=88=9C=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DocumentRequestDraftJpaEntity.java | 2 + .../DocumentSecurityIntegrationTest.java | 344 ++++++++++++++++++ .../file/FileSecurityIntegrationTest.java | 15 +- 3 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java diff --git a/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java index bcf0ae0..657964d 100644 --- a/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java +++ b/src/main/java/com/fowoco/server/document/infrastructure/persistence/DocumentRequestDraftJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; +import jakarta.persistence.Version; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; @@ -56,6 +57,7 @@ public class DocumentRequestDraftJpaEntity { @Column(name = "updated_at", nullable = false) private Instant updatedAt; + @Version @Column(name = "version", nullable = false) private long version; diff --git a/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java new file mode 100644 index 0000000..a57fdf2 --- /dev/null +++ b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java @@ -0,0 +1,344 @@ +package com.fowoco.server.document; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DocumentSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("80000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("81000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("91000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.doc.a@example.com"; + private static final String HR_B_EMAIL = "hr.doc.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoDocTestBoundary1234"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompaniesAndUsers() { + cleanupAll(); + insertCompany(COMPANY_A, "사업장 A"); + insertCompany(COMPANY_B, "사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + } + + @BeforeEach + void resetState() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + } + + private void cleanupAll() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); + jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void readinessReflectsMissingThenAvailableAfterDocumentLinked() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "준비도테스트근로자"); + String taskId = createTask(token, workerId); + + HttpResponse beforeResponse = getJson("/api/v1/tasks/" + taskId + "/document-readiness", token); + assertThat(beforeResponse.statusCode()).isEqualTo(200); + List missingBefore = JsonPath.read(beforeResponse.body(), "$.missing"); + assertThat(missingBefore).containsExactlyInAnyOrder("CONTRACT", "PERMIT"); + assertThat(JsonPath.read(beforeResponse.body(), "$.completion_blocked")).isTrue(); + + String fileId = uploadFile(token, "contract.pdf", "application/pdf", "contract content".getBytes(StandardCharsets.UTF_8)); + String documentId = registerWorkerDocument(token, workerId, "CONTRACT"); + linkFileToDocument(token, workerId, documentId, fileId, 0); + + String fileId2 = uploadFile(token, "permit.pdf", "application/pdf", "permit content".getBytes(StandardCharsets.UTF_8)); + String documentId2 = registerWorkerDocument(token, workerId, "PERMIT"); + linkFileToDocument(token, workerId, documentId2, fileId2, 0); + + HttpResponse afterResponse = getJson("/api/v1/tasks/" + taskId + "/document-readiness", token); + List availableAfter = JsonPath.read(afterResponse.body(), "$.available"); + assertThat(availableAfter).containsExactlyInAnyOrder("CONTRACT", "PERMIT"); + assertThat(JsonPath.read(afterResponse.body(), "$.completion_blocked")).isFalse(); + } + + @Test + void readinessReturnsNotFoundForOtherCompanyTask() throws Exception { + String tokenA = accessToken(login(HR_A_EMAIL)); + String tokenB = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(tokenA, "격리테스트근로자"); + String taskId = createTask(tokenA, workerId); + + HttpResponse response = getJson("/api/v1/tasks/" + taskId + "/document-readiness", tokenB); + + assertThat(response.statusCode()).isEqualTo(404); + } + + @Test + void listDocumentsIncludesWorkerDisplayNameAndFiltersByWorker() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "목록조회테스트"); + registerWorkerDocument(token, workerId, "CONTRACT"); + + HttpResponse response = getJson("/api/v1/documents?worker_id=" + workerId, token); + + assertThat(response.statusCode()).isEqualTo(200); + List items = JsonPath.read(response.body(), "$.items"); + assertThat(items).hasSize(1); + assertThat(JsonPath.read(response.body(), "$.items[0].display_name")) + .isEqualTo("목록조회테스트"); + } + + @Test + void documentRequestDraftCreatesThenUpdatesThenRejectsStaleVersion() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(token, "초안테스트근로자"); + String taskId = createTask(token, workerId); + String path = "/api/v1/tasks/" + taskId + "/document-request-draft"; + + String createBody = """ + {"language":"vi","document_types":["CONTRACT"],"message":"계약서를 제출해 주세요.","expected_version":0} + """; + HttpResponse createResponse = putJson(path, createBody, token); + assertThat(createResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(createResponse.body(), "$.version").longValue()).isZero(); + assertThat(JsonPath.read(createResponse.body(), "$.review_status")).isEqualTo("DRAFT"); + + String updateBody = """ + {"language":"ko","document_types":["CONTRACT","PERMIT"],"message":"수정된 안내","expected_version":0} + """; + HttpResponse updateResponse = putJson(path, updateBody, token); + assertThat(updateResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updateResponse.body(), "$.version").longValue()).isEqualTo(1); + + HttpResponse staleResponse = putJson(path, updateBody, token); + assertThat(staleResponse.statusCode()).isEqualTo(409); + } + + @Test + void documentRequestDraftReturnsNotFoundForNonExistentTask() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + String path = "/api/v1/tasks/" + UUID.randomUUID() + "/document-request-draft"; + + String body = """ + {"language":"vi","document_types":["CONTRACT"],"expected_version":0} + """; + HttpResponse response = putJson(path, body, token); + + assertThat(response.statusCode()).isEqualTo(404); + } + + private String registerWorker(String token, String displayName) throws Exception { + String body = """ + {"display_name":"%s"} + """.formatted(displayName); + HttpResponse response = postJson("/api/v1/workers", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private String createTask(String token, String workerId) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"재계약 준비", + "description":"기존 조건 확인", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId); + HttpResponse response = postJson("/api/v1/tasks", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.task_id"); + } + + private String registerWorkerDocument(String token, String workerId, String documentType) throws Exception { + String body = """ + {"document_type":"%s","submission_status":"SUBMITTED"} + """.formatted(documentType); + HttpResponse response = postJson("/api/v1/workers/" + workerId + "/documents", body, token); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_document_id"); + } + + private void linkFileToDocument(String token, String workerId, String documentId, String fileId, long expectedVersion) + throws Exception { + String body = """ + {"file_id":"%s","expected_version":%d} + """.formatted(fileId, expectedVersion); + HttpResponse response = patchJson( + "/api/v1/workers/" + workerId + "/documents/" + documentId, body, token + ); + assertThat(response.statusCode()).isEqualTo(200); + } + + private String uploadFile(String token, String filename, String mimeType, byte[] content) throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "purpose", "WORKER_DOCUMENT"); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/files")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.file_id"); + } + + private void writePart( + java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content + ) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write( + ("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n") + .getBytes(StandardCharsets.UTF_8) + ); + out.write(("Content-Type: " + mimeType + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(content); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void writeFieldPart(java.io.ByteArrayOutputStream out, String name, String value) throws java.io.IOException { + out.write(("--" + BOUNDARY + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(value.getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse getJson(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "POST"); + } + + private HttpResponse patchJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "PATCH"); + } + + private HttpResponse putJson(String path, String body, String token) throws Exception { + return sendJson(path, body, token, "PUT"); + } + + private HttpResponse sendJson(String path, String body, String token, String method) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index ebf607d..5c10efb 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -45,13 +45,20 @@ class FileSecurityIntegrationTest { @BeforeAll void seedCompanyAndUser() { + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); jdbcTemplate.update("DELETE FROM stored_file"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); jdbcTemplate.update("DELETE FROM worker_document"); jdbcTemplate.update("DELETE FROM worker"); - jdbcTemplate.update("DELETE FROM refresh_token"); - jdbcTemplate.update("DELETE FROM user_account"); - jdbcTemplate.update("DELETE FROM company"); - jdbcTemplate.update( """ INSERT INTO company (company_id, name, status, created_at, updated_at, version) From 72a13f3c23f809471c7ea039d2599de84deed377 Mon Sep 17 00:00:00 2001 From: chaelin Date: Wed, 29 Jul 2026 13:19:31 +0900 Subject: [PATCH 36/36] =?UTF-8?q?fix(document):=20migration=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20V11=20->=20V9=EB=A1=9C=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nt_request_draft.sql => V9__create_document_request_draft.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V11__create_document_request_draft.sql => V9__create_document_request_draft.sql} (100%) diff --git a/src/main/resources/db/migration/V11__create_document_request_draft.sql b/src/main/resources/db/migration/V9__create_document_request_draft.sql similarity index 100% rename from src/main/resources/db/migration/V11__create_document_request_draft.sql rename to src/main/resources/db/migration/V9__create_document_request_draft.sql