Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
611e51c
feat(document): DocumentItemResponse에 version, task_id 필드 추가
chaeliki Aug 5, 2026
efb495a
feat(worker): findByIdAndCompanyId 단건 조회 메서드 추가
chaeliki Aug 5, 2026
8041403
feat(document): DocumentService에 단건 조회 메서드 추가
chaeliki Aug 5, 2026
b725afe
feat(document): DocumentDetailResponse 추가, 기타 정리
chaeliki Aug 5, 2026
9a74b1c
test(document): 단건 조회 API 검증 통합 테스트 (정상 조회, 타 사업장 차단)
chaeliki Aug 5, 2026
230774a
Merge remote-tracking branch 'origin/main' into feat/80-document-deta…
chaeliki Aug 5, 2026
7377fad
feat(file): HWPX(application/hwp+zip) MIME 타입 지원 추가
chaeliki Aug 5, 2026
18a080c
test(file): FileSecurityIntegrationTest에 @AfterAll 정리 추가
chaeliki Aug 6, 2026
2d96cf1
build: HWP 시그니처 검증을 위한 Apache POI(POIFS) 의존성 추가
chaeliki Aug 6, 2026
ea0adc7
feat(file): HwpSignatureValidator 추가 (OLE FileHeader 스트림 signature 검증)
chaeliki Aug 6, 2026
5aefdbf
feat(file): HWP 확장자 파일에 시그니처 검증 적용
chaeliki Aug 6, 2026
06374c9
Merge remote-tracking branch 'origin/main' into feat/80-document-deta…
chaeliki Aug 6, 2026
b6c4307
test(document): merge 과정에서 유실된 단건 조회 검증 테스트 복구
chaeliki Aug 6, 2026
dff91aa
test(file): HwpSignatureValidator 단위 테스트 추가 (실제 통과 확인됨)
chaeliki Aug 6, 2026
492dea0
test(file): HWP/HWPX 업로드 통합 테스트 추가 (시그니처 검증, MIME 검증 실제 확인)
chaeliki Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
compileOnly 'org.projectlombok:lombok'
implementation 'org.apache.poi:poi:5.4.0'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.document.application.DocumentDetailResult;
import com.fowoco.server.document.application.DocumentPageResult;
import com.fowoco.server.document.application.DocumentService;
import com.fowoco.server.worker.application.WorkerDocumentSearchQuery;
Expand All @@ -23,6 +24,7 @@
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand Down Expand Up @@ -96,4 +98,30 @@ public DocumentPageResponse list(
.toList();
return new DocumentPageResponse(items, result.page(), result.size(), result.totalElements());
}

@Operation(
operationId = "getDocument",
summary = "서류 단건 상세 조회",
description = "연결된 파일 정보와 expected_version 기준값을 함께 반환합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = DocumentDetailResponse.class)
)
),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound")
})
@GetMapping(path = "/{documentId}", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
public DocumentDetailResponse findById(@Parameter(description = "서류 ID") @PathVariable UUID documentId) {
ActorContext actor = actorContextProvider.requireCurrentActor();
DocumentDetailResult result = documentService.findById(documentId, actor);
return DocumentDetailResponse.from(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.fowoco.server.document.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.document.application.DocumentDetailResult;
import com.fowoco.server.file.domain.ScanStatus;
import com.fowoco.server.worker.domain.DocumentType;
import com.fowoco.server.worker.domain.SubmissionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.UUID;

@Schema(name = "DocumentDetailResponse", description = "서류 단건 상세 응답 (연결된 파일 정보 포함)")
public final class DocumentDetailResponse {

@JsonProperty("worker_document_id")
@Schema(name = "worker_document_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED)
private final UUID workerDocumentId;

@JsonProperty("worker_id")
@Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED)
private final UUID workerId;

@JsonProperty("task_id")
@Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID")
private final UUID taskId;

@JsonProperty("display_name")
@Schema(name = "display_name", description = "근로자 화면 표시 이름")
private final String displayName;

@JsonProperty("document_type")
@Schema(name = "document_type", requiredMode = Schema.RequiredMode.REQUIRED)
private final DocumentType documentType;

@JsonProperty("submission_status")
@Schema(name = "submission_status", requiredMode = Schema.RequiredMode.REQUIRED)
private final SubmissionStatus submissionStatus;

@JsonProperty("expiry_date")
@Schema(name = "expiry_date", format = "date")
private final LocalDate expiryDate;

@JsonProperty("version")
@Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED)
private final long version;

@JsonProperty("file_id")
@Schema(name = "file_id", format = "uuid")
private final UUID fileId;

@JsonProperty("file_name")
@Schema(name = "file_name", description = "연결된 파일의 표시 파일명 (파일 없으면 null)")
private final String fileName;

@JsonProperty("file_mime_type")
@Schema(name = "file_mime_type", description = "연결된 파일의 MIME 타입 (파일 없으면 null)")
private final String fileMimeType;

@JsonProperty("file_size")
@Schema(name = "file_size", description = "연결된 파일의 크기(byte) (파일 없으면 null)")
private final Long fileSize;

@JsonProperty("file_scan_status")
@Schema(name = "file_scan_status", description = "연결된 파일의 검사 상태 (파일 없으면 null)")
private final ScanStatus fileScanStatus;

private DocumentDetailResponse(
UUID workerDocumentId,
UUID workerId,
UUID taskId,
String displayName,
DocumentType documentType,
SubmissionStatus submissionStatus,
LocalDate expiryDate,
long version,
UUID fileId,
String fileName,
String fileMimeType,
Long fileSize,
ScanStatus fileScanStatus
) {
this.workerDocumentId = workerDocumentId;
this.workerId = workerId;
this.taskId = taskId;
this.displayName = displayName;
this.documentType = documentType;
this.submissionStatus = submissionStatus;
this.expiryDate = expiryDate;
this.version = version;
this.fileId = fileId;
this.fileName = fileName;
this.fileMimeType = fileMimeType;
this.fileSize = fileSize;
this.fileScanStatus = fileScanStatus;
}

public static DocumentDetailResponse from(DocumentDetailResult result) {
var document = result.document();
var storedFile = result.storedFile();
return new DocumentDetailResponse(
document.workerDocumentId(),
document.workerId(),
document.taskId(),
result.workerDisplayName(),
document.documentType(),
document.submissionStatus(),
document.expiryDate(),
document.version(),
document.fileId(),
storedFile == null ? null : storedFile.name(),
storedFile == null ? null : storedFile.mimeType(),
storedFile == null ? null : storedFile.size(),
storedFile == null ? null : storedFile.scanStatus()
);
}

public UUID getWorkerDocumentId() {
return workerDocumentId;
}

public UUID getWorkerId() {
return workerId;
}

public UUID getTaskId() {
return taskId;
}

public String getWorkerDisplayName() {
return displayName;
}

public DocumentType getDocumentType() {
return documentType;
}

public SubmissionStatus getSubmissionStatus() {
return submissionStatus;
}

public LocalDate getExpiryDate() {
return expiryDate;
}

public long getVersion() {
return version;
}

public UUID getFileId() {
return fileId;
}

public String getFileName() {
return fileName;
}

public String getFileMimeType() {
return fileMimeType;
}

public Long getFileSize() {
return fileSize;
}

public ScanStatus getFileScanStatus() {
return fileScanStatus;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public final class DocumentItemResponse {
@Schema(name = "worker_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED)
private final UUID workerId;

@JsonProperty("task_id")
@Schema(name = "task_id", format = "uuid", description = "연결된 업무카드 ID")
private final UUID taskId;

@JsonProperty("display_name")
@Schema(
name = "display_name",
Expand All @@ -43,33 +47,43 @@ public final class DocumentItemResponse {
@Schema(name = "file_id", format = "uuid")
private final UUID fileId;

@JsonProperty("version")
@Schema(name = "version", description = "PATCH 요청의 expected_version 기준값", requiredMode = Schema.RequiredMode.REQUIRED)
private final long version;

private DocumentItemResponse(
UUID workerDocumentId,
UUID workerId,
UUID taskId,
String displayName,
DocumentType documentType,
SubmissionStatus submissionStatus,
LocalDate expiryDate,
UUID fileId
UUID fileId,
long version
) {
this.workerDocumentId = workerDocumentId;
this.workerId = workerId;
this.taskId = taskId;
this.displayName = displayName;
this.documentType = documentType;
this.submissionStatus = submissionStatus;
this.expiryDate = expiryDate;
this.fileId = fileId;
this.version = version;
}

public static DocumentItemResponse from(WorkerDocument document, String displayName) {
return new DocumentItemResponse(
document.workerDocumentId(),
document.workerId(),
document.taskId(),
displayName,
document.documentType(),
document.submissionStatus(),
document.expiryDate(),
document.fileId()
document.fileId(),
document.version()
);
}

Expand All @@ -81,6 +95,10 @@ public UUID getWorkerId() {
return workerId;
}

public UUID getTaskId() {
return taskId;
}

public String getWorkerDisplayName() {
return displayName;
}
Expand All @@ -100,4 +118,8 @@ public LocalDate getExpiryDate() {
public UUID getFileId() {
return fileId;
}

public long getVersion() {
return version;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.fowoco.server.document.application;

import com.fowoco.server.file.domain.StoredFile;
import com.fowoco.server.worker.domain.WorkerDocument;

public record DocumentDetailResult(WorkerDocument document, String workerDisplayName, StoredFile storedFile) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.fowoco.server.document.application;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.document.application.error.DocumentErrorCode;
import com.fowoco.server.file.application.port.StoredFileRepository;
import com.fowoco.server.file.domain.StoredFile;
import com.fowoco.server.worker.application.WorkerDocumentSearchQuery;
import com.fowoco.server.worker.application.port.WorkerDocumentRepository;
import com.fowoco.server.worker.application.port.WorkerRepository;
Expand All @@ -21,18 +25,38 @@ public class DocumentService {

private final WorkerDocumentRepository workerDocumentRepository;
private final WorkerRepository workerRepository;
private final StoredFileRepository storedFileRepository;
private final TenantDatabaseContext tenantDatabaseContext;

public DocumentService(
WorkerDocumentRepository workerDocumentRepository,
WorkerRepository workerRepository,
StoredFileRepository storedFileRepository,
TenantDatabaseContext tenantDatabaseContext
) {
this.workerDocumentRepository = workerDocumentRepository;
this.workerRepository = workerRepository;
this.storedFileRepository = storedFileRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
}

@Transactional(readOnly = true)
public DocumentDetailResult findById(UUID workerDocumentId, ActorContext actor) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
UUID companyId = actor.companyId();
WorkerDocument document = workerDocumentRepository
.findByIdAndCompanyId(workerDocumentId, companyId)
.orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_NOT_FOUND));
String displayName = workerRepository
.findByWorkerIdAndCompanyId(document.workerId(), companyId)
.map(Worker::displayName)
.orElse(null);
StoredFile storedFile = document.fileId() == null
? null
: storedFileRepository.findByIdAndCompanyId(document.fileId(), companyId).orElse(null);
return new DocumentDetailResult(document, displayName, storedFile);
}

@Transactional(readOnly = true)
public DocumentPageResult findPage(ActorContext actor, WorkerDocumentSearchQuery query) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ public enum DocumentErrorCode implements ApiErrorCode {
DOCUMENT_REQUEST_DRAFT_VERSION_CONFLICT(
HttpStatus.CONFLICT,
"다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."
),
DOCUMENT_NOT_FOUND(
HttpStatus.NOT_FOUND,
"문서를 찾을 수 없습니다."
);

private final HttpStatus status;
Expand Down
Loading
Loading