Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4b861ef
feat(workerlink): WorkerLinkStatus, ConversationStatus, WorkerRespons…
chaeliki Jul 31, 2026
f458713
feat(workerlink): WorkerResponse 도메인 추가
chaeliki Jul 31, 2026
8ef493b
feat(workerlink): WorkerLinkRepository, WorkerResponseRepository, Wor…
chaeliki Jul 31, 2026
2c058ec
feat(workerlink): worker_link, worker_response, worker_response_uploa…
chaeliki Jul 31, 2026
8c6b0bb
feat(workerlink): WorkerLink/WorkerResponse JPA Entity, JpaWorkerLink…
chaeliki Jul 31, 2026
3776207
feat(workerlink): tenant bootstrap 추가
chaeliki Jul 31, 2026
0abfce3
feat(workerlink): 에러코드, 발급 Command 추가
chaeliki Jul 31, 2026
865294e
feat(workerlink): 토큰 생성기, 해셔 추가
chaeliki Aug 1, 2026
808e392
feat(workerlink): 발급 Service, DTO 추가
chaeliki Aug 1, 2026
6ccaa92
feat(workerlink): 발급 Controller 추가
chaeliki Aug 1, 2026
3bae17c
feat(workerlink): 공개 조회 결과 DTO 추가
chaeliki Aug 1, 2026
9c12807
feat(workerlink): 공개 조회 Service 추가
chaeliki Aug 1, 2026
9738582
feat(workerlink): 문서 업로드 Command 추가
chaeliki Aug 1, 2026
fce106e
feat(workerlink): 공개 조회 API 추가
chaeliki Aug 1, 2026
4658514
feat(workerlink): 문서 업로드 Service 추가, 감사로그 연동
chaeliki Aug 1, 2026
937c3d7
fix(workerlink): audit_event request_id NOT NULL 제약 반영
chaeliki Aug 1, 2026
f03d25a
feat(workerlink): 문서 업로드 결과에 링크 만료시각 포함
chaeliki Aug 1, 2026
7edd699
feat(workerlink): 문서 업로드 API 추가
chaeliki Aug 1, 2026
f7e98d8
feat(workerlink): 응답 제출 Command 추가
chaeliki Aug 1, 2026
eeaea7c
fix(workerlink): migration 번호 재조정, 값 정리
chaeliki Aug 1, 2026
82f6aaa
feat(workerlink): WorkerResponseUpload 연결/조회 메서드 추가
chaeliki Aug 1, 2026
6b327b8
feat(workerlink): 응답 제출 Service 완성, 감사로그 연동, 응답유형 정정
chaeliki Aug 2, 2026
5fdcd67
feat(workerlink): 응답 제출 API 추가
chaeliki Aug 2, 2026
61fa7a5
feat(workerlink): 공개 조회 응답에 Cache-Control no-store 추가
chaeliki Aug 2, 2026
a436cbd
fix(workerlink): SecurityConfig에 공개 API permitAll 등록 누락 수정
chaeliki Aug 2, 2026
4c6657c
test(workerlink): 발급~응답제출 전체 흐름 및 tenant 격리 통합 테스트 추가
chaeliki Aug 2, 2026
4c53add
fix(workerlink): 404/410 통일, conversationStatus
chaeliki Aug 2, 2026
09f63a6
feat(workerlink): Idempotency-Key헤더 검증 로직
chaeliki Aug 3, 2026
374acee
feat(workerlink): PostgreSQL tenant bootstrap
chaeliki Aug 3, 2026
f146987
feat(workerlink): PostgreSQL SECURITY DEFINER 함수 및 RLS 정책 추가 (V13)
chaeliki Aug 3, 2026
427805d
fix(workerlink): 문서 업로드 API Idempotency-Key 헤더
chaeliki Aug 3, 2026
b962ca6
fix(workerlink): idempotency key를 해시로 저장하도록 수정
chaeliki Aug 3, 2026
5cb0c11
fix(workerlink): RLS 정책 삭제, 함수만 유지
chaeliki Aug 3, 2026
2ff71ff
test: PostgreSqlMigrationTests에 worker_link 함수 목록 반영
chaeliki Aug 3, 2026
c45b4f3
feat(workerlink): already_issued 필드 반영, RLS 정책,감사로그 최종 마무리
chaeliki Aug 4, 2026
294104c
Merge remote-tracking branch 'origin/main' into feat/7-worker-link
chaeliki Aug 4, 2026
bde7bdf
fix(workerlink): 공개 API 경로/public/worker-links/**로 변경
chaeliki Aug 4, 2026
6b5e04c
feat(workerlink): 문서 업로드 clientRequestId 멱등성 구현, 재시도 통합 테스트 추가
chaeliki Aug 4, 2026
11262d3
feat(file): StoredFile.verify() 추가
chaeliki Aug 4, 2026
84e22a8
feat(workerlink): CORS 허용 헤더에 Idempotency-Key 추가, OPTIONS 통합 테스트
chaeliki Aug 4, 2026
8075961
Merge remote-tracking branch 'origin/main' into feat/7-worker-link
chaeliki Aug 4, 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
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ public enum AuditAction {
TASK_COMPLETED,
FILE_UPLOADED,
WORKER_DOCUMENT_FILE_LINKED,
DOCUMENT_REQUEST_DRAFT_SAVED
DOCUMENT_REQUEST_DRAFT_SAVED,
Comment thread
chaeliki marked this conversation as resolved.
WORKER_LINK_RESPONSE_SUBMITTED,
WORKER_LINK_ACCESSED
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public CorsConfigurationSource corsConfigurationSource(
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id", "Idempotency-Key"));
configuration.setExposedHeaders(List.of("X-Request-Id"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public SecurityFilterChain applicationSecurityFilterChain(
"/api/v1/auth/logout"
).permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers(HttpMethod.GET, "/public/worker-links/**").permitAll()
Comment thread
chaeliki marked this conversation as resolved.
.requestMatchers(HttpMethod.POST, "/public/worker-links/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/**")
.hasAnyRole("ADMIN", "HR", "VIEWER")
.requestMatchers(HttpMethod.HEAD, "/api/v1/**")
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/fowoco/server/file/domain/StoredFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ public static StoredFile create(
);
}

/**
* 검증을 통과한 파일임을 표시한 새 StoredFile을 반환한다.
* #7(Worker Link)의 "격리 저장 → 검증 → 검증된 ID만 연결" 흐름에서 사용한다.
*/
public StoredFile verify() {
return new StoredFile(
storedFileId,
companyId,
name,
mimeType,
size,
purpose,
taskId,
workerId,
storageKey,
scanStatus,
true,
createdAt
);
}

private static String requireBounded(String value, int maxLength, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.fowoco.server.workerlink.api;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.workerlink.application.WorkerLinkIssueCommand;
import com.fowoco.server.workerlink.application.WorkerLinkIssueResult;
import com.fowoco.server.workerlink.application.WorkerLinkService;
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.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Worker Link", description = "근로자 보안 링크 발급")
@RestController
@RequestMapping("/api/v1/tasks/{taskId}/worker-link")
@SecurityRequirement(name = "bearerAuth")
public class WorkerLinkController {

private final WorkerLinkService workerLinkService;
private final ActorContextProvider actorContextProvider;

public WorkerLinkController(
WorkerLinkService workerLinkService,
ActorContextProvider actorContextProvider
) {
this.workerLinkService = workerLinkService;
this.actorContextProvider = actorContextProvider;
}

@Operation(
operationId = "issueWorkerLink",
summary = "근로자 보안 링크 발급",
description = "승인된 현재 업무카드 version에서만 발급 가능합니다. "
+ "rotate_existing=true면 기존 활성 링크를 즉시 폐기하고 재발급합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "발급 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = WorkerLinkIssueResponse.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", description = "이미 유효한 링크가 있어 rotate_existing 필요"),
@ApiResponse(responseCode = "422", description = "승인되지 않은 업무카드")
})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public ResponseEntity<WorkerLinkIssueResponse> issue(
@Parameter(description = "업무 ID") @PathVariable UUID taskId,
@Valid @RequestBody WorkerLinkIssueRequest request,
//: 나중에 확인 필요
Comment thread
chaeliki marked this conversation as resolved.
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
WorkerLinkIssueCommand command = new WorkerLinkIssueCommand(
taskId,
actor.companyId(),
request.getExpiresInHours(),
request.isRotateExisting(),
actor.actorId(),
idempotencyKey
);
WorkerLinkIssueResult result = workerLinkService.issue(command);
return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.fowoco.server.workerlink.api;

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.workerlink.application.WorkerLinkDocumentService;
import com.fowoco.server.workerlink.application.WorkerLinkDocumentUploadCommand;
import com.fowoco.server.workerlink.application.WorkerLinkDocumentUploadResult;
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.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.UncheckedIOException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "Worker Link (Public)", description = "근로자 공개 안내·제출")
@RestController
public class WorkerLinkDocumentController {

private final WorkerLinkDocumentService workerLinkDocumentService;

public WorkerLinkDocumentController(WorkerLinkDocumentService workerLinkDocumentService) {
this.workerLinkDocumentService = workerLinkDocumentService;
}

@Operation(
operationId = "uploadWorkerLinkDocument",
summary = "근로자 링크 문서 제출",
description = "만료·폐기된 token은 거절합니다. 확장자·MIME·크기·악성파일 검사 후 격리 저장합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "업로드 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = WorkerLinkDocumentUploadResponse.class)
)
),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"),
@ApiResponse(responseCode = "413", description = "파일 크기 초과"),
@ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"),
@ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity"),
@ApiResponse(responseCode = "429", description = "요청 과다")
})
@PostMapping(
path = "/public/worker-links/{token}/documents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<WorkerLinkDocumentUploadResponse> upload(
@Parameter(description = "근로자 링크 토큰") @PathVariable String token,
@Parameter(description = "업로드할 파일") @RequestParam("file") MultipartFile file,
@Parameter(description = "문서 유형") @RequestParam(value = "documentType", required = false) String documentType,
@Parameter(description = "클라이언트 중복 방지 키") @RequestParam("clientRequestId") String clientRequestId,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
HttpServletRequest servletRequest
) {
if (file.isEmpty() || file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) {
throw new ApiException(ErrorCode.VALIDATION_FAILED, "업로드할 파일과 파일명이 필요합니다.");
}
try {
WorkerLinkDocumentUploadCommand command = new WorkerLinkDocumentUploadCommand(
token,
file.getOriginalFilename(),
file.getContentType(),
file.getSize(),
documentType,
clientRequestId,
file.getInputStream()
);
WorkerLinkDocumentUploadResult result = workerLinkDocumentService.upload(
command,
RequestMetadata.from(servletRequest)
);
return ResponseEntity.status(HttpStatus.CREATED)
.body(WorkerLinkDocumentUploadResponse.from(result.storedFile(), result.linkExpiresAt()));
} catch (IOException exception) {
throw new UncheckedIOException("failed to read uploaded file", exception);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.fowoco.server.workerlink.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.file.domain.StoredFile;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.UUID;

@Schema(name = "WorkerLinkDocumentUploadResponse", description = "근로자 링크 문서 업로드 결과")
public final class WorkerLinkDocumentUploadResponse {

@JsonProperty("upload_id")
@Schema(name = "upload_id")
private final UUID uploadId;

@JsonProperty("file_name")
@Schema(name = "file_name")
private final String fileName;

@JsonProperty("size")
private final long size;

@JsonProperty("expires_at")
@Schema(name = "expires_at")
private final Instant expiresAt;

private WorkerLinkDocumentUploadResponse(UUID uploadId, String fileName, long size, Instant expiresAt) {
this.uploadId = uploadId;
this.fileName = fileName;
this.size = size;
this.expiresAt = expiresAt;
}

public static WorkerLinkDocumentUploadResponse from(StoredFile storedFile, Instant linkExpiresAt) {
return new WorkerLinkDocumentUploadResponse(
storedFile.storedFileId(),
storedFile.name(),
storedFile.size(),
linkExpiresAt
);
}

public UUID getUploadId() {
return uploadId;
}

public String getFileName() {
return fileName;
}

public long getSize() {
return size;
}

public Instant getExpiresAt() {
return expiresAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.fowoco.server.workerlink.api;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(name = "WorkerLinkIssueRequest", description = "근로자 보안 링크 발급 요청")
public final class WorkerLinkIssueRequest {

@Schema(name = "expires_in_hours", description = "링크 유효 시간(시간 단위)", example = "72")
private final Long expiresInHours;

@Schema(name = "rotate_existing", description = "기존 활성 링크를 폐기하고 재발급할지 여부")
private final boolean rotateExisting;

@JsonCreator
public WorkerLinkIssueRequest(
@JsonProperty("expires_in_hours") Long expiresInHours,
@JsonProperty("rotate_existing") Boolean rotateExisting
) {
this.expiresInHours = expiresInHours;
this.rotateExisting = rotateExisting != null && rotateExisting;
}

public Long getExpiresInHours() {
return expiresInHours;
}

public boolean isRotateExisting() {
return rotateExisting;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.fowoco.server.workerlink.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.workerlink.application.WorkerLinkIssueResult;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;

@Schema(name = "WorkerLinkIssueResponse", description = "근로자 보안 링크 발급 결과")
public final class WorkerLinkIssueResponse {

@JsonProperty("worker_url")
@Schema(name = "worker_url", description = "근로자에게 전달할 링크 URL. 같은 idempotency key로 재요청한 경우 null")
private final String workerUrl;

@JsonProperty("expires_at")
@Schema(name = "expires_at")
private final Instant expiresAt;

@JsonProperty("already_issued")
@Schema(name = "already_issued", description = "같은 idempotency key로 이미 발급된 적이 있어 재사용된 응답인지 여부")
private final boolean alreadyIssued;

private WorkerLinkIssueResponse(String workerUrl, Instant expiresAt, boolean alreadyIssued) {
this.workerUrl = workerUrl;
this.expiresAt = expiresAt;
this.alreadyIssued = alreadyIssued;
}

public static WorkerLinkIssueResponse from(WorkerLinkIssueResult result) {
// TODO: workerUrl 조립 방식 확정 필요.
// 확인 후 실제 프론트 base URL + 경로로 조립해야 함.
// 지금은 원문 토큰만 그대로 노출한 상태(미완성).
// 재시도 worker_url을 null로 반환함.
return new WorkerLinkIssueResponse(result.rawToken(), result.expiresAt(), result.alreadyIssued());
}

public String getWorkerUrl() {
return workerUrl;
}

public Instant getExpiresAt() {
return expiresAt;
}

public boolean isAlreadyIssued() {
return alreadyIssued;
}
}
Loading
Loading