Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
80d7e90
feat(workerlink): WorkerLinkStatus, ConversationStatus, WorkerRespons…
chaeliki Jul 31, 2026
a12bc99
feat(workerlink): WorkerResponse 도메인 추가
chaeliki Jul 31, 2026
fdad556
feat(workerlink): WorkerLinkRepository, WorkerResponseRepository, Wor…
chaeliki Jul 31, 2026
92a5aa6
feat(workerlink): worker_link, worker_response, worker_response_uploa…
chaeliki Jul 31, 2026
2337450
feat(workerlink): WorkerLink/WorkerResponse JPA Entity, JpaWorkerLink…
chaeliki Jul 31, 2026
666eb6e
feat(workerlink): tenant bootstrap 추가
chaeliki Jul 31, 2026
5663f9d
feat(workerlink): 에러코드, 발급 Command 추가
chaeliki Jul 31, 2026
66d15fa
feat(workerlink): 토큰 생성기, 해셔 추가
chaeliki Aug 1, 2026
6f3f91e
feat(workerlink): 발급 Service, DTO 추가
chaeliki Aug 1, 2026
1e8057a
feat(workerlink): 발급 Controller 추가
chaeliki Aug 1, 2026
e0667b4
feat(workerlink): 공개 조회 결과 DTO 추가
chaeliki Aug 1, 2026
c178a76
feat(workerlink): 공개 조회 Service 추가
chaeliki Aug 1, 2026
cf16234
feat(workerlink): 문서 업로드 Command 추가
chaeliki Aug 1, 2026
e1b4a41
feat(workerlink): 공개 조회 API 추가
chaeliki Aug 1, 2026
fdfdc8f
feat(workerlink): 문서 업로드 Service 추가, 감사로그 연동
chaeliki Aug 1, 2026
dde0ad7
fix(workerlink): audit_event request_id NOT NULL 제약 반영
chaeliki Aug 1, 2026
79d3c01
feat(workerlink): 문서 업로드 결과에 링크 만료시각 포함
chaeliki Aug 1, 2026
4d26359
feat(workerlink): 문서 업로드 API 추가
chaeliki Aug 1, 2026
1127d32
feat(workerlink): 응답 제출 Command 추가
chaeliki Aug 1, 2026
28753b2
fix(workerlink): migration 번호 재조정, 값 정리
chaeliki Aug 1, 2026
e191f26
feat(workerlink): WorkerResponseUpload 연결/조회 메서드 추가
chaeliki Aug 1, 2026
a43e0d9
feat(workerlink): 응답 제출 Service 완성, 감사로그 연동, 응답유형 정정
chaeliki Aug 2, 2026
8d9e642
feat(workerlink): 응답 제출 API 추가
chaeliki Aug 2, 2026
4752254
feat(workerlink): 공개 조회 응답에 Cache-Control no-store 추가
chaeliki Aug 2, 2026
8cd4804
fix(workerlink): SecurityConfig에 공개 API permitAll 등록 누락 수정
chaeliki Aug 2, 2026
5b208f3
test(workerlink): 발급~응답제출 전체 흐름 및 tenant 격리 통합 테스트 추가
chaeliki Aug 2, 2026
97b2e08
fix(workerlink): 404/410 통일, conversationStatus
chaeliki Aug 2, 2026
73e7b51
feat(workerlink): Idempotency-Key헤더 검증 로직
chaeliki Aug 3, 2026
68517c1
feat(workerlink): PostgreSQL tenant bootstrap
chaeliki Aug 3, 2026
2634838
feat(workerlink): PostgreSQL SECURITY DEFINER 함수 및 RLS 정책 추가 (V13)
chaeliki Aug 3, 2026
f8319cc
fix(workerlink): 문서 업로드 API Idempotency-Key 헤더
chaeliki Aug 3, 2026
fdf8300
fix(workerlink): idempotency key를 해시로 저장하도록 수정
chaeliki Aug 3, 2026
1bdc360
fix(workerlink): RLS 정책 삭제, 함수만 유지
chaeliki Aug 3, 2026
67a494c
test: PostgreSqlMigrationTests에 worker_link 함수 목록 반영
chaeliki Aug 3, 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,6 @@ public enum AuditAction {
TASK_COMPLETED,
FILE_UPLOADED,
WORKER_DOCUMENT_FILE_LINKED,
DOCUMENT_REQUEST_DRAFT_SAVED
DOCUMENT_REQUEST_DRAFT_SAVED,
WORKER_LINK_RESPONSE_SUBMITTED
}
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, "/api/v1/public/worker-links/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/public/worker-links/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/**")
.hasAnyRole("ADMIN", "HR", "VIEWER")
.requestMatchers(HttpMethod.HEAD, "/api/v1/**")
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,
//: 나중에 확인 필요
@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 = "/api/v1/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,38 @@
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")
private final String workerUrl;

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

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

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

public String getWorkerUrl() {
return workerUrl;
}

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

import com.fowoco.server.workerlink.application.WorkerLinkViewResult;
import com.fowoco.server.workerlink.application.WorkerLinkViewService;
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 org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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 = "Worker Link (Public)", description = "근로자 공개 안내·제출")
@RestController
@RequestMapping("/api/v1/public/worker-links/{token}")
public class WorkerLinkViewController {

private final WorkerLinkViewService workerLinkViewService;

public WorkerLinkViewController(WorkerLinkViewService workerLinkViewService) {
this.workerLinkViewService = workerLinkViewService;
}

@Operation(
operationId = "viewWorkerLink",
summary = "근로자 공개 안내 조회",
description = "로그인 없이 번역된 최소 안내와 허용 응답을 조회합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = WorkerLinkViewResponse.class)
)
),
@ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"),
@ApiResponse(responseCode = "429", description = "요청 과다")
})
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<WorkerLinkViewResponse> view(
@Parameter(description = "근로자 링크 토큰") @PathVariable String token
) {
WorkerLinkViewResult result = workerLinkViewService.view(token);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(WorkerLinkViewResponse.from(result));
}
}
Loading
Loading