-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 근로자 보안 링크(Worker Link) 발급, 안내, 문서제출, 응답제출 구현 #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 f458713
feat(workerlink): WorkerResponse 도메인 추가
chaeliki 8ef493b
feat(workerlink): WorkerLinkRepository, WorkerResponseRepository, Wor…
chaeliki 2c058ec
feat(workerlink): worker_link, worker_response, worker_response_uploa…
chaeliki 8c6b0bb
feat(workerlink): WorkerLink/WorkerResponse JPA Entity, JpaWorkerLink…
chaeliki 3776207
feat(workerlink): tenant bootstrap 추가
chaeliki 0abfce3
feat(workerlink): 에러코드, 발급 Command 추가
chaeliki 865294e
feat(workerlink): 토큰 생성기, 해셔 추가
chaeliki 808e392
feat(workerlink): 발급 Service, DTO 추가
chaeliki 6ccaa92
feat(workerlink): 발급 Controller 추가
chaeliki 3bae17c
feat(workerlink): 공개 조회 결과 DTO 추가
chaeliki 9c12807
feat(workerlink): 공개 조회 Service 추가
chaeliki 9738582
feat(workerlink): 문서 업로드 Command 추가
chaeliki fce106e
feat(workerlink): 공개 조회 API 추가
chaeliki 4658514
feat(workerlink): 문서 업로드 Service 추가, 감사로그 연동
chaeliki 937c3d7
fix(workerlink): audit_event request_id NOT NULL 제약 반영
chaeliki f03d25a
feat(workerlink): 문서 업로드 결과에 링크 만료시각 포함
chaeliki 7edd699
feat(workerlink): 문서 업로드 API 추가
chaeliki f7e98d8
feat(workerlink): 응답 제출 Command 추가
chaeliki eeaea7c
fix(workerlink): migration 번호 재조정, 값 정리
chaeliki 82f6aaa
feat(workerlink): WorkerResponseUpload 연결/조회 메서드 추가
chaeliki 6b327b8
feat(workerlink): 응답 제출 Service 완성, 감사로그 연동, 응답유형 정정
chaeliki 5fdcd67
feat(workerlink): 응답 제출 API 추가
chaeliki 61fa7a5
feat(workerlink): 공개 조회 응답에 Cache-Control no-store 추가
chaeliki a436cbd
fix(workerlink): SecurityConfig에 공개 API permitAll 등록 누락 수정
chaeliki 4c6657c
test(workerlink): 발급~응답제출 전체 흐름 및 tenant 격리 통합 테스트 추가
chaeliki 4c53add
fix(workerlink): 404/410 통일, conversationStatus
chaeliki 09f63a6
feat(workerlink): Idempotency-Key헤더 검증 로직
chaeliki 374acee
feat(workerlink): PostgreSQL tenant bootstrap
chaeliki f146987
feat(workerlink): PostgreSQL SECURITY DEFINER 함수 및 RLS 정책 추가 (V13)
chaeliki 427805d
fix(workerlink): 문서 업로드 API Idempotency-Key 헤더
chaeliki b962ca6
fix(workerlink): idempotency key를 해시로 저장하도록 수정
chaeliki 5cb0c11
fix(workerlink): RLS 정책 삭제, 함수만 유지
chaeliki 2ff71ff
test: PostgreSqlMigrationTests에 worker_link 함수 목록 반영
chaeliki c45b4f3
feat(workerlink): already_issued 필드 반영, RLS 정책,감사로그 최종 마무리
chaeliki 294104c
Merge remote-tracking branch 'origin/main' into feat/7-worker-link
chaeliki bde7bdf
fix(workerlink): 공개 API 경로/public/worker-links/**로 변경
chaeliki 6b5e04c
feat(workerlink): 문서 업로드 clientRequestId 멱등성 구현, 재시도 통합 테스트 추가
chaeliki 11262d3
feat(file): StoredFile.verify() 추가
chaeliki 84e22a8
feat(workerlink): CORS 허용 헤더에 Idempotency-Key 추가, OPTIONS 통합 테스트
chaeliki 8075961
Merge remote-tracking branch 'origin/main' into feat/7-worker-link
chaeliki File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
88 changes: 88 additions & 0 deletions
88
src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| //: 나중에 확인 필요 | ||
|
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)); | ||
| } | ||
| } | ||
96 changes: 96 additions & 0 deletions
96
src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } |
58 changes: 58 additions & 0 deletions
58
src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
32 changes: 32 additions & 0 deletions
32
src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
48 changes: 48 additions & 0 deletions
48
src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.