From 80d7e90aa76bbc55754c6e4804d0452b0f3462ae Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 17:11:29 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat(workerlink):=20WorkerLinkStatus,=20C?= =?UTF-8?q?onversationStatus,=20WorkerResponseType,=20WorkerLink=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workerlink/domain/ConversationStatus.java | 7 + .../server/workerlink/domain/WorkerLink.java | 166 ++++++++++++++++++ .../workerlink/domain/WorkerLinkStatus.java | 7 + .../workerlink/domain/WorkerResponseType.java | 9 + 4 files changed, 189 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java create mode 100644 src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java create mode 100644 src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java create mode 100644 src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java diff --git a/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java b/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java new file mode 100644 index 0000000..60e2cca --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/ConversationStatus.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.domain; + +public enum ConversationStatus { + WAITING_WORKER, + NEEDS_FOLLOWUP, + REOPENED +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java new file mode 100644 index 0000000..c773233 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java @@ -0,0 +1,166 @@ +package com.fowoco.server.workerlink.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class WorkerLink { + + private final UUID workerLinkId; + private final UUID taskId; + private final UUID companyId; + private final String tokenHash; + private final Instant expiresAt; + private final WorkerLinkStatus status; + private final ConversationStatus conversationStatus; + private final UUID assigneeId; + private final UUID issuedBy; + private final UUID replacesLinkId; + private final Instant createdAt; + private final Instant updatedAt; + private final long version; + + public WorkerLink( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + WorkerLinkStatus status, + ConversationStatus conversationStatus, + UUID assigneeId, + UUID issuedBy, + UUID replacesLinkId, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.workerLinkId = Objects.requireNonNull(workerLinkId, "workerLinkId 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.tokenHash = requireText(tokenHash, "tokenHash"); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + this.status = Objects.requireNonNull(status, "status must not be null"); + this.conversationStatus = Objects.requireNonNull(conversationStatus, "conversationStatus must not be null"); + this.assigneeId = assigneeId; + this.issuedBy = Objects.requireNonNull(issuedBy, "issuedBy must not be null"); + this.replacesLinkId = replacesLinkId; + 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 WorkerLink issue( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + UUID issuedBy, + UUID replacesLinkId, + Instant now + ) { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + WorkerLinkStatus.ACTIVE, + ConversationStatus.WAITING_WORKER, + null, + issuedBy, + replacesLinkId, + now, + now, + 0L + ); + } + + public WorkerLink revoke(Instant now) { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + WorkerLinkStatus.REVOKED, + conversationStatus, + assigneeId, + issuedBy, + replacesLinkId, + createdAt, + now, + version + ); + } + + public boolean isUsable(Instant now) { + return status == WorkerLinkStatus.ACTIVE && expiresAt.isAfter(now); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } + + public UUID workerLinkId() { + return workerLinkId; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public String tokenHash() { + return tokenHash; + } + + public Instant expiresAt() { + return expiresAt; + } + + public WorkerLinkStatus status() { + return status; + } + + public ConversationStatus conversationStatus() { + return conversationStatus; + } + + public UUID assigneeId() { + return assigneeId; + } + + public UUID issuedBy() { + return issuedBy; + } + + public UUID replacesLinkId() { + return replacesLinkId; + } + + public Instant createdAt() { + return createdAt; + } + + public Instant updatedAt() { + return updatedAt; + } + + public long version() { + return version; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java new file mode 100644 index 0000000..3a0c548 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLinkStatus.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.domain; + +public enum WorkerLinkStatus { + ACTIVE, + EXPIRED, + REVOKED +} diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java new file mode 100644 index 0000000..b9a6a70 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerlink.domain; + +public enum WorkerResponseType { + ACKNOWLEDGED, + QUESTION, + IN_PROGRESS, + DOCUMENT_SUBMITTED, + DIFFICULT +} From a12bc99d49ce6c2e29613764941737383283f1fc Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 17:16:45 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat(workerlink):=20WorkerResponse=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workerlink/domain/WorkerResponse.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java new file mode 100644 index 0000000..6ba82c1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponse.java @@ -0,0 +1,94 @@ +package com.fowoco.server.workerlink.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public final class WorkerResponse { + + private static final int MAX_MESSAGE_LENGTH = 1000; + + private final UUID responseId; + private final UUID workerLinkId; + private final UUID companyId; + private final WorkerResponseType responseType; + private final String message; + private final String idempotencyKey; + private final Instant receivedAt; + + public WorkerResponse( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant receivedAt + ) { + this.responseId = Objects.requireNonNull(responseId, "responseId must not be null"); + this.workerLinkId = Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + this.companyId = Objects.requireNonNull(companyId, "companyId must not be null"); + this.responseType = Objects.requireNonNull(responseType, "responseType must not be null"); + this.message = normalizeMessage(message); + this.idempotencyKey = requireText(idempotencyKey, "idempotencyKey"); + this.receivedAt = Objects.requireNonNull(receivedAt, "receivedAt must not be null"); + } + + public static WorkerResponse create( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant now + ) { + return new WorkerResponse(responseId, workerLinkId, companyId, responseType, message, idempotencyKey, now); + } + + private static String normalizeMessage(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.strip(); + if (normalized.length() > MAX_MESSAGE_LENGTH) { + throw new IllegalArgumentException("message must not exceed " + MAX_MESSAGE_LENGTH + " characters"); + } + return normalized; + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value; + } + + public UUID responseId() { + return responseId; + } + + public UUID workerLinkId() { + return workerLinkId; + } + + public UUID companyId() { + return companyId; + } + + public WorkerResponseType responseType() { + return responseType; + } + + public String message() { + return message; + } + + public String idempotencyKey() { + return idempotencyKey; + } + + public Instant receivedAt() { + return receivedAt; + } +} From fdad55661852bad8f4d8868530c731e61abec303 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 17:22:08 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat(workerlink):=20WorkerLinkRepository,?= =?UTF-8?q?=20WorkerResponseRepository,=20WorkerLinkTenantBootstrap=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 --- .../application/port/WorkerLinkRepository.java | 16 ++++++++++++++++ .../port/WorkerLinkTenantBootstrap.java | 8 ++++++++ .../port/WorkerResponseRepository.java | 12 ++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java new file mode 100644 index 0000000..ae3ca81 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java @@ -0,0 +1,16 @@ +package com.fowoco.server.workerlink.application.port; + +import com.fowoco.server.workerlink.domain.WorkerLink; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerLinkRepository { + + void insert(WorkerLink workerLink); + + WorkerLink update(WorkerLink workerLink); + + Optional findByTokenHash(String tokenHash); + + Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId); +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..ff866aa --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkTenantBootstrap.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workerlink.application.port; + +import java.util.Optional; +import java.util.UUID; + +public interface WorkerLinkTenantBootstrap { + Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash); +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java new file mode 100644 index 0000000..c61d8fb --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -0,0 +1,12 @@ +package com.fowoco.server.workerlink.application.port; + +import com.fowoco.server.workerlink.domain.WorkerResponse; +import java.util.Optional; +import java.util.UUID; + +public interface WorkerResponseRepository { + + void insert(WorkerResponse workerResponse); + + Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); +} From 92a5aa6ceb170f927979709a28fefce59e524fc3 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 17:44:14 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat(workerlink):=20worker=5Flink,=20work?= =?UTF-8?q?er=5Fresponse,=20worker=5Fresponse=5Fupload=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20migration=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V10__create_worker_link.sql | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/resources/db/migration/V10__create_worker_link.sql diff --git a/src/main/resources/db/migration/V10__create_worker_link.sql b/src/main/resources/db/migration/V10__create_worker_link.sql new file mode 100644 index 0000000..f57efaf --- /dev/null +++ b/src/main/resources/db/migration/V10__create_worker_link.sql @@ -0,0 +1,72 @@ +CREATE TABLE worker_link ( + worker_link_id UUID NOT NULL, + task_id UUID NOT NULL, + company_id UUID NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + status VARCHAR(20) NOT NULL, + conversation_status VARCHAR(20) NOT NULL, + assignee_id UUID, + issued_by UUID NOT NULL, + replaces_link_id UUID, + 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_worker_link PRIMARY KEY (worker_link_id), + CONSTRAINT uq_worker_link_token_hash UNIQUE (token_hash), + CONSTRAINT fk_worker_link_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_issued_by_company + FOREIGN KEY (issued_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_assignee_company + FOREIGN KEY (assignee_id, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_link_replaces + FOREIGN KEY (replaces_link_id) REFERENCES worker_link (worker_link_id) ON DELETE SET NULL, + CONSTRAINT ck_worker_link_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'REVOKED')), + CONSTRAINT ck_worker_link_conversation_status + CHECK (conversation_status IN ('WAITING_WORKER', 'NEEDS_FOLLOWUP', 'REOPENED')), + CONSTRAINT ck_worker_link_hash_length CHECK (CHAR_LENGTH(token_hash) = 64), + CONSTRAINT ck_worker_link_hash_lowercase CHECK (token_hash = LOWER(token_hash)), + CONSTRAINT ck_worker_link_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_worker_link_version CHECK (version >= 0), + CONSTRAINT ck_worker_link_updated_at CHECK (updated_at >= created_at) +); + +CREATE INDEX idx_worker_link_company ON worker_link (company_id); +CREATE INDEX idx_worker_link_task ON worker_link (task_id, company_id); + +CREATE TABLE worker_response ( + response_id UUID NOT NULL, + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + response_type VARCHAR(30) NOT NULL, + message VARCHAR(1000), + idempotency_key VARCHAR(100) NOT NULL, + received_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_worker_response PRIMARY KEY (response_id), + CONSTRAINT uq_worker_response_idempotency UNIQUE (worker_link_id, idempotency_key), + CONSTRAINT fk_worker_response_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_response_link + FOREIGN KEY (worker_link_id) REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT, + CONSTRAINT ck_worker_response_type + CHECK (response_type IN ('ACKNOWLEDGED', 'QUESTION', 'IN_PROGRESS', 'DOCUMENT_SUBMITTED', 'DIFFICULT')) +); + +CREATE INDEX idx_worker_response_link ON worker_response (worker_link_id); +CREATE INDEX idx_worker_response_company ON worker_response (company_id); + +CREATE TABLE worker_response_upload ( + response_id UUID NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_response_upload PRIMARY KEY (response_id, stored_file_id), + CONSTRAINT fk_worker_response_upload_response + FOREIGN KEY (response_id) REFERENCES worker_response (response_id) ON DELETE CASCADE, + CONSTRAINT fk_worker_response_upload_file + FOREIGN KEY (stored_file_id) REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT +); From 23374500b3d62d63d71fe66fbcd4fdd28fdf6786 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 23:08:46 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat(workerlink):=20WorkerLink/WorkerResp?= =?UTF-8?q?onse=20JPA=20Entity,=20JpaWorkerLinkRepository=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 --- .../persistence/JpaWorkerLinkRepository.java | 78 +++++++++ .../persistence/WorkerLinkJpaEntity.java | 151 ++++++++++++++++++ .../persistence/WorkerResponseJpaEntity.java | 87 ++++++++++ 3 files changed, 316 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java new file mode 100644 index 0000000..3d3dcf2 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java @@ -0,0 +1,78 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaWorkerLinkRepository implements WorkerLinkRepository { + + private final EntityManager entityManager; + + public JpaWorkerLinkRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + entityManager.persist(WorkerLinkJpaEntity.fromDomain(workerLink)); + entityManager.flush(); + } + + @Override + public WorkerLink update(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + WorkerLinkJpaEntity entity = entityManager.find(WorkerLinkJpaEntity.class, workerLink.workerLinkId()); + if (entity == null) { + throw new IllegalStateException("worker link to update was not found"); + } + entity.applyState(workerLink); + entityManager.flush(); + return entity.toDomain(); + } + + @Override + public Optional findByTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.tokenHash = :tokenHash + """, + WorkerLinkJpaEntity.class + ) + .setParameter("tokenHash", tokenHash) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } + + @Override + public Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.taskId = :taskId + and link.companyId = :companyId + and link.status = :status + """, + WorkerLinkJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("companyId", companyId) + .setParameter("status", WorkerLinkStatus.ACTIVE) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java new file mode 100644 index 0000000..1250a64 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java @@ -0,0 +1,151 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.domain.ConversationStatus; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerLinkStatus; +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 jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "worker_link") +public class WorkerLinkJpaEntity { + + @Id + @Column(name = "worker_link_id", nullable = false, updatable = false) + private UUID workerLinkId; + + @Column(name = "task_id", nullable = false, updatable = false) + private UUID taskId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Column(name = "token_hash", nullable = false, updatable = false, length = 64) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private WorkerLinkStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "conversation_status", nullable = false, length = 20) + private ConversationStatus conversationStatus; + + @Column(name = "assignee_id") + private UUID assigneeId; + + @Column(name = "issued_by", nullable = false, updatable = false) + private UUID issuedBy; + + @Column(name = "replaces_link_id", updatable = false) + private UUID replacesLinkId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private long version; + + protected WorkerLinkJpaEntity() { + } + + private WorkerLinkJpaEntity( + UUID workerLinkId, + UUID taskId, + UUID companyId, + String tokenHash, + Instant expiresAt, + WorkerLinkStatus status, + ConversationStatus conversationStatus, + UUID assigneeId, + UUID issuedBy, + UUID replacesLinkId, + Instant createdAt, + Instant updatedAt, + long version + ) { + this.workerLinkId = workerLinkId; + this.taskId = taskId; + this.companyId = companyId; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + this.status = status; + this.conversationStatus = conversationStatus; + this.assigneeId = assigneeId; + this.issuedBy = issuedBy; + this.replacesLinkId = replacesLinkId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static WorkerLinkJpaEntity fromDomain(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + return new WorkerLinkJpaEntity( + workerLink.workerLinkId(), + workerLink.taskId(), + workerLink.companyId(), + workerLink.tokenHash(), + workerLink.expiresAt(), + workerLink.status(), + workerLink.conversationStatus(), + workerLink.assigneeId(), + workerLink.issuedBy(), + workerLink.replacesLinkId(), + workerLink.createdAt(), + workerLink.updatedAt(), + workerLink.version() + ); + } + + public WorkerLink toDomain() { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + status, + conversationStatus, + assigneeId, + issuedBy, + replacesLinkId, + createdAt, + updatedAt, + version + ); + } + + public void applyState(WorkerLink workerLink) { + Objects.requireNonNull(workerLink, "workerLink must not be null"); + if (!workerLinkId.equals(workerLink.workerLinkId()) + || !taskId.equals(workerLink.taskId()) + || !companyId.equals(workerLink.companyId()) + || !tokenHash.equals(workerLink.tokenHash()) + || !createdAt.equals(workerLink.createdAt())) { + throw new IllegalArgumentException("immutable worker link fields must not change"); + } + if (version != workerLink.version()) { + throw new IllegalArgumentException("worker link version does not match"); + } + this.status = workerLink.status(); + this.conversationStatus = workerLink.conversationStatus(); + this.assigneeId = workerLink.assigneeId(); + this.updatedAt = workerLink.updatedAt(); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java new file mode 100644 index 0000000..d369baa --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerResponseJpaEntity.java @@ -0,0 +1,87 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.domain.WorkerResponse; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +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 = "worker_response") +public class WorkerResponseJpaEntity { + + @Id + @Column(name = "response_id", nullable = false, updatable = false) + private UUID responseId; + + @Column(name = "worker_link_id", nullable = false, updatable = false) + private UUID workerLinkId; + + @Column(name = "company_id", nullable = false, updatable = false) + private UUID companyId; + + @Enumerated(EnumType.STRING) + @Column(name = "response_type", nullable = false, updatable = false, length = 30) + private WorkerResponseType responseType; + + @Column(name = "message", updatable = false, length = 1000) + private String message; + + @Column(name = "idempotency_key", nullable = false, updatable = false, length = 100) + private String idempotencyKey; + + @Column(name = "received_at", nullable = false, updatable = false) + private Instant receivedAt; + + protected WorkerResponseJpaEntity() { + } + + private WorkerResponseJpaEntity( + UUID responseId, + UUID workerLinkId, + UUID companyId, + WorkerResponseType responseType, + String message, + String idempotencyKey, + Instant receivedAt + ) { + this.responseId = responseId; + this.workerLinkId = workerLinkId; + this.companyId = companyId; + this.responseType = responseType; + this.message = message; + this.idempotencyKey = idempotencyKey; + this.receivedAt = receivedAt; + } + + public static WorkerResponseJpaEntity fromDomain(WorkerResponse response) { + Objects.requireNonNull(response, "response must not be null"); + return new WorkerResponseJpaEntity( + response.responseId(), + response.workerLinkId(), + response.companyId(), + response.responseType(), + response.message(), + response.idempotencyKey(), + response.receivedAt() + ); + } + + public WorkerResponse toDomain() { + return new WorkerResponse( + responseId, + workerLinkId, + companyId, + responseType, + message, + idempotencyKey, + receivedAt + ); + } +} From 666eb6eb615d34d4fd0e496a064247bce35cc006 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 31 Jul 2026 23:14:35 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat(workerlink):=20tenant=20bootstrap=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 --- .../JpaWorkerLinkTenantBootstrap.java | 41 +++++++++++++++++ .../JpaWorkerResponseRepository.java | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..4e8bd2f --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkTenantBootstrap.java @@ -0,0 +1,41 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class JpaWorkerLinkTenantBootstrap implements WorkerLinkTenantBootstrap { + + private final EntityManager entityManager; + + public JpaWorkerLinkTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return entityManager.createQuery( + """ + select link.companyId + from WorkerLinkJpaEntity link + where link.tokenHash = :tokenHash + """, + UUID.class + ) + .setParameter("tokenHash", tokenHash) + .setMaxResults(1) + .getResultStream() + .findFirst(); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java new file mode 100644 index 0000000..3e000d1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -0,0 +1,46 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.domain.WorkerResponse; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaWorkerResponseRepository implements WorkerResponseRepository { + + private final EntityManager entityManager; + + public JpaWorkerResponseRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public void insert(WorkerResponse workerResponse) { + Objects.requireNonNull(workerResponse, "workerResponse must not be null"); + entityManager.persist(WorkerResponseJpaEntity.fromDomain(workerResponse)); + entityManager.flush(); + } + + @Override + public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey) { + Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + return entityManager.createQuery( + """ + select response + from WorkerResponseJpaEntity response + where response.workerLinkId = :workerLinkId + and response.idempotencyKey = :idempotencyKey + """, + WorkerResponseJpaEntity.class + ) + .setParameter("workerLinkId", workerLinkId) + .setParameter("idempotencyKey", idempotencyKey) + .getResultStream() + .findFirst() + .map(WorkerResponseJpaEntity::toDomain); + } +} From 5663f9dc593e0a42e5c58e62b67f0a62eca60571 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 1 Aug 2026 04:51:22 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat(workerlink):=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C,=20=EB=B0=9C=EA=B8=89=20Command=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/WorkerLinkIssueCommand.java | 46 +++++++++++++++++++ .../error/WorkerLinkErrorCode.java | 41 +++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java new file mode 100644 index 0000000..eb3f617 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java @@ -0,0 +1,46 @@ +package com.fowoco.server.workerlink.application; + +import java.util.UUID; + +public final class WorkerLinkIssueCommand { + + private final UUID taskId; + private final UUID companyId; + private final Long expiresInHours; + private final boolean rotateExisting; + private final UUID issuedBy; + + public WorkerLinkIssueCommand( + UUID taskId, + UUID companyId, + Long expiresInHours, + boolean rotateExisting, + UUID issuedBy + ) { + this.taskId = taskId; + this.companyId = companyId; + this.expiresInHours = expiresInHours; + this.rotateExisting = rotateExisting; + this.issuedBy = issuedBy; + } + + public UUID taskId() { + return taskId; + } + + public UUID companyId() { + return companyId; + } + + public Long expiresInHours() { + return expiresInHours; + } + + public boolean rotateExisting() { + return rotateExisting; + } + + public UUID issuedBy() { + return issuedBy; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java new file mode 100644 index 0000000..f80db39 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -0,0 +1,41 @@ +package com.fowoco.server.workerlink.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum WorkerLinkErrorCode implements ApiErrorCode { + TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "업무카드를 찾을 수 없습니다."), + TASK_NOT_APPROVED( + HttpStatus.UNPROCESSABLE_CONTENT, + "승인된 업무카드에서만 근로자 링크를 발급할 수 있습니다." + ), + WORKER_LINK_ISSUANCE_CONFLICT( + HttpStatus.CONFLICT, + "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." + ), + WORKER_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "링크를 찾을 수 없습니다."), + WORKER_LINK_EXPIRED(HttpStatus.GONE, "만료되었거나 폐기된 링크입니다."); + + private final HttpStatus status; + private final String defaultMessage; + + WorkerLinkErrorCode(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 66d15fa351c9b6cff073acda178158851a67247a Mon Sep 17 00:00:00 2001 From: chaelin Date: Sat, 1 Aug 2026 23:56:05 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat(workerlink):=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B8=B0,=20=ED=95=B4=EC=85=94=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/WorkerLinkGenerator.java | 9 ++++++ .../security/SecureWorkerLinkGenerator.java | 30 +++++++++++++++++++ .../security/WorkerLinkHasher.java | 24 +++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java new file mode 100644 index 0000000..ff3bc94 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkGenerator.java @@ -0,0 +1,9 @@ +package com.fowoco.server.workerlink.application.port; + +public interface WorkerLinkGenerator { + + GeneratedWorkerLinkToken generate(); + + record GeneratedWorkerLinkToken(String rawValue, String tokenHash) { + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java new file mode 100644 index 0000000..bc00799 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/SecureWorkerLinkGenerator.java @@ -0,0 +1,30 @@ +package com.fowoco.server.workerlink.infrastructure.security; + +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import org.springframework.stereotype.Component; + +@Component +public final class SecureWorkerLinkGenerator implements WorkerLinkGenerator { + + private static final int ENTROPY_BYTES = 32; + + private final SecureRandom secureRandom; + private final WorkerLinkHasher workerLinkHasher; + + public SecureWorkerLinkGenerator(WorkerLinkHasher workerLinkHasher) { + this.secureRandom = new SecureRandom(); + this.workerLinkHasher = Objects.requireNonNull(workerLinkHasher, "workerLinkHasher must not be null"); + } + + @Override + public GeneratedWorkerLinkToken generate() { + byte[] tokenBytes = new byte[ENTROPY_BYTES]; + secureRandom.nextBytes(tokenBytes); + String rawValue = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + String tokenHash = workerLinkHasher.hash(rawValue); + return new GeneratedWorkerLinkToken(rawValue, tokenHash); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java new file mode 100644 index 0000000..5a08fa6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/security/WorkerLinkHasher.java @@ -0,0 +1,24 @@ +package com.fowoco.server.workerlink.infrastructure.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +@Component +public final class WorkerLinkHasher { + + public String hash(String rawToken) { + if (rawToken == null || rawToken.isBlank()) { + throw new IllegalArgumentException("rawToken must not be blank"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} From 6f3f91ebfb6e0a7332598cb7af99ccd7880b508d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 00:28:38 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat(workerlink):=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?Service,=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerLinkIssueRequest.java | 32 +++++++ .../api/WorkerLinkIssueResponse.java | 38 ++++++++ .../application/WorkerLinkIssueResult.java | 6 ++ .../application/WorkerLinkService.java | 94 +++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java new file mode 100644 index 0000000..49e4dd4 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueRequest.java @@ -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; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java new file mode 100644 index 0000000..9397cd6 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkIssueResponse.java @@ -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; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java new file mode 100644 index 0000000..cacd34c --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueResult.java @@ -0,0 +1,6 @@ +package com.fowoco.server.workerlink.application; + +import java.time.Instant; + +public record WorkerLinkIssueResult(String rawToken, Instant expiresAt) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java new file mode 100644 index 0000000..026e5d2 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -0,0 +1,94 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkService { + + private static final long DEFAULT_EXPIRES_IN_HOURS = 72L; + + private final TaskRepository taskRepository; + private final ApprovalRequestRepository approvalRequestRepository; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkGenerator workerLinkGenerator; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerLinkService( + TaskRepository taskRepository, + ApprovalRequestRepository approvalRequestRepository, + WorkerLinkRepository workerLinkRepository, + WorkerLinkGenerator workerLinkGenerator, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.taskRepository = taskRepository; + this.approvalRequestRepository = approvalRequestRepository; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkGenerator = workerLinkGenerator; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND)); + + ApprovalRequest approval = approvalRequestRepository + .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), command.companyId()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED)); + + if (!approval.isValidFor(task.contentRevision(), task.criticalFingerprint())) { + throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); + } + + Instant now = clock.instant(); + Optional existing = workerLinkRepository + .findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId()); + + WorkerLink previousLink = null; + if (existing.isPresent()) { + if (!command.rotateExisting()) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_ISSUANCE_CONFLICT); + } + previousLink = existing.get(); + workerLinkRepository.update(previousLink.revoke(now)); + } + + long hours = command.expiresInHours() != null ? command.expiresInHours() : DEFAULT_EXPIRES_IN_HOURS; + Instant expiresAt = now.plus(Duration.ofHours(hours)); + + WorkerLinkGenerator.GeneratedWorkerLinkToken generated = workerLinkGenerator.generate(); + + WorkerLink workerLink = WorkerLink.issue( + uuidGenerator.generate(), + command.taskId(), + command.companyId(), + generated.tokenHash(), + expiresAt, + command.issuedBy(), + previousLink != null ? previousLink.workerLinkId() : null, + now + ); + workerLinkRepository.insert(workerLink); + + return new WorkerLinkIssueResult(generated.rawValue(), expiresAt); + } +} From 1e8057a5c6f2ab650af630ba0c65ef3493b76f3a Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 00:30:07 +0900 Subject: [PATCH 10/34] =?UTF-8?q?feat(workerlink):=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?Controller=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workerlink/api/WorkerLinkController.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java new file mode 100644 index 0000000..3c432ff --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -0,0 +1,87 @@ +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 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() + ); + WorkerLinkIssueResult result = workerLinkService.issue(command); + return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result)); + } +} From e0667b43c17d2afe00d21f7ecaed01742f1bafc1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 01:49:49 +0900 Subject: [PATCH 11/34] =?UTF-8?q?feat(workerlink):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B2=B0=EA=B3=BC=20DTO=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 --- .../workerlink/application/WorkerLinkViewResult.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java new file mode 100644 index 0000000..4d4fff0 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java @@ -0,0 +1,12 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import java.time.LocalDate; +import java.util.List; + +public record WorkerLinkViewResult( + String guidance, + LocalDate dueDate, + List allowedResponses +) { +} From c178a7636c98490cd65d14392a7d338df40d1e5b Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 01:49:57 +0900 Subject: [PATCH 12/34] =?UTF-8?q?feat(workerlink):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20Service=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/WorkerLinkViewService.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java new file mode 100644 index 0000000..85eba20 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java @@ -0,0 +1,67 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkViewService { + + private final WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkHasher workerLinkHasher; + private final Clock clock; + + public WorkerLinkViewService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerLinkHasher workerLinkHasher, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkHasher = workerLinkHasher; + this.clock = clock; + } + + @Transactional + public WorkerLinkViewResult view(String rawToken) { + String tokenHash = workerLinkHasher.hash(rawToken); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + } + + // document-request-draft 연동해서 guidance/dueDate 실제 값 채우기. + // AI Agent(Language Agent) 다음에 다시 확인 + return new WorkerLinkViewResult( + "document-request-draft 연동 전", + null, + List.of(WorkerResponseType.values()) + ); + } +} From cf16234e61571290148bc1c022f6ac04ea73a929 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 02:57:09 +0900 Subject: [PATCH 13/34] =?UTF-8?q?feat(workerlink):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20Command=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerLinkDocumentUploadCommand.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java new file mode 100644 index 0000000..e87e232 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadCommand.java @@ -0,0 +1,60 @@ +package com.fowoco.server.workerlink.application; + +import java.io.InputStream; + +public final class WorkerLinkDocumentUploadCommand { + + private final String rawToken; + private final String fileName; + private final String mimeType; + private final long size; + private final String documentType; + private final String clientRequestId; + private final InputStream content; + + public WorkerLinkDocumentUploadCommand( + String rawToken, + String fileName, + String mimeType, + long size, + String documentType, + String clientRequestId, + InputStream content + ) { + this.rawToken = rawToken; + this.fileName = fileName; + this.mimeType = mimeType; + this.size = size; + this.documentType = documentType; + this.clientRequestId = clientRequestId; + this.content = content; + } + + public String rawToken() { + return rawToken; + } + + public String fileName() { + return fileName; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public String documentType() { + return documentType; + } + + public String clientRequestId() { + return clientRequestId; + } + + public InputStream content() { + return content; + } +} From e1b4a414353746d1b324cf26f3c6a9b4619a43ec Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:12:40 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat(workerlink):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A1=B0=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 --- .../api/WorkerLinkViewController.java | 54 +++++++++++++++++++ .../api/WorkerLinkViewResponse.java | 46 ++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java new file mode 100644 index 0000000..bd3abe1 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java @@ -0,0 +1,54 @@ +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.MediaType; +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 = "404", description = "존재하지 않는 링크"), + @ApiResponse(responseCode = "410", description = "만료·폐기된 링크"), + @ApiResponse(responseCode = "429", description = "요청 과다") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public WorkerLinkViewResponse view( + @Parameter(description = "근로자 링크 토큰") @PathVariable String token + ) { + WorkerLinkViewResult result = workerLinkViewService.view(token); + return WorkerLinkViewResponse.from(result); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java new file mode 100644 index 0000000..3d24e98 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java @@ -0,0 +1,46 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerLinkViewResult; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +@Schema(name = "WorkerLinkViewResponse", description = "근로자 공개 안내 조회 결과") +public final class WorkerLinkViewResponse { + + @JsonProperty("guidance") + @Schema(description = "번역된 안내 내용") + private final String guidance; + + @JsonProperty("due_date") + @Schema(name = "due_date", description = "제출 마감일") + private final LocalDate dueDate; + + @JsonProperty("allowed_responses") + @Schema(name = "allowed_responses", description = "이 링크에서 허용되는 응답 유형") + private final List allowedResponses; + + private WorkerLinkViewResponse(String guidance, LocalDate dueDate, List allowedResponses) { + this.guidance = guidance; + this.dueDate = dueDate; + this.allowedResponses = allowedResponses; + } + + public static WorkerLinkViewResponse from(WorkerLinkViewResult result) { + return new WorkerLinkViewResponse(result.guidance(), result.dueDate(), result.allowedResponses()); + } + + public String getGuidance() { + return guidance; + } + + public LocalDate getDueDate() { + return dueDate; + } + + public List getAllowedResponses() { + return allowedResponses; + } +} From fdfdc8fd18c6447e4b0c7bb5ee7dd69d258e284c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:19:39 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat(workerlink):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20Service=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=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 --- .../WorkerLinkDocumentService.java | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java new file mode 100644 index 0000000..2534102 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -0,0 +1,138 @@ +package com.fowoco.server.workerlink.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.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +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 com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerLinkDocumentService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + //FileService(#13)와 동일한 값 + 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 WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerLinkHasher workerLinkHasher; + private final StoredFileRepository storedFileRepository; + private final FileStorage fileStorage; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerLinkDocumentService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerLinkHasher workerLinkHasher, + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerLinkHasher = workerLinkHasher; + this.storedFileRepository = storedFileRepository; + this.fileStorage = fileStorage; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public StoredFile upload(WorkerLinkDocumentUploadCommand 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); + } + + String tokenHash = workerLinkHasher.hash(command.rawToken()); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + } + + UUID storedFileId = uuidGenerator.generate(); + String storageKey = storedFileId.toString(); + String purpose = command.documentType() != null ? command.documentType() : "WORKER_LINK_SUBMISSION"; + + StoredFile storedFile = StoredFile.create( + storedFileId, + companyId, + command.fileName(), + command.mimeType(), + command.size(), + purpose, + link.taskId(), + null, + storageKey, + now + ); + + fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); + storedFileRepository.insert(storedFile); + + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + companyId, + ActorType.WORKER_LINK, + null, + null, + AuditAction.FILE_UPLOADED, + AuditTargetType.FILE, + storedFileId, + null, + null, + AUDIT_EVENT_VERSION, + "근로자 링크로 파일 업로드: " + purpose, + now + )); + + // TODO: clientRequestId를 이용한 중복 제출 방지 로직 미구현 (스키마 변경 필요, 후속 처리) + + return storedFile; + } +} From dde0ad740afab5db94cb9572d58e5010080892f8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:26:50 +0900 Subject: [PATCH 16/34] =?UTF-8?q?fix(workerlink):=20audit=5Fevent=20reques?= =?UTF-8?q?t=5Fid=20NOT=20NULL=20=EC=A0=9C=EC=95=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workerlink/application/WorkerLinkDocumentService.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java index 2534102..6f74dc1 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -8,6 +8,7 @@ import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; +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; @@ -71,7 +72,7 @@ public WorkerLinkDocumentService( } @Transactional - public StoredFile upload(WorkerLinkDocumentUploadCommand command) { + public StoredFile upload(WorkerLinkDocumentUploadCommand command, RequestMetadata metadata) { if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } @@ -124,8 +125,8 @@ public StoredFile upload(WorkerLinkDocumentUploadCommand command) { AuditAction.FILE_UPLOADED, AuditTargetType.FILE, storedFileId, - null, - null, + metadata.requestId(), + metadata.traceId(), AUDIT_EVENT_VERSION, "근로자 링크로 파일 업로드: " + purpose, now From 79d3c0142d434ce4d39259366ac90210cd743afe Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:49:58 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat(workerlink):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B2=B0=EA=B3=BC=EC=97=90=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerLinkDocumentUploadResponse.java | 58 +++++++++++++++++++ .../WorkerLinkDocumentService.java | 4 +- .../WorkerLinkDocumentUploadResult.java | 7 +++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java new file mode 100644 index 0000000..9a1caf7 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentUploadResponse.java @@ -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; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java index 6f74dc1..74be9d4 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -72,7 +72,7 @@ public WorkerLinkDocumentService( } @Transactional - public StoredFile upload(WorkerLinkDocumentUploadCommand command, RequestMetadata metadata) { + public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand command, RequestMetadata metadata) { if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } @@ -134,6 +134,6 @@ public StoredFile upload(WorkerLinkDocumentUploadCommand command, RequestMetadat // TODO: clientRequestId를 이용한 중복 제출 방지 로직 미구현 (스키마 변경 필요, 후속 처리) - return storedFile; + return new WorkerLinkDocumentUploadResult(storedFile, link.expiresAt()); } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java new file mode 100644 index 0000000..635294b --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentUploadResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.file.domain.StoredFile; +import java.time.Instant; + +public record WorkerLinkDocumentUploadResult(StoredFile storedFile, Instant linkExpiresAt) { +} From 4d26359f89be2d38c4c6c294546601517f1f4b6c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:54:39 +0900 Subject: [PATCH 18/34] =?UTF-8?q?feat(workerlink):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerLinkDocumentController.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java new file mode 100644 index 0000000..0013d4f --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java @@ -0,0 +1,95 @@ +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.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 = "404", description = "존재하지 않는 링크"), + @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 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, + 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); + } + } +} From 1127d32cf5c3decfbe7d3846c4e96600f7fcc14c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 03:58:32 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat(workerlink):=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20Command=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerResponseSubmitCommand.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java new file mode 100644 index 0000000..9464699 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitCommand.java @@ -0,0 +1,48 @@ +package com.fowoco.server.workerlink.application; + +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import java.util.List; +import java.util.UUID; + +public final class WorkerResponseSubmitCommand { + + private final String rawToken; + private final WorkerResponseType responseType; + private final String message; + private final List uploadIds; + private final String idempotencyKey; + + public WorkerResponseSubmitCommand( + String rawToken, + WorkerResponseType responseType, + String message, + List uploadIds, + String idempotencyKey + ) { + this.rawToken = rawToken; + this.responseType = responseType; + this.message = message; + this.uploadIds = uploadIds; + this.idempotencyKey = idempotencyKey; + } + + public String rawToken() { + return rawToken; + } + + public WorkerResponseType responseType() { + return responseType; + } + + public String message() { + return message; + } + + public List uploadIds() { + return uploadIds; + } + + public String idempotencyKey() { + return idempotencyKey; + } +} From 28753b2f3b92a0f0eab4a4af2f1027acba93f73c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 06:37:56 +0900 Subject: [PATCH 20/34] =?UTF-8?q?fix(workerlink):=20migration=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=A1=B0=EC=A0=95,=20=EA=B0=92=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...{V10__create_worker_link.sql => V11__create_worker_link.sql} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/resources/db/migration/{V10__create_worker_link.sql => V11__create_worker_link.sql} (96%) diff --git a/src/main/resources/db/migration/V10__create_worker_link.sql b/src/main/resources/db/migration/V11__create_worker_link.sql similarity index 96% rename from src/main/resources/db/migration/V10__create_worker_link.sql rename to src/main/resources/db/migration/V11__create_worker_link.sql index f57efaf..78869d6 100644 --- a/src/main/resources/db/migration/V10__create_worker_link.sql +++ b/src/main/resources/db/migration/V11__create_worker_link.sql @@ -55,7 +55,7 @@ CREATE TABLE worker_response ( CONSTRAINT fk_worker_response_link FOREIGN KEY (worker_link_id) REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT, CONSTRAINT ck_worker_response_type - CHECK (response_type IN ('ACKNOWLEDGED', 'QUESTION', 'IN_PROGRESS', 'DOCUMENT_SUBMITTED', 'DIFFICULT')) + CHECK (response_type IN ('ACKNOWLEDGED', 'QUESTION', 'NOT_UNDERSTOOD', 'DOCUMENT_SUBMITTED', 'DIFFICULT')) ); CREATE INDEX idx_worker_response_link ON worker_response (worker_link_id); From e191f26cba531d004a167aa528fbf41482dec64b Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 06:48:29 +0900 Subject: [PATCH 21/34] =?UTF-8?q?feat(workerlink):=20WorkerResponseUpload?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0/=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=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 --- .../port/WorkerResponseRepository.java | 4 ++++ .../JpaWorkerResponseRepository.java | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java index c61d8fb..07ca385 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -9,4 +9,8 @@ public interface WorkerResponseRepository { void insert(WorkerResponse workerResponse); Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); + + void linkUpload(UUID responseId, UUID storedFileId); + + boolean isUploadAlreadyLinked(UUID storedFileId); } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index 3e000d1..d30252c 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -3,6 +3,7 @@ import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; import com.fowoco.server.workerlink.domain.WorkerResponse; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -43,4 +44,27 @@ public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerL .findFirst() .map(WorkerResponseJpaEntity::toDomain); } + + @Override + public void linkUpload(UUID responseId, UUID storedFileId) { + Objects.requireNonNull(responseId, "responseId must not be null"); + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Query query = entityManager.createNativeQuery( + "INSERT INTO worker_response_upload (response_id, stored_file_id) VALUES (?1, ?2)" + ); + query.setParameter(1, responseId); + query.setParameter(2, storedFileId); + query.executeUpdate(); + } + + @Override + public boolean isUploadAlreadyLinked(UUID storedFileId) { + Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Long count = (Long) entityManager.createNativeQuery( + "SELECT COUNT(*) FROM worker_response_upload WHERE stored_file_id = ?1" + ) + .setParameter(1, storedFileId) + .getSingleResult(); + return count != null && count > 0; + } } From a43e0d9db3e98bce728e29c9bec91f495ca23e0c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 17:11:02 +0900 Subject: [PATCH 22/34] =?UTF-8?q?feat(workerlink):=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20Service=20=EC=99=84=EC=84=B1,=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=EB=A1=9C=EA=B7=B8=20=EC=97=B0=EB=8F=99,=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9C=A0=ED=98=95=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/audit/domain/AuditAction.java | 3 +- .../application/WorkerResponseService.java | 137 ++++++++++++++++++ .../WorkerResponseSubmitResult.java | 7 + .../error/WorkerLinkErrorCode.java | 6 +- .../workerlink/domain/WorkerResponseType.java | 2 +- 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java create mode 100644 src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java 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 b9001d0..f15081a 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -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 } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java new file mode 100644 index 0000000..6f6327e --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -0,0 +1,137 @@ +package com.fowoco.server.workerlink.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.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.StoredFile; +import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.domain.WorkerResponse; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WorkerResponseService { + + private static final String AUDIT_EVENT_VERSION = "1"; + + private final WorkerLinkTenantBootstrap workerLinkTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; + private final WorkerLinkRepository workerLinkRepository; + private final WorkerResponseRepository workerResponseRepository; + private final WorkerLinkHasher workerLinkHasher; + private final StoredFileRepository storedFileRepository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public WorkerResponseService( + WorkerLinkTenantBootstrap workerLinkTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, + WorkerLinkRepository workerLinkRepository, + WorkerResponseRepository workerResponseRepository, + WorkerLinkHasher workerLinkHasher, + StoredFileRepository storedFileRepository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; + this.workerLinkRepository = workerLinkRepository; + this.workerResponseRepository = workerResponseRepository; + this.workerLinkHasher = workerLinkHasher; + this.storedFileRepository = storedFileRepository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, RequestMetadata metadata) { + String tokenHash = workerLinkHasher.hash(command.rawToken()); + + UUID companyId = workerLinkTenantBootstrap + .findCompanyIdByWorkerLinkTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + WorkerLink link = workerLinkRepository.findByTokenHash(tokenHash) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + + Instant now = clock.instant(); + if (!link.isUsable(now)) { + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + } + + Optional existing = workerResponseRepository + .findByWorkerLinkIdAndIdempotencyKey(link.workerLinkId(), command.idempotencyKey()); + if (existing.isPresent()) { + WorkerResponse previous = existing.get(); + return new WorkerResponseSubmitResult(previous.responseId(), previous.receivedAt()); + } + + List uploadIds = command.uploadIds() != null ? command.uploadIds() : List.of(); + for (UUID uploadId : uploadIds) { + StoredFile storedFile = storedFileRepository.findByIdAndCompanyId(uploadId, companyId) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE)); + if (!storedFile.verified() || !link.taskId().equals(storedFile.taskId())) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + if (workerResponseRepository.isUploadAlreadyLinked(uploadId)) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } + } + + UUID responseId = uuidGenerator.generate(); + WorkerResponse response = WorkerResponse.create( + responseId, + link.workerLinkId(), + companyId, + command.responseType(), + command.message(), + command.idempotencyKey(), + now + ); + workerResponseRepository.insert(response); + + for (UUID uploadId : uploadIds) { + workerResponseRepository.linkUpload(responseId, uploadId); + } + + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + companyId, + ActorType.WORKER_LINK, + null, + null, + AuditAction.WORKER_LINK_RESPONSE_SUBMITTED, + AuditTargetType.TASK, + link.taskId(), + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + "근로자 응답 제출: " + command.responseType(), + now + )); + + return new WorkerResponseSubmitResult(responseId, now); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java new file mode 100644 index 0000000..296c694 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseSubmitResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.workerlink.application; + +import java.time.Instant; +import java.util.UUID; + +public record WorkerResponseSubmitResult(UUID responseId, Instant receivedAt) { +} diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java index f80db39..c651d39 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -14,7 +14,11 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." ), WORKER_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "링크를 찾을 수 없습니다."), - WORKER_LINK_EXPIRED(HttpStatus.GONE, "만료되었거나 폐기된 링크입니다."); + WORKER_LINK_EXPIRED(HttpStatus.GONE, "만료되었거나 폐기된 링크입니다."), + UPLOAD_NOT_AVAILABLE( + HttpStatus.UNPROCESSABLE_CONTENT, + "업로드된 파일을 찾을 수 없거나 이미 사용된 파일입니다." + ); private final HttpStatus status; private final String defaultMessage; diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java index b9a6a70..50a8ebf 100644 --- a/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerResponseType.java @@ -3,7 +3,7 @@ public enum WorkerResponseType { ACKNOWLEDGED, QUESTION, - IN_PROGRESS, + NOT_UNDERSTOOD, DOCUMENT_SUBMITTED, DIFFICULT } From 8d9e642047ce2f6bb7f6cff0026d916e756f8045 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 17:20:46 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat(workerlink):=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerResponseController.java | 79 +++++++++++++++++++ .../api/WorkerResponseSubmitRequest.java | 59 ++++++++++++++ .../api/WorkerResponseSubmitResponse.java | 36 +++++++++ 3 files changed, 174 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java create mode 100644 src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java new file mode 100644 index 0000000..f36483d --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java @@ -0,0 +1,79 @@ +package com.fowoco.server.workerlink.api; + +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.workerlink.application.WorkerResponseService; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitCommand; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitResult; +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 jakarta.validation.Valid; +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.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Worker Link (Public)", description = "근로자 공개 안내·제출") +@RestController +public class WorkerResponseController { + + private final WorkerResponseService workerResponseService; + + public WorkerResponseController(WorkerResponseService workerResponseService) { + this.workerResponseService = workerResponseService; + } + + @Operation( + operationId = "submitWorkerResponse", + summary = "근로자 응답 제출", + description = "같은 idempotency_key로 재시도해도 중복 응답이 생기지 않습니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "제출 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerResponseSubmitResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 링크"), + @ApiResponse(responseCode = "410", description = "만료·폐기된 링크"), + @ApiResponse(responseCode = "413", description = "요청 크기 초과"), + @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), + @ApiResponse(responseCode = "422", description = "허용되지 않은 upload_id"), + @ApiResponse(responseCode = "429", description = "요청 과다") + }) + @PostMapping( + path = "/api/v1/public/worker-links/{token}/responses", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity submit( + @Parameter(description = "근로자 링크 토큰") @PathVariable String token, + @Valid @RequestBody WorkerResponseSubmitRequest request, + HttpServletRequest servletRequest + ) { + WorkerResponseSubmitCommand command = new WorkerResponseSubmitCommand( + token, + request.getResponseType(), + request.getMessage(), + request.getUploadIds(), + request.getIdempotencyKey() + ); + WorkerResponseSubmitResult result = workerResponseService.submit( + command, + RequestMetadata.from(servletRequest) + ); + return ResponseEntity.status(HttpStatus.CREATED).body(WorkerResponseSubmitResponse.from(result)); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java new file mode 100644 index 0000000..0aa4316 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitRequest.java @@ -0,0 +1,59 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.domain.WorkerResponseType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; + +@Schema(name = "WorkerResponseSubmitRequest", description = "근로자 응답 제출 요청") +public final class WorkerResponseSubmitRequest { + + @Schema(name = "response_type", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "response_type을 입력해 주세요.") + private final WorkerResponseType responseType; + + @Schema(description = "근로자 메시지", maxLength = 1000) + @Size(max = 1000, message = "message는 1000자 이하여야 합니다.") + private final String message; + + @Schema(name = "upload_ids", description = "함께 제출할 업로드 ID 목록") + private final List uploadIds; + + @Schema(name = "idempotency_key", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "idempotency_key를 입력해 주세요.") + private final String idempotencyKey; + + @JsonCreator + public WorkerResponseSubmitRequest( + @JsonProperty("response_type") WorkerResponseType responseType, + @JsonProperty("message") String message, + @JsonProperty("upload_ids") List uploadIds, + @JsonProperty("idempotency_key") String idempotencyKey + ) { + this.responseType = responseType; + this.message = message; + this.uploadIds = uploadIds; + this.idempotencyKey = idempotencyKey; + } + + public WorkerResponseType getResponseType() { + return responseType; + } + + public String getMessage() { + return message; + } + + public List getUploadIds() { + return uploadIds; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java new file mode 100644 index 0000000..d406699 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseSubmitResponse.java @@ -0,0 +1,36 @@ +package com.fowoco.server.workerlink.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.workerlink.application.WorkerResponseSubmitResult; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(name = "WorkerResponseSubmitResponse", description = "근로자 응답 제출 결과") +public final class WorkerResponseSubmitResponse { + + @JsonProperty("response_id") + @Schema(name = "response_id") + private final UUID responseId; + + @JsonProperty("received_at") + @Schema(name = "received_at") + private final Instant receivedAt; + + private WorkerResponseSubmitResponse(UUID responseId, Instant receivedAt) { + this.responseId = responseId; + this.receivedAt = receivedAt; + } + + public static WorkerResponseSubmitResponse from(WorkerResponseSubmitResult result) { + return new WorkerResponseSubmitResponse(result.responseId(), result.receivedAt()); + } + + public UUID getResponseId() { + return responseId; + } + + public Instant getReceivedAt() { + return receivedAt; + } +} From 475225432991e98425518ddffc4c86f95f080e63 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 17:49:03 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat(workerlink):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20Cache-Contro?= =?UTF-8?q?l=20no-store=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/workerlink/api/WorkerLinkViewController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java index bd3abe1..6ae16ca 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java @@ -9,7 +9,9 @@ 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; @@ -45,10 +47,12 @@ public WorkerLinkViewController(WorkerLinkViewService workerLinkViewService) { @ApiResponse(responseCode = "429", description = "요청 과다") }) @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - public WorkerLinkViewResponse view( + public ResponseEntity view( @Parameter(description = "근로자 링크 토큰") @PathVariable String token ) { WorkerLinkViewResult result = workerLinkViewService.view(token); - return WorkerLinkViewResponse.from(result); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .body(WorkerLinkViewResponse.from(result)); } } From 8cd48043c6a9e85fc4027392bdcd3a4d9bc6361d Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 17:49:16 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix(workerlink):=20SecurityConfig?= =?UTF-8?q?=EC=97=90=20=EA=B3=B5=EA=B0=9C=20API=20permitAll=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/fowoco/server/common/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java index ed23ab4..7659b0e 100644 --- a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java +++ b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java @@ -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/**") From 5b208f38ec07322499564783c662b064e3e58d9c Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 18:41:32 +0900 Subject: [PATCH 26/34] =?UTF-8?q?test(workerlink):=20=EB=B0=9C=EA=B8=89~?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=A0=9C=EC=B6=9C=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EB=B0=8F=20tenant=20=EA=B2=A9=EB=A6=AC=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- .../WorkerLinkSecurityIntegrationTest.java | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java new file mode 100644 index 0000000..38a12f1 --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -0,0 +1,349 @@ +package com.fowoco.server.workerlink; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +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.Map; +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 WorkerLinkSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("A0000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("B0000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("A1000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("B1000000-0000-0000-0000-000000000002"); + private static final String HR_A_EMAIL = "hr.link.a@example.com"; + private static final String HR_B_EMAIL = "hr.link.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + private static final String BOUNDARY = "FowocoLinkTestBoundary1234"; + + @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 worker_response_upload"); + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update("DELETE FROM worker_link"); + 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() { + resetState(); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void fullFlow_issueViewUploadRespond_succeeds() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "전체흐름테스트근로자"); + String taskId = createApprovedTask(hrToken, workerId); + + HttpResponse issueResponse = postJson( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrToken + ); + assertThat(issueResponse.statusCode()).isEqualTo(201); + String workerUrl = JsonPath.read(issueResponse.body(), "$.worker_url"); + assertThat(workerUrl).isNotBlank(); + + String rawToken = jdbcTemplate.queryForObject( + "SELECT token_hash FROM worker_link WHERE task_id = ?", String.class, UUID.fromString(taskId) + ) != null ? workerUrl : workerUrl; + + HttpResponse viewResponse = getJson("/api/v1/public/worker-links/" + workerUrl, null); + assertThat(viewResponse.statusCode()).isEqualTo(200); + assertThat(viewResponse.headers().firstValue("Cache-Control")).contains("no-store"); + + HttpResponse uploadResponse = uploadFile(workerUrl, "passport.pdf", "application/pdf", "content".getBytes(StandardCharsets.UTF_8)); + assertThat(uploadResponse.statusCode()).isEqualTo(201); + String uploadId = JsonPath.read(uploadResponse.body(), "$.upload_id"); + + jdbcTemplate.update("UPDATE stored_file SET verified = true WHERE stored_file_id = ?", UUID.fromString(uploadId)); + + HttpResponse responseSubmit = postJson( + "/api/v1/public/worker-links/" + workerUrl + "/responses", + """ + {"response_type":"DOCUMENT_SUBMITTED","upload_ids":["%s"],"idempotency_key":"key-1"} + """.formatted(uploadId), + null + ); + assertThat(responseSubmit.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(responseSubmit.body(), "$.response_id")).isNotBlank(); + } + + @Test + void issueRejectsUnapprovedTask() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "미승인테스트근로자"); + String taskId = createUnapprovedTask(hrToken, workerId); + + HttpResponse issueResponse = postJson( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrToken + ); + + assertThat(issueResponse.statusCode()).isEqualTo(422); + } + + @Test + void issueRejectsOtherCompanyTask() throws Exception { + String hrTokenA = accessToken(login(HR_A_EMAIL)); + String hrTokenB = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(hrTokenA, "타사업장테스트근로자"); + String taskId = createApprovedTask(hrTokenA, workerId); + + HttpResponse issueResponse = postJson( + "/api/v1/tasks/" + taskId + "/worker-link", + """ + {"expires_in_hours":72,"rotate_existing":false} + """, + hrTokenB + ); + + assertThat(issueResponse.statusCode()).isEqualTo(404); + } + + @Test + void viewReturns404ForNonExistentToken() throws Exception { + HttpResponse viewResponse = getJson("/api/v1/public/worker-links/nonexistenttoken12345", null); + assertThat(viewResponse.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 createApprovedTask(String token, String workerId) throws Exception { + String taskId = createUnapprovedTask(token, workerId); + completeRequiredChecklistItems(taskId, token); + + String approvalBody = """ + { + "expected_version":0, + "ai_snapshot":{"intent":"EXPIRY_RENEWAL","confidence":0.94}, + "hr_snapshot":{"worker_id":"%s","contract_end_date":"2027-08-31","monthly_wage":2500000}, + "changed_fields":["monthly_wage"], + "source_versions":{"agent_version":"agent-1","workflow_catalog_version":"2026.07"} + } + """.formatted(workerId); + HttpResponse approvalRequest = postJson( + "/api/v1/tasks/" + taskId + "/approval-requests", approvalBody, token + ); + assertThat(approvalRequest.statusCode()).as("approval response body: %s", approvalRequest.body()).isEqualTo(201); + + HttpResponse approve = postJson( + "/api/v1/tasks/" + taskId + "/approve", + """ + {"expected_version":1,"reason":"테스트 승인"} + """, + token + ); + assertThat(approve.statusCode()).as("approve response body: %s", approve.body()).isEqualTo(200); + return taskId; + } + + private void completeRequiredChecklistItems(String taskId, String token) throws Exception { + HttpResponse detail = getJson("/api/v1/tasks/" + taskId, token); + assertThat(detail.statusCode()).isEqualTo(200); + + List> checklistItems = JsonPath.read(detail.body(), "$.checklist_items"); + for (Map item : checklistItems) { + boolean required = (boolean) item.get("required"); + if (!required) { + continue; + } + String itemId = (String) item.get("checklist_item_id"); + int itemVersion = (int) item.get("version"); + int taskVersion = ((Number) JsonPath.read(detail.body(), "$.version")).intValue(); + + HttpResponse patchResponse = patchJson( + "/api/v1/tasks/" + taskId + "/checklist-items/" + itemId, + """ + {"completed":true,"expected_version":%d,"expected_task_version":%d} + """.formatted(itemVersion, taskVersion), + token + ); + assertThat(patchResponse.statusCode()) + .as("checklist patch response: %s", patchResponse.body()) + .isEqualTo(200); + } + } + + + private String createUnapprovedTask(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 HttpResponse uploadFile(String token, String filename, String mimeType, byte[] content) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writePart(out, "file", filename, mimeType, content); + writeFieldPart(out, "clientRequestId", UUID.randomUUID().toString()); + out.write(("--" + BOUNDARY + "--\r\n").getBytes(StandardCharsets.UTF_8)); + + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/public/worker-links/" + token + "/documents")) + .header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + BOUNDARY) + .POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private void writePart(ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content) + throws 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(ByteArrayOutputStream out, String name, String value) throws 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.Builder builder = HttpRequest.newBuilder(uri(path)).GET(); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse patchJson(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method("PATCH", 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); + } +} From 97b2e089b7f0ee0e85b0417f67611274c27dcd97 Mon Sep 17 00:00:00 2001 From: chaelin Date: Sun, 2 Aug 2026 20:34:40 +0900 Subject: [PATCH 27/34] =?UTF-8?q?fix(workerlink):=20404/410=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC,=20conversationStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerLinkDocumentController.java | 3 +-- .../api/WorkerLinkViewController.java | 3 +-- .../api/WorkerResponseController.java | 3 +-- .../WorkerLinkDocumentService.java | 2 +- .../application/WorkerLinkViewService.java | 2 +- .../application/WorkerResponseService.java | 9 ++++++++- .../error/WorkerLinkErrorCode.java | 4 ++-- .../server/workerlink/domain/WorkerLink.java | 19 +++++++++++++++++++ .../WorkerLinkSecurityIntegrationTest.java | 4 ++-- 9 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java index 0013d4f..486e659 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java @@ -50,8 +50,7 @@ public WorkerLinkDocumentController(WorkerLinkDocumentService workerLinkDocument ) ), @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 링크"), - @ApiResponse(responseCode = "410", description = "만료·폐기된 링크"), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), @ApiResponse(responseCode = "413", description = "파일 크기 초과"), @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity"), diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java index 6ae16ca..6cd5331 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java @@ -42,8 +42,7 @@ public WorkerLinkViewController(WorkerLinkViewService workerLinkViewService) { schema = @Schema(implementation = WorkerLinkViewResponse.class) ) ), - @ApiResponse(responseCode = "404", description = "존재하지 않는 링크"), - @ApiResponse(responseCode = "410", description = "만료·폐기된 링크"), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), @ApiResponse(responseCode = "429", description = "요청 과다") }) @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java index f36483d..23c05fa 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerResponseController.java @@ -46,8 +46,7 @@ public WorkerResponseController(WorkerResponseService workerResponseService) { ) ), @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 링크"), - @ApiResponse(responseCode = "410", description = "만료·폐기된 링크"), + @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), @ApiResponse(responseCode = "413", description = "요청 크기 초과"), @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"), @ApiResponse(responseCode = "422", description = "허용되지 않은 upload_id"), diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java index 74be9d4..7764aeb 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -93,7 +93,7 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com Instant now = clock.instant(); if (!link.isUsable(now)) { - throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); } UUID storedFileId = uuidGenerator.generate(); diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java index 85eba20..ee8c256 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java @@ -53,7 +53,7 @@ public WorkerLinkViewResult view(String rawToken) { Instant now = clock.instant(); if (!link.isUsable(now)) { - throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); } // document-request-draft 연동해서 guidance/dueDate 실제 값 채우기. diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index 6f6327e..158e296 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -17,6 +17,7 @@ import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; import com.fowoco.server.workerlink.domain.WorkerLink; import com.fowoco.server.workerlink.domain.WorkerResponse; +import com.fowoco.server.workerlink.domain.WorkerResponseType; import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; import java.time.Clock; import java.time.Instant; @@ -78,7 +79,7 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re Instant now = clock.instant(); if (!link.isUsable(now)) { - throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_EXPIRED); + throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); } Optional existing = workerResponseRepository @@ -116,6 +117,12 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re workerResponseRepository.linkUpload(responseId, uploadId); } + if (command.responseType() == WorkerResponseType.QUESTION + || command.responseType() == WorkerResponseType.NOT_UNDERSTOOD) { + workerLinkRepository.update(link.markNeedsFollowup(now)); + // HR 후속 업무/활동 이력 + } + auditRepository.append(new AuditEvent( uuidGenerator.generate(), companyId, diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java index c651d39..7bd9eb8 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -13,8 +13,8 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { HttpStatus.CONFLICT, "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." ), - WORKER_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "링크를 찾을 수 없습니다."), - WORKER_LINK_EXPIRED(HttpStatus.GONE, "만료되었거나 폐기된 링크입니다."), + + WORKER_LINK_NOT_FOUND(HttpStatus.GONE, "링크를 찾을 수 없거나 더 이상 사용할 수 없습니다."), UPLOAD_NOT_AVAILABLE( HttpStatus.UNPROCESSABLE_CONTENT, "업로드된 파일을 찾을 수 없거나 이미 사용된 파일입니다." diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java index c773233..c45ca62 100644 --- a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java @@ -101,6 +101,25 @@ public WorkerLink revoke(Instant now) { ); } + // 근로자가 QUESTION/NOT_UNDERSTOOD 응답을 보내면, HR 후속조치가 필요한 상태로 + public WorkerLink markNeedsFollowup(Instant now) { + return new WorkerLink( + workerLinkId, + taskId, + companyId, + tokenHash, + expiresAt, + status, + ConversationStatus.NEEDS_FOLLOWUP, + assigneeId, + issuedBy, + replacesLinkId, + createdAt, + now, + version + ); + } + public boolean isUsable(Instant now) { return status == WorkerLinkStatus.ACTIVE && expiresAt.isAfter(now); } diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index 38a12f1..dc8c8f9 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -164,9 +164,9 @@ void issueRejectsOtherCompanyTask() throws Exception { } @Test - void viewReturns404ForNonExistentToken() throws Exception { + void viewReturns410ForNonExistentToken() throws Exception { HttpResponse viewResponse = getJson("/api/v1/public/worker-links/nonexistenttoken12345", null); - assertThat(viewResponse.statusCode()).isEqualTo(404); + assertThat(viewResponse.statusCode()).isEqualTo(410); } private String registerWorker(String token, String displayName) throws Exception { From 73e7b51be0c719d2f64e151e52eda2831a06b802 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 10:42:39 +0900 Subject: [PATCH 28/34] =?UTF-8?q?feat(workerlink):=20Idempotency-Key?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workerlink/api/WorkerLinkController.java | 3 +- .../application/WorkerLinkIssueCommand.java | 9 +++- .../application/WorkerLinkService.java | 14 +++++-- .../port/WorkerLinkRepository.java | 2 + .../server/workerlink/domain/WorkerLink.java | 42 +++++++------------ .../persistence/JpaWorkerLinkRepository.java | 20 +++++++++ .../persistence/WorkerLinkJpaEntity.java | 7 ++++ .../db/migration/V11__create_worker_link.sql | 2 + .../WorkerLinkSecurityIntegrationTest.java | 34 +++++++++------ 9 files changed, 89 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java index 3c432ff..7b53839 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -79,7 +79,8 @@ public ResponseEntity issue( actor.companyId(), request.getExpiresInHours(), request.isRotateExisting(), - actor.actorId() + actor.actorId(), + idempotencyKey ); WorkerLinkIssueResult result = workerLinkService.issue(command); return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result)); diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java index eb3f617..ba3c0f7 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java @@ -9,19 +9,22 @@ public final class WorkerLinkIssueCommand { private final Long expiresInHours; private final boolean rotateExisting; private final UUID issuedBy; + private final String idempotencyKey; public WorkerLinkIssueCommand( UUID taskId, UUID companyId, Long expiresInHours, boolean rotateExisting, - UUID issuedBy + UUID issuedBy, + String idempotencyKey ) { this.taskId = taskId; this.companyId = companyId; this.expiresInHours = expiresInHours; this.rotateExisting = rotateExisting; this.issuedBy = issuedBy; + this.idempotencyKey = idempotencyKey; } public UUID taskId() { @@ -43,4 +46,8 @@ public boolean rotateExisting() { public UUID issuedBy() { return issuedBy; } + + public String idempotencyKey() { + return idempotencyKey; + } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 026e5d2..1484403 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -59,16 +59,23 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); } + Optional existingByIdempotency = workerLinkRepository + .findByTaskIdAndIdempotencyKey(command.taskId(), command.idempotencyKey()); + if (existingByIdempotency.isPresent()) { + WorkerLink previous = existingByIdempotency.get(); + return new WorkerLinkIssueResult(previous.tokenHash(), previous.expiresAt()); + } + Instant now = clock.instant(); - Optional existing = workerLinkRepository + Optional existingActive = workerLinkRepository .findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId()); WorkerLink previousLink = null; - if (existing.isPresent()) { + if (existingActive.isPresent()) { if (!command.rotateExisting()) { throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_ISSUANCE_CONFLICT); } - previousLink = existing.get(); + previousLink = existingActive.get(); workerLinkRepository.update(previousLink.revoke(now)); } @@ -85,6 +92,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { expiresAt, command.issuedBy(), previousLink != null ? previousLink.workerLinkId() : null, + command.idempotencyKey(), now ); workerLinkRepository.insert(workerLink); diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java index ae3ca81..b4103ad 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerLinkRepository.java @@ -13,4 +13,6 @@ public interface WorkerLinkRepository { Optional findByTokenHash(String tokenHash); Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID companyId); + + Optional findByTaskIdAndIdempotencyKey(UUID taskId, String idempotencyKey); } diff --git a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java index c45ca62..dc3be52 100644 --- a/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java +++ b/src/main/java/com/fowoco/server/workerlink/domain/WorkerLink.java @@ -16,6 +16,7 @@ public final class WorkerLink { private final UUID assigneeId; private final UUID issuedBy; private final UUID replacesLinkId; + private final String idempotencyKey; private final Instant createdAt; private final Instant updatedAt; private final long version; @@ -31,6 +32,7 @@ public WorkerLink( UUID assigneeId, UUID issuedBy, UUID replacesLinkId, + String idempotencyKey, Instant createdAt, Instant updatedAt, long version @@ -45,6 +47,7 @@ public WorkerLink( this.assigneeId = assigneeId; this.issuedBy = Objects.requireNonNull(issuedBy, "issuedBy must not be null"); this.replacesLinkId = replacesLinkId; + this.idempotencyKey = requireText(idempotencyKey, "idempotencyKey"); this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt must not be null"); if (updatedAt.isBefore(createdAt)) { @@ -64,6 +67,7 @@ public static WorkerLink issue( Instant expiresAt, UUID issuedBy, UUID replacesLinkId, + String idempotencyKey, Instant now ) { return new WorkerLink( @@ -77,6 +81,7 @@ public static WorkerLink issue( null, issuedBy, replacesLinkId, + idempotencyKey, now, now, 0L @@ -85,38 +90,17 @@ public static WorkerLink issue( public WorkerLink revoke(Instant now) { return new WorkerLink( - workerLinkId, - taskId, - companyId, - tokenHash, - expiresAt, - WorkerLinkStatus.REVOKED, - conversationStatus, - assigneeId, - issuedBy, - replacesLinkId, - createdAt, - now, - version + workerLinkId, taskId, companyId, tokenHash, expiresAt, + WorkerLinkStatus.REVOKED, conversationStatus, assigneeId, issuedBy, + replacesLinkId, idempotencyKey, createdAt, now, version ); } - // 근로자가 QUESTION/NOT_UNDERSTOOD 응답을 보내면, HR 후속조치가 필요한 상태로 public WorkerLink markNeedsFollowup(Instant now) { return new WorkerLink( - workerLinkId, - taskId, - companyId, - tokenHash, - expiresAt, - status, - ConversationStatus.NEEDS_FOLLOWUP, - assigneeId, - issuedBy, - replacesLinkId, - createdAt, - now, - version + workerLinkId, taskId, companyId, tokenHash, expiresAt, + status, ConversationStatus.NEEDS_FOLLOWUP, assigneeId, issuedBy, + replacesLinkId, idempotencyKey, createdAt, now, version ); } @@ -171,6 +155,10 @@ public UUID replacesLinkId() { return replacesLinkId; } + public String idempotencyKey() { + return idempotencyKey; + } + public Instant createdAt() { return createdAt; } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java index 3d3dcf2..288f3cd 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerLinkRepository.java @@ -75,4 +75,24 @@ public Optional findActiveByTaskIdAndCompanyId(UUID taskId, UUID com .findFirst() .map(WorkerLinkJpaEntity::toDomain); } + + @Override + public Optional findByTaskIdAndIdempotencyKey(UUID taskId, String idempotencyKey) { + Objects.requireNonNull(taskId, "taskId must not be null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + return entityManager.createQuery( + """ + select link + from WorkerLinkJpaEntity link + where link.taskId = :taskId + and link.idempotencyKey = :idempotencyKey + """, + WorkerLinkJpaEntity.class + ) + .setParameter("taskId", taskId) + .setParameter("idempotencyKey", idempotencyKey) + .getResultStream() + .findFirst() + .map(WorkerLinkJpaEntity::toDomain); + } } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java index 1250a64..1c95803 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/WorkerLinkJpaEntity.java @@ -51,6 +51,9 @@ public class WorkerLinkJpaEntity { @Column(name = "replaces_link_id", updatable = false) private UUID replacesLinkId; + @Column(name = "idempotency_key", nullable = false, updatable = false, length = 100) + private String idempotencyKey; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -75,6 +78,7 @@ private WorkerLinkJpaEntity( UUID assigneeId, UUID issuedBy, UUID replacesLinkId, + String idempotencyKey, Instant createdAt, Instant updatedAt, long version @@ -89,6 +93,7 @@ private WorkerLinkJpaEntity( this.assigneeId = assigneeId; this.issuedBy = issuedBy; this.replacesLinkId = replacesLinkId; + this.idempotencyKey = idempotencyKey; this.createdAt = createdAt; this.updatedAt = updatedAt; this.version = version; @@ -107,6 +112,7 @@ public static WorkerLinkJpaEntity fromDomain(WorkerLink workerLink) { workerLink.assigneeId(), workerLink.issuedBy(), workerLink.replacesLinkId(), + workerLink.idempotencyKey(), workerLink.createdAt(), workerLink.updatedAt(), workerLink.version() @@ -125,6 +131,7 @@ public WorkerLink toDomain() { assigneeId, issuedBy, replacesLinkId, + idempotencyKey, createdAt, updatedAt, version diff --git a/src/main/resources/db/migration/V11__create_worker_link.sql b/src/main/resources/db/migration/V11__create_worker_link.sql index 78869d6..f2501d6 100644 --- a/src/main/resources/db/migration/V11__create_worker_link.sql +++ b/src/main/resources/db/migration/V11__create_worker_link.sql @@ -9,11 +9,13 @@ CREATE TABLE worker_link ( assignee_id UUID, issued_by UUID NOT NULL, replaces_link_id UUID, + idempotency_key VARCHAR(100) 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_worker_link PRIMARY KEY (worker_link_id), CONSTRAINT uq_worker_link_token_hash UNIQUE (token_hash), + CONSTRAINT uq_worker_link_task_idempotency UNIQUE (task_id, idempotency_key), CONSTRAINT fk_worker_link_company FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, CONSTRAINT fk_worker_link_task_company diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index dc8c8f9..d0d4551 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -92,21 +92,18 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { String workerId = registerWorker(hrToken, "전체흐름테스트근로자"); String taskId = createApprovedTask(hrToken, workerId); - HttpResponse issueResponse = postJson( + HttpResponse issueResponse = postJsonWithIdempotencyKey( "/api/v1/tasks/" + taskId + "/worker-link", """ {"expires_in_hours":72,"rotate_existing":false} """, - hrToken + hrToken, + "fullflow-issue-key" ); - assertThat(issueResponse.statusCode()).isEqualTo(201); + assertThat(issueResponse.statusCode()).as("issue response body: %s", issueResponse.body()).isEqualTo(201); String workerUrl = JsonPath.read(issueResponse.body(), "$.worker_url"); assertThat(workerUrl).isNotBlank(); - String rawToken = jdbcTemplate.queryForObject( - "SELECT token_hash FROM worker_link WHERE task_id = ?", String.class, UUID.fromString(taskId) - ) != null ? workerUrl : workerUrl; - HttpResponse viewResponse = getJson("/api/v1/public/worker-links/" + workerUrl, null); assertThat(viewResponse.statusCode()).isEqualTo(200); assertThat(viewResponse.headers().firstValue("Cache-Control")).contains("no-store"); @@ -134,12 +131,13 @@ void issueRejectsUnapprovedTask() throws Exception { String workerId = registerWorker(hrToken, "미승인테스트근로자"); String taskId = createUnapprovedTask(hrToken, workerId); - HttpResponse issueResponse = postJson( + HttpResponse issueResponse = postJsonWithIdempotencyKey( "/api/v1/tasks/" + taskId + "/worker-link", """ {"expires_in_hours":72,"rotate_existing":false} """, - hrToken + hrToken, + "unapproved-issue-key" ); assertThat(issueResponse.statusCode()).isEqualTo(422); @@ -152,12 +150,13 @@ void issueRejectsOtherCompanyTask() throws Exception { String workerId = registerWorker(hrTokenA, "타사업장테스트근로자"); String taskId = createApprovedTask(hrTokenA, workerId); - HttpResponse issueResponse = postJson( + HttpResponse issueResponse = postJsonWithIdempotencyKey( "/api/v1/tasks/" + taskId + "/worker-link", """ {"expires_in_hours":72,"rotate_existing":false} """, - hrTokenB + hrTokenB, + "othercompany-issue-key" ); assertThat(issueResponse.statusCode()).isEqualTo(404); @@ -233,7 +232,6 @@ private void completeRequiredChecklistItems(String taskId, String token) throws .isEqualTo(200); } } - private String createUnapprovedTask(String token, String workerId) throws Exception { String body = """ @@ -333,6 +331,18 @@ private HttpResponse postJson(String path, String body, String token) th } return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } + + private HttpResponse postJsonWithIdempotencyKey(String path, String body, String token, String idempotencyKey) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header("Idempotency-Key", idempotencyKey) + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + private HttpResponse patchJson(String path, String body, String token) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) .header(HttpHeaders.CONTENT_TYPE, "application/json") From 68517c16440d4a53c6e38da425e42f8482d0ee3c Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 16:08:13 +0900 Subject: [PATCH 29/34] feat(workerlink): PostgreSQL tenant bootstrap --- .../PostgreSqlWorkerLinkTenantBootstrap.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java new file mode 100644 index 0000000..b0deaa0 --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/PostgreSqlWorkerLinkTenantBootstrap.java @@ -0,0 +1,42 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlWorkerLinkTenantBootstrap implements WorkerLinkTenantBootstrap { + + private static final String WORKER_LINK_BOOTSTRAP_SQL = """ + SELECT public.bootstrap_company_id_by_worker_link_token_hash(?1) + """; + + private final EntityManager entityManager; + + public PostgreSqlWorkerLinkTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByWorkerLinkTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + Object result = entityManager.createNativeQuery(WORKER_LINK_BOOTSTRAP_SQL) + .setParameter(1, tokenHash) + .getSingleResult(); + if (result == null) { + return Optional.empty(); + } + if (result instanceof UUID companyId) { + return Optional.of(companyId); + } + return Optional.of(UUID.fromString(result.toString())); + } +} From 2634838844d455a0fb7be9dce7f3a0c5761d5267 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 16:21:50 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat(workerlink):=20PostgreSQL=20SECURITY?= =?UTF-8?q?=20DEFINER=20=ED=95=A8=EC=88=98=20=EB=B0=8F=20RLS=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=B6=94=EA=B0=80=20(V13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V13__worker_link_tenant_bootstrap.sql | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql diff --git a/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql new file mode 100644 index 0000000..9cf376b --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql @@ -0,0 +1,66 @@ +CREATE FUNCTION public.bootstrap_company_id_by_worker_link_token_hash( + p_token_hash TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT link.company_id + FROM public.worker_link AS link + WHERE link.token_hash = p_token_hash + LIMIT 1 +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_worker_link_token_hash(TEXT) + FROM PUBLIC; + +CREATE POLICY pl_worker_link_tenant_isolation + ON public.worker_link + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_worker_response_tenant_isolation + ON public.worker_response + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); +CREATE POLICY pl_worker_response_upload_tenant_isolation + ON public.worker_response_upload + FOR ALL + TO PUBLIC + USING ( + EXISTS ( + SELECT 1 + FROM public.worker_response AS response + WHERE response.response_id = worker_response_upload.response_id + AND response.company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.worker_response AS response + WHERE response.response_id = worker_response_upload.response_id + AND response.company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + ); \ No newline at end of file From f8319ccb5d5b0b49f8d90c07666c6258743420b7 Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 16:27:59 +0900 Subject: [PATCH 31/34] =?UTF-8?q?fix(workerlink):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20API=20Idempotency-Key=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/workerlink/api/WorkerLinkDocumentController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java index 486e659..dde6990 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkDocumentController.java @@ -23,6 +23,7 @@ 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 = "근로자 공개 안내·제출") @@ -66,6 +67,7 @@ public ResponseEntity upload( @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()) { From fdf83002d9ec9f6e898dd811a4edd037292df2ae Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 16:39:49 +0900 Subject: [PATCH 32/34] =?UTF-8?q?fix(workerlink):=20idempotency=20key?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EC=8B=9C=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/workerlink/application/WorkerLinkService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 1484403..e0af314 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -11,6 +11,7 @@ import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.domain.WorkerLink; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -27,6 +28,7 @@ public class WorkerLinkService { private final ApprovalRequestRepository approvalRequestRepository; private final WorkerLinkRepository workerLinkRepository; private final WorkerLinkGenerator workerLinkGenerator; + private final WorkerLinkHasher workerLinkHasher; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -35,6 +37,7 @@ public WorkerLinkService( ApprovalRequestRepository approvalRequestRepository, WorkerLinkRepository workerLinkRepository, WorkerLinkGenerator workerLinkGenerator, + WorkerLinkHasher workerLinkHasher, UuidGenerator uuidGenerator, Clock clock ) { @@ -42,6 +45,7 @@ public WorkerLinkService( this.approvalRequestRepository = approvalRequestRepository; this.workerLinkRepository = workerLinkRepository; this.workerLinkGenerator = workerLinkGenerator; + this.workerLinkHasher = workerLinkHasher; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -59,8 +63,9 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { throw new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED); } + String idempotencyKeyHash = workerLinkHasher.hash(command.idempotencyKey()); Optional existingByIdempotency = workerLinkRepository - .findByTaskIdAndIdempotencyKey(command.taskId(), command.idempotencyKey()); + .findByTaskIdAndIdempotencyKey(command.taskId(), idempotencyKeyHash); if (existingByIdempotency.isPresent()) { WorkerLink previous = existingByIdempotency.get(); return new WorkerLinkIssueResult(previous.tokenHash(), previous.expiresAt()); @@ -92,7 +97,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { expiresAt, command.issuedBy(), previousLink != null ? previousLink.workerLinkId() : null, - command.idempotencyKey(), + idempotencyKeyHash, now ); workerLinkRepository.insert(workerLink); From 1bdc360e5f729ed1560b67e036abb468393037ff Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 16:54:44 +0900 Subject: [PATCH 33/34] =?UTF-8?q?fix(workerlink):=20RLS=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=82=AD=EC=A0=9C,=20=ED=95=A8=EC=88=98=EB=A7=8C?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V13__worker_link_tenant_bootstrap.sql | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql index 9cf376b..bbba055 100644 --- a/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql +++ b/src/main/resources/db/migration-postgresql/V13__worker_link_tenant_bootstrap.sql @@ -16,51 +16,3 @@ $$; REVOKE ALL ON FUNCTION public.bootstrap_company_id_by_worker_link_token_hash(TEXT) FROM PUBLIC; - -CREATE POLICY pl_worker_link_tenant_isolation - ON public.worker_link - FOR ALL - TO PUBLIC - USING ( - company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ) - WITH CHECK ( - company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ); - -CREATE POLICY pl_worker_response_tenant_isolation - ON public.worker_response - FOR ALL - TO PUBLIC - USING ( - company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ) - WITH CHECK ( - company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ); -CREATE POLICY pl_worker_response_upload_tenant_isolation - ON public.worker_response_upload - FOR ALL - TO PUBLIC - USING ( - EXISTS ( - SELECT 1 - FROM public.worker_response AS response - WHERE response.response_id = worker_response_upload.response_id - AND response.company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ) - ) - WITH CHECK ( - EXISTS ( - SELECT 1 - FROM public.worker_response AS response - WHERE response.response_id = worker_response_upload.response_id - AND response.company_id = - NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID - ) - ); \ No newline at end of file From 67a494cd220abd77dbda1c6108adcb451bc02bbf Mon Sep 17 00:00:00 2001 From: chaelin Date: Mon, 3 Aug 2026 17:31:51 +0900 Subject: [PATCH 34/34] =?UTF-8?q?test:=20PostgreSqlMigrationTests=EC=97=90?= =?UTF-8?q?=20worker=5Flink=20=ED=95=A8=EC=88=98=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 67b839c..2111aad 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -244,6 +244,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", "bootstrap_oldest_outstanding_event_occurred_at" @@ -252,6 +253,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", "bootstrap_oldest_outstanding_event_occurred_at"