From e5309faf8e7115ffe8d4dffe05fcced72c875270 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 17:26:35 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[Feat]=20analysis=20async=EB=A5=BC=20MQ=20w?= =?UTF-8?q?orker=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20worker=20=EC=83=81=ED=83=9C=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- .env.production.example | 8 +- ...4_analysis_async_tasks_worker_metadata.sql | 20 +++ .../AnalysisWorkerInternalController.java | 105 ++++++++++++++ .../response/AnalysisAsyncStatusResponse.java | 7 + .../dto/worker/AnalysisTaskMessage.java | 40 ++++++ .../worker/AnalysisWorkerCompleteRequest.java | 16 +++ .../worker/AnalysisWorkerContextRequest.java | 11 ++ .../worker/AnalysisWorkerContextResponse.java | 25 ++++ .../worker/AnalysisWorkerFailureRequest.java | 16 +++ .../worker/AnalysisWorkerRetryRequest.java | 16 +++ .../worker/AnalysisWorkerRunningRequest.java | 13 ++ .../analysis/entity/AnalysisAsyncTask.java | 63 ++++++++- .../service/AnalysisAsyncFacadeService.java | 15 +- .../service/AnalysisAsyncProcessor.java | 36 +---- .../service/AnalysisAsyncTaskService.java | 54 ++++++-- .../service/AnalysisTaskMessagePublisher.java | 31 +++++ .../service/AnalysisWorkerBridgeService.java | 129 ++++++++++++++++++ .../global/config/RabbitMqConfig.java | 29 +++- src/main/resources/application-dev.yaml | 9 +- src/main/resources/application-prod.yaml | 9 +- .../AnalysisAsyncFacadeServiceTest.java | 4 +- 22 files changed, 609 insertions(+), 55 deletions(-) create mode 100644 ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java diff --git a/.env.example b/.env.example index 08dab98..320abfc 100644 --- a/.env.example +++ b/.env.example @@ -38,11 +38,17 @@ TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +APP_WORKER_EXCHANGE=jobdri.worker.exchange APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key -APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange +APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE} APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq +APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE} +APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute +APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute +APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq +APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/.env.production.example b/.env.production.example index 5578866..e5991d9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -42,11 +42,17 @@ TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 +APP_WORKER_EXCHANGE=jobdri.worker.exchange APP_WORKER_INTERNAL_API_KEY=change-me-internal-worker-key -APP_WORKER_JOB_POSTING_EXCHANGE=jobdri.worker.exchange +APP_WORKER_JOB_POSTING_EXCHANGE=${APP_WORKER_EXCHANGE} APP_WORKER_JOB_POSTING_QUEUE=jobdri.job-posting.ingest APP_WORKER_JOB_POSTING_ROUTING_KEY=job-posting.ingest APP_WORKER_JOB_POSTING_DLQ=jobdri.job-posting.ingest.dlq +APP_WORKER_ANALYSIS_EXCHANGE=${APP_WORKER_EXCHANGE} +APP_WORKER_ANALYSIS_QUEUE=jobdri.analysis.execute +APP_WORKER_ANALYSIS_ROUTING_KEY=analysis.execute +APP_WORKER_ANALYSIS_DLQ=jobdri.analysis.execute.dlq +APP_WORKER_ANALYSIS_MAX_RETRY_COUNT=3 JOB_POSTING_ASYNC_CORE_POOL_SIZE=2 JOB_POSTING_ASYNC_MAX_POOL_SIZE=4 JOB_POSTING_ASYNC_QUEUE_CAPACITY=20 diff --git a/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql new file mode 100644 index 0000000..b743b58 --- /dev/null +++ b/ops/db/migrations/20260704_analysis_async_tasks_worker_metadata.sql @@ -0,0 +1,20 @@ +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40); + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100); + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP; + +ALTER TABLE analysis_async_tasks + ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java new file mode 100644 index 0000000..0b33a7b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -0,0 +1,105 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerFailureRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRunningRequest; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisWorkerBridgeService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/internal/worker/analysis") +public class AnalysisWorkerInternalController { + + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + private final InternalApiKeyValidator internalApiKeyValidator; + private final AnalysisWorkerBridgeService analysisWorkerBridgeService; + private final AnalysisAsyncTaskService analysisAsyncTaskService; + + @PostMapping("/tasks/{taskId}/running") + public ApiResponse markRunning( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerRunningRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 시작 상태를 반영했습니다."); + } + + @PostMapping("/tasks/{taskId}/retry") + public ApiResponse markRetry( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerRetryRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 재시도 상태를 반영했습니다."); + } + + @PostMapping("/tasks/{taskId}/failed") + public ApiResponse failTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerFailureRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + analysisWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("자소서 분석 worker 작업 실패 상태를 반영했습니다."); + } + + @PostMapping("/context") + public ApiResponse getContext( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @Valid @RequestBody AnalysisWorkerContextRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 컨텍스트 조회에 성공했습니다.", + analysisWorkerBridgeService.getContext(request.taskId(), request.userId(), request.mockApplyId()) + ); + } + + @PostMapping("/tasks/{taskId}/complete") + public ApiResponse completeTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody AnalysisWorkerCompleteRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 작업 완료 상태를 반영했습니다.", + analysisWorkerBridgeService.completeTask(taskId, request) + ); + } + + @GetMapping("/tasks/{taskId}") + public ApiResponse getTask( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId + ) { + internalApiKeyValidator.validate(internalApiKey); + return ApiResponse.onSuccess( + "자소서 분석 worker 작업 상태 조회에 성공했습니다.", + analysisAsyncTaskService.getTaskStatusByTaskId(taskId) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java index 5220d1b..62000ce 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java @@ -11,7 +11,14 @@ public record AnalysisAsyncStatusResponse( String status, String message, String error, + String failureReason, + String workerId, + Integer retryCount, + Integer maxRetryCount, + Long queueLatencyMillis, LocalDateTime createdAt, + LocalDateTime submittedAt, + LocalDateTime lastAttemptAt, LocalDateTime startedAt, LocalDateTime completedAt, AnalysisResponse result diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java new file mode 100644 index 0000000..066a294 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java @@ -0,0 +1,40 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +@Builder +public record AnalysisTaskMessage( + String messageId, + String taskType, + String taskId, + Long userId, + Long mockApplyId, + String creditReferenceId, + int retryCount, + int maxRetryCount, + Instant submittedAt +) { + + public static AnalysisTaskMessage of( + String taskId, + Long userId, + Long mockApplyId, + String creditReferenceId, + int maxRetryCount + ) { + return AnalysisTaskMessage.builder() + .messageId(UUID.randomUUID().toString()) + .taskType("ANALYSIS") + .taskId(taskId) + .userId(userId) + .mockApplyId(mockApplyId) + .creditReferenceId(creditReferenceId) + .retryCount(0) + .maxRetryCount(Math.max(0, maxRetryCount)) + .submittedAt(Instant.now()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java new file mode 100644 index 0000000..5f3fb7c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerCompleteRequest( + @NotNull Long userId, + @NotNull Long mockApplyId, + @Valid @NotNull AnalysisLlmResponse llmResponse, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java new file mode 100644 index 0000000..0ec7a04 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerContextRequest( + @NotBlank String taskId, + @NotNull Long userId, + @NotNull Long mockApplyId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java new file mode 100644 index 0000000..f92e7a9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java @@ -0,0 +1,25 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import java.util.List; + +public record AnalysisWorkerContextResponse( + Long userId, + Long mockApplyId, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String bigClassificationName, + String middleClassificationName, + String detailClassificationName, + List questions +) { + public record AnalysisWorkerQuestionItem( + Long questionId, + String question, + String answer, + int charLimit + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java new file mode 100644 index 0000000..86b3433 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerFailureRequest( + @NotBlank String errorMessage, + @NotNull FailureReason failureReason, + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java new file mode 100644 index 0000000..2ea31a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AnalysisWorkerRetryRequest( + @NotBlank String errorMessage, + @NotNull FailureReason failureReason, + @Min(0) int retryCount, + @NotBlank String workerId, + Long queueLatencyMillis, + String openAiRequestId +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java new file mode 100644 index 0000000..08ecd1f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.worker; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; + +public record AnalysisWorkerRunningRequest( + @NotBlank String workerId, + @Min(0) int retryCount, + Instant submittedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index c9cc408..2f5c0fa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -11,6 +11,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -47,13 +49,35 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(length = 2000) private String error; + @Enumerated(EnumType.STRING) + @Column(name = "failure_reason", length = 40) + private FailureReason failureReason; + + @Column(name = "worker_id", length = 100) + private String workerId; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "max_retry_count", nullable = false) + private int maxRetryCount; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + @Column(name = "last_attempt_at") + private LocalDateTime lastAttemptAt; + + @Column(name = "queue_latency_millis") + private Long queueLatencyMillis; + @Column(name = "started_at") private LocalDateTime startedAt; @Column(name = "completed_at") private LocalDateTime completedAt; - public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) { + public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) { AnalysisAsyncTask task = new AnalysisAsyncTask(); task.taskId = UUID.randomUUID().toString(); task.userId = userId; @@ -61,6 +85,9 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId) { task.creditStatus = CreditStatus.NONE; task.status = TaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; + task.retryCount = 0; + task.maxRetryCount = Math.max(0, maxRetryCount); + task.submittedAt = LocalDateTime.now(); return task; } @@ -77,23 +104,43 @@ public void markCreditReleased() { this.creditStatus = CreditStatus.RELEASED; } - public void markRunning() { + public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; - this.startedAt = LocalDateTime.now(); + this.failureReason = null; + this.error = null; + this.workerId = workerId; + this.retryCount = Math.max(0, retryCount); + this.lastAttemptAt = LocalDateTime.now(); + this.startedAt = this.lastAttemptAt; + if (messageSubmittedAt != null) { + this.queueLatencyMillis = Math.max(0L, Duration.between(messageSubmittedAt, Instant.now()).toMillis()); + } } public void markSuccess() { this.status = TaskStatus.SUCCEEDED; this.message = "자소서 분석이 완료되었습니다."; this.error = null; + this.failureReason = null; this.completedAt = LocalDateTime.now(); } - public void markFailed(String errorMessage) { + public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + this.status = TaskStatus.PENDING; + this.message = "자소서 분석 재시도를 대기 중입니다."; + this.failureReason = failureReason; + this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); + this.completedAt = null; + } + + public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { this.status = TaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; + this.failureReason = failureReason; this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); } @@ -110,4 +157,12 @@ public enum CreditStatus { CONFIRMED, RELEASED } + + public enum FailureReason { + RATE_LIMIT, + QUEUE_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java index a0dd4b2..2a414cd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java @@ -48,7 +48,14 @@ public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String t .status(status.status()) .message(status.message()) .error(status.error()) + .failureReason(status.failureReason()) + .workerId(status.workerId()) + .retryCount(status.retryCount()) + .maxRetryCount(status.maxRetryCount()) + .queueLatencyMillis(status.queueLatencyMillis()) .createdAt(status.createdAt()) + .submittedAt(status.submittedAt()) + .lastAttemptAt(status.lastAttemptAt()) .startedAt(status.startedAt()) .completedAt(status.completedAt()) .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) @@ -68,7 +75,13 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp try { analysisService.reserveAnalysisCredit(user, creditReferenceId); analysisAsyncTaskService.markCreditReserved(taskId, creditReferenceId); - analysisAsyncProcessor.process(taskId, user.getId(), mockApplyId, creditReferenceId); + analysisAsyncProcessor.process( + taskId, + user.getId(), + mockApplyId, + creditReferenceId, + task.getMaxRetryCount() + ); return new AnalysisAsyncSubmitResponse( taskId, "PENDING", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java index afb24f4..61151f6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java @@ -1,42 +1,18 @@ package com.jobdri.jobdri_api.domain.analysis.service; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -@Slf4j @Service @RequiredArgsConstructor public class AnalysisAsyncProcessor { - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisService analysisService; - private final UserService userService; + private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; - @Async("llmAsyncExecutor") - public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId) { - analysisAsyncTaskService.markRunning(taskId); - - try { - User user = userService.getUser(userId); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); - var llmResponse = analysisService.executeAnalysis(payload); - analysisService.finalizeAnalysis(user, mockApplyId, payload, llmResponse); - analysisService.confirmAnalysisCredit(user, creditReferenceId); - analysisAsyncTaskService.markCreditConfirmed(taskId); - analysisAsyncTaskService.markSuccess(taskId); - } catch (Exception e) { - log.error("자소서 분석 비동기 처리 실패: taskId={}, mockApplyId={}", taskId, mockApplyId, e); - try { - analysisService.releaseAnalysisCredit(userService.getUser(userId), creditReferenceId); - analysisAsyncTaskService.markCreditReleased(taskId); - } catch (Exception refundException) { - log.error("자소서 분석 실패 환불 처리 실패: taskId={}, userId={}", taskId, userId, refundException); - } - analysisAsyncTaskService.markFailed(taskId, e.getMessage()); - } + public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId, int maxRetryCount) { + analysisTaskMessagePublisher.publish( + AnalysisTaskMessage.of(taskId, userId, mockApplyId, creditReferenceId, maxRetryCount) + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java index 223daea..8459225 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java @@ -3,14 +3,17 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.EnumSet; import java.util.Optional; @@ -19,10 +22,15 @@ public class AnalysisAsyncTaskService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Value("${app.worker.analysis.max-retry-count:3}") + private int maxRetryCount; @Transactional public AnalysisAsyncTask createPendingTask(Long userId, Long mockApplyId) { - return analysisAsyncTaskRepository.saveAndFlush(AnalysisAsyncTask.pending(userId, mockApplyId)); + return analysisAsyncTaskRepository.saveAndFlush( + AnalysisAsyncTask.pending(userId, mockApplyId, maxRetryCount) + ); } @Transactional @@ -40,8 +48,8 @@ public Optional findActiveTask(Long userId, Long mockApplyId) } @Transactional - public void markRunning(String taskId) { - getTask(taskId).markRunning(); + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + getTask(taskId).markRunning(workerId, retryCount, submittedAt); } @Transactional @@ -50,8 +58,13 @@ public void markSuccess(String taskId) { } @Transactional - public void markFailed(String taskId, String errorMessage) { - getTask(taskId).markFailed(errorMessage); + public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + getTask(taskId).markRetryScheduled(failureReason, errorMessage, retryCount); + } + + @Transactional + public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + getTask(taskId).markFailed(failureReason, errorMessage, retryCount); } @Transactional @@ -81,25 +94,40 @@ public AnalysisAsyncStatusResponse getTaskStatus(Long userId, String taskId) { GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); + return toStatusResponse(task); + } + + @Transactional(readOnly = true) + public AnalysisAsyncStatusResponse getTaskStatusByTaskId(String taskId) { + return toStatusResponse(getTask(taskId)); + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task) { return AnalysisAsyncStatusResponse.builder() .taskId(task.getTaskId()) .mockApplyId(task.getMockApplyId()) .status(task.getStatus().name()) .message(task.getMessage()) .error(task.getError()) + .failureReason(task.getFailureReason() != null ? task.getFailureReason().name() : null) + .workerId(task.getWorkerId()) + .retryCount(task.getRetryCount()) + .maxRetryCount(task.getMaxRetryCount()) + .queueLatencyMillis(task.getQueueLatencyMillis()) .createdAt(task.getCreatedAt()) + .submittedAt(task.getSubmittedAt()) + .lastAttemptAt(task.getLastAttemptAt()) .startedAt(task.getStartedAt()) .completedAt(task.getCompletedAt()) .result(null) .build(); } - - private AnalysisAsyncTask getTask(String taskId) { - return analysisAsyncTaskRepository.findById(taskId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, - "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId - )); - } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java new file mode 100644 index 0000000..8f99d8a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -0,0 +1,31 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.MessageDeliveryMode; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AnalysisTaskMessagePublisher { + + private final RabbitTemplate rabbitTemplate; + + @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") + private String exchange; + + @Value("${app.worker.analysis.routing-key:analysis.execute}") + private String routingKey; + + public void publish(AnalysisTaskMessage message) { + rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + }); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java new file mode 100644 index 0000000..87743b2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -0,0 +1,129 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AnalysisWorkerBridgeService { + + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisService analysisService; + private final UserService userService; + + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); + } + + public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + } + + @Transactional + public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); + } + + @Transactional(readOnly = true) + public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { + AnalysisAsyncTask task = getTask(taskId); + if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + User user = userService.getUser(userId); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); + + return new AnalysisWorkerContextResponse( + userId, + mockApplyId, + payload.jobPosting().getCompany().getName(), + payload.jobPosting().getDetailClassification().getDetailName(), + payload.jobPosting().getTask(), + payload.jobPosting().getRequirement(), + payload.jobPosting().getPreferred(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), + payload.jobPosting().getDetailClassification().getDetailName(), + toQuestionItems(payload.questions()) + ); + } + + @Transactional + public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED) { + return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); + } + if (task.getStatus() == TaskStatus.FAILED) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + + User user = userService.getUser(request.userId()); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); + AnalysisLlmResponse llmResponse = request.llmResponse(); + AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); + analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditConfirmed(taskId); + analysisAsyncTaskService.markSuccess(taskId); + return response; + } + + private List toQuestionItems(List questions) { + return questions.stream() + .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( + question.getId(), + question.getContent(), + question.getAnswer(), + question.getLimit() + )) + .toList(); + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + User user = userService.getUser(task.getUserId()); + analysisService.releaseAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditReleased(task.getTaskId()); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java index fcf71aa..aad06c2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/RabbitMqConfig.java @@ -17,7 +17,7 @@ public class RabbitMqConfig { @Bean public DirectExchange workerExchange( - @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") String exchangeName + @Value("${app.worker.exchange:jobdri.worker.exchange}") String exchangeName ) { return new DirectExchange(exchangeName, true, false); } @@ -49,6 +49,33 @@ public Binding jobPostingIngestBinding( return BindingBuilder.bind(jobPostingIngestQueue).to(workerExchange).with(routingKey); } + @Bean + public Queue analysisQueue( + @Value("${app.worker.analysis.queue:jobdri.analysis.execute}") String queueName, + @Value("${app.worker.analysis.dlq:jobdri.analysis.execute.dlq}") String deadLetterQueueName + ) { + return QueueBuilder.durable(queueName) + .withArgument("x-dead-letter-exchange", "") + .withArgument("x-dead-letter-routing-key", deadLetterQueueName) + .build(); + } + + @Bean + public Queue analysisDeadLetterQueue( + @Value("${app.worker.analysis.dlq:jobdri.analysis.execute.dlq}") String deadLetterQueueName + ) { + return QueueBuilder.durable(deadLetterQueueName).build(); + } + + @Bean + public Binding analysisBinding( + @Qualifier("analysisQueue") Queue analysisQueue, + DirectExchange workerExchange, + @Value("${app.worker.analysis.routing-key:analysis.execute}") String routingKey + ) { + return BindingBuilder.bind(analysisQueue).to(workerExchange).with(routingKey); + } + @Bean public MessageConverter rabbitMessageConverter() { return new Jackson2JsonMessageConverter(); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index db107f7..37a134e 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -75,12 +75,19 @@ app: oauth2: redirect-uri: ${APP_OAUTH2_REDIRECT_URI:http://localhost:3000/oauth2/redirect} worker: + exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY:change-me-internal-worker-key} job-posting: - exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:jobdri.worker.exchange} + exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + analysis: + exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} + queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} + routing-key: ${APP_WORKER_ANALYSIS_ROUTING_KEY:analysis.execute} + dlq: ${APP_WORKER_ANALYSIS_DLQ:jobdri.analysis.execute.dlq} + max-retry-count: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:3} admin: bootstrap-emails: ${APP_ADMIN_BOOTSTRAP_EMAILS:} corpus: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index acd8a2b..852d6bd 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -75,12 +75,19 @@ app: oauth2: redirect-uri: ${APP_OAUTH2_REDIRECT_URI} worker: + exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY} job-posting: - exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:jobdri.worker.exchange} + exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + analysis: + exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} + queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} + routing-key: ${APP_WORKER_ANALYSIS_ROUTING_KEY:analysis.execute} + dlq: ${APP_WORKER_ANALYSIS_DLQ:jobdri.analysis.execute.dlq} + max-retry-count: ${APP_WORKER_ANALYSIS_MAX_RETRY_COUNT:3} admin: bootstrap-emails: ${APP_ADMIN_BOOTSTRAP_EMAILS:} corpus: diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java index 4050907..cd8a13b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java @@ -47,7 +47,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { User user = User.signup("테스트 사용자", "analysis-async@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - AnalysisAsyncTask existingTask = AnalysisAsyncTask.pending(1L, 10L); + AnalysisAsyncTask existingTask = AnalysisAsyncTask.pending(1L, 10L, 3); when(userService.validateUser(user)).thenReturn(user); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.of(existingTask)); @@ -59,7 +59,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); - verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), anyString()); + verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), anyString(), eq(3)); } @Test From 3b8789b3f0186d2ed9734d9e96073738e4c51d40 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:03:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[Fix]=20MQ=20=EB=B0=9C=ED=96=89=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20analysis=20?= =?UTF-8?q?=ED=81=AC=EB=A0=88=EB=94=A7=20=EC=98=88=EC=95=BD=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=A1=B0=EC=A0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradle-8.14.4-bin.zip.lck | 0 .../gradle-8.14.4-bin.zip.part | 0 .../dto/worker/AnalysisTaskMessage.java | 3 - .../service/AnalysisAsyncFacadeService.java | 4 - .../service/AnalysisAsyncProcessor.java | 4 +- .../service/AnalysisTaskMessagePublisher.java | 26 ++-- .../service/AnalysisWorkerBridgeService.java | 26 +++- .../JobPostingTaskMessagePublisher.java | 26 ++-- .../mq/service/RabbitPublishSupport.java | 72 +++++++++++ src/main/resources/application-dev.yaml | 5 + src/main/resources/application-prod.yaml | 5 + .../AnalysisAsyncFacadeServiceTest.java | 23 +++- .../AnalysisWorkerBridgeServiceTest.java | 119 ++++++++++++++++++ 13 files changed, 282 insertions(+), 31 deletions(-) delete mode 100644 .gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck delete mode 100644 .gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part create mode 100644 src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.lck deleted file mode 100644 index e69de29..0000000 diff --git a/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part b/.gradle-local/wrapper/dists/gradle-8.14.4-bin/92wwslzcyst3phie3o264zltu/gradle-8.14.4-bin.zip.part deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java index 066a294..04d23bf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java @@ -12,7 +12,6 @@ public record AnalysisTaskMessage( String taskId, Long userId, Long mockApplyId, - String creditReferenceId, int retryCount, int maxRetryCount, Instant submittedAt @@ -22,7 +21,6 @@ public static AnalysisTaskMessage of( String taskId, Long userId, Long mockApplyId, - String creditReferenceId, int maxRetryCount ) { return AnalysisTaskMessage.builder() @@ -31,7 +29,6 @@ public static AnalysisTaskMessage of( .taskId(taskId) .userId(userId) .mockApplyId(mockApplyId) - .creditReferenceId(creditReferenceId) .retryCount(0) .maxRetryCount(Math.max(0, maxRetryCount)) .submittedAt(Instant.now()) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java index 2a414cd..3fa23e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java @@ -70,16 +70,12 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp AnalysisAsyncTask task = pendingTaskResult.task(); String taskId = task.getTaskId(); - String creditReferenceId = "analysisTaskId=" + taskId; try { - analysisService.reserveAnalysisCredit(user, creditReferenceId); - analysisAsyncTaskService.markCreditReserved(taskId, creditReferenceId); analysisAsyncProcessor.process( taskId, user.getId(), mockApplyId, - creditReferenceId, task.getMaxRetryCount() ); return new AnalysisAsyncSubmitResponse( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java index 61151f6..3b632c0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncProcessor.java @@ -10,9 +10,9 @@ public class AnalysisAsyncProcessor { private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; - public void process(String taskId, Long userId, Long mockApplyId, String creditReferenceId, int maxRetryCount) { + public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { analysisTaskMessagePublisher.publish( - AnalysisTaskMessage.of(taskId, userId, mockApplyId, creditReferenceId, maxRetryCount) + AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java index 8f99d8a..89fb2f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.service; import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -11,7 +11,7 @@ @RequiredArgsConstructor public class AnalysisTaskMessagePublisher { - private final RabbitTemplate rabbitTemplate; + private final RabbitPublishSupport rabbitPublishSupport; @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") private String exchange; @@ -20,12 +20,20 @@ public class AnalysisTaskMessagePublisher { private String routingKey; public void publish(AnalysisTaskMessage message) { - rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { - outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); - outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); - outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); - outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); - return outgoingMessage; - }); + rabbitPublishSupport.publish( + exchange, + routingKey, + message, + message.messageId(), + "자소서 분석 작업 메시지 발행에 실패했습니다.", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + } + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index 87743b2..b8af231 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -49,7 +49,7 @@ public void failTask(String taskId, FailureReason failureReason, String errorMes analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } - @Transactional(readOnly = true) + @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { AnalysisAsyncTask task = getTask(taskId); if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { @@ -58,6 +58,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." ); } + reserveCreditIfNeeded(task); User user = userService.getUser(userId); AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); @@ -93,8 +94,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); - analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditConfirmed(taskId); + confirmCreditIfNeeded(task, user); analysisAsyncTaskService.markSuccess(taskId); return response; } @@ -118,6 +118,26 @@ private AnalysisAsyncTask getTask(String taskId) { )); } + private void reserveCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.NONE) { + return; + } + + User user = userService.getUser(task.getUserId()); + String creditReferenceId = "analysisTaskId=" + task.getTaskId(); + analysisService.reserveAnalysisCredit(user, creditReferenceId); + analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); + } + + private void confirmCreditIfNeeded(AnalysisAsyncTask task, User user) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + + analysisService.confirmAnalysisCredit(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); + } + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java index 34432a0..0126f37 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; +import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -11,7 +11,7 @@ @RequiredArgsConstructor public class JobPostingTaskMessagePublisher { - private final RabbitTemplate rabbitTemplate; + private final RabbitPublishSupport rabbitPublishSupport; @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") private String exchange; @@ -20,12 +20,20 @@ public class JobPostingTaskMessagePublisher { private String routingKey; public void publish(JobPostingIngestTaskMessage message) { - rabbitTemplate.convertAndSend(exchange, routingKey, message, outgoingMessage -> { - outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); - outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); - outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); - outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); - return outgoingMessage; - }); + rabbitPublishSupport.publish( + exchange, + routingKey, + message, + message.messageId(), + "채용 공고 작업 메시지 발행에 실패했습니다.", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + outgoingMessage.getMessageProperties().setHeader("x-task-id", message.taskId()); + outgoingMessage.getMessageProperties().setHeader("x-task-type", message.taskType()); + outgoingMessage.getMessageProperties().setHeader("x-retry-count", message.retryCount()); + return outgoingMessage; + } + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java new file mode 100644 index 0000000..2746f0c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupport.java @@ -0,0 +1,72 @@ +package com.jobdri.jobdri_api.global.mq.service; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.amqp.core.ReturnedMessage; +import org.springframework.amqp.rabbit.connection.CorrelationData; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Component +@RequiredArgsConstructor +public class RabbitPublishSupport { + + private final RabbitTemplate rabbitTemplate; + + @Value("${app.worker.publish-confirm-timeout-millis:5000}") + private long publishConfirmTimeoutMillis; + + public void publish( + String exchange, + String routingKey, + Object payload, + String correlationId, + String failureMessage, + MessagePostProcessor messagePostProcessor + ) { + CorrelationData correlationData = new CorrelationData(correlationId); + rabbitTemplate.convertAndSend(exchange, routingKey, payload, messagePostProcessor, correlationData); + awaitPublisherConfirm(correlationData, failureMessage); + } + + private void awaitPublisherConfirm(CorrelationData correlationData, String failureMessage) { + try { + CorrelationData.Confirm confirm = correlationData.getFuture() + .get(publishConfirmTimeoutMillis, TimeUnit.MILLISECONDS); + + if (confirm == null || !confirm.isAck()) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + failureMessage + buildReasonSuffix(confirm != null ? confirm.getReason() : null) + ); + } + + ReturnedMessage returnedMessage = correlationData.getReturned(); + if (returnedMessage != null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + failureMessage + " routingKey=" + returnedMessage.getRoutingKey() + ); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + } catch (ExecutionException | TimeoutException e) { + throw new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, failureMessage); + } + } + + private String buildReasonSuffix(String reason) { + if (reason == null || reason.isBlank()) { + return ""; + } + return " reason=" + reason; + } +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 37a134e..8bf3075 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -30,6 +30,10 @@ spring: username: ${RABBITMQ_USERNAME:guest} password: ${RABBITMQ_PASSWORD:guest} virtual-host: ${RABBITMQ_VHOST:/} + publisher-confirm-type: correlated + publisher-returns: true + template: + mandatory: true mail: host: ${MAIL_HOST:smtp.gmail.com} port: ${MAIL_PORT:587} @@ -77,6 +81,7 @@ app: worker: exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY:change-me-internal-worker-key} + publish-confirm-timeout-millis: ${APP_WORKER_PUBLISH_CONFIRM_TIMEOUT_MILLIS:5000} job-posting: exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 852d6bd..53c869d 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -30,6 +30,10 @@ spring: username: ${RABBITMQ_USERNAME} password: ${RABBITMQ_PASSWORD} virtual-host: ${RABBITMQ_VHOST:/} + publisher-confirm-type: correlated + publisher-returns: true + template: + mandatory: true mail: host: ${MAIL_HOST} port: ${MAIL_PORT} @@ -77,6 +81,7 @@ app: worker: exchange: ${APP_WORKER_EXCHANGE:jobdri.worker.exchange} internal-api-key: ${APP_WORKER_INTERNAL_API_KEY} + publish-confirm-timeout-millis: ${APP_WORKER_PUBLISH_CONFIRM_TIMEOUT_MILLIS:5000} job-posting: exchange: ${APP_WORKER_JOB_POSTING_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java index cd8a13b..04b519f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -59,7 +60,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); - verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), anyString(), eq(3)); + verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), eq(3)); } @Test @@ -78,4 +79,24 @@ void submitPropagatesExceptionWhenTaskDisappearsAfterConflict() { assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) .isSameAs(exception); } + + @Test + @DisplayName("메시지 발행이 성공해도 submit 단계에서는 크레딧을 바로 예약하지 않는다") + void submitDoesNotReserveCreditBeforeWorkerStarts() { + User user = User.signup("테스트 사용자", "analysis-async-submit@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + AnalysisAsyncTask createdTask = AnalysisAsyncTask.pending(1L, 10L, 3); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenReturn(createdTask); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(createdTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); + verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java new file mode 100644 index 0000000..b9075a8 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -0,0 +1,119 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisWorkerBridgeServiceTest { + + @Mock + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisService analysisService; + + @Mock + private UserService userService; + + @InjectMocks + private AnalysisWorkerBridgeService analysisWorkerBridgeService; + + @Test + @DisplayName("worker가 컨텍스트를 조회할 때 처음 한 번만 크레딧을 예약한다") + void getContextReservesCreditBeforePreparingExecution() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-worker@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getTask()).thenReturn("백엔드 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring"); + when(jobPosting.getPreferred()).thenReturn("RabbitMQ"); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + + AnalysisExecutionPayload payload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); + + analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + + verify(analysisService).reserveAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); + verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); + verify(analysisService).prepareAnalysisExecution(user, 10L); + } + + @Test + @DisplayName("이미 예약된 작업은 컨텍스트 재조회 시 크레딧을 다시 예약하지 않는다") + void getContextDoesNotReserveCreditTwice() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); + User user = User.signup("테스트 사용자", "analysis-worker-repeat@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + Company company = mock(Company.class); + when(company.getName()).thenReturn("잡드리"); + when(jobPosting.getCompany()).thenReturn(company); + when(jobPosting.getTask()).thenReturn("백엔드 개발"); + when(jobPosting.getRequirement()).thenReturn("Spring"); + when(jobPosting.getPreferred()).thenReturn("RabbitMQ"); + when(jobPosting.getDetailClassification().getDetailName()).thenReturn("백엔드"); + when(jobPosting.getDetailClassification().getMiddleClassification().getMiddleName()).thenReturn("서버"); + when(jobPosting.getDetailClassification().getMiddleClassification().getClassification().getBigName()).thenReturn("개발"); + + AnalysisExecutionPayload payload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); + + analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + + verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); + verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); + verify(analysisService).prepareAnalysisExecution(user, 10L); + } +} From b7385b3157a50fc66ab8c0617a915b02caccc64d Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:08:21 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[Fix]=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=84=EB=8F=99=EA=B8=B0=20task=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260704_job_posting_async_tasks_user_id.sql | 5 ++ .../controller/JobPostingAiController.java | 2 +- .../JobPostingWorkerInternalController.java | 2 +- .../entity/JobPostingAsyncTask.java | 6 +- .../JobPostingAsyncTaskRepository.java | 3 + .../service/JobPostingAsyncFacadeService.java | 9 +- .../service/JobPostingAsyncTaskService.java | 24 ++++- .../JobPostingAsyncFacadeServiceTest.java | 89 +++++++++++++++++++ .../JobPostingAsyncTaskServiceTest.java | 78 ++++++++++++++++ 9 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql new file mode 100644 index 0000000..37d4942 --- /dev/null +++ b/ops/db/migrations/20260704_job_posting_async_tasks_user_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS user_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_job_posting_async_tasks_user_id_created_at + ON job_posting_async_tasks (user_id, created_at DESC); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index b89683a..3e3fedb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -104,7 +104,7 @@ public ApiResponse getIngestJobPostingAsyncStatus validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( "채용 공고 비동기 작업 상태 조회에 성공했습니다.", - jobPostingAsyncFacadeService.getTask(taskId) + jobPostingAsyncFacadeService.getTask(userDetails.getUser(), taskId) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index 7f0f163..c8006c7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -125,7 +125,7 @@ public ApiResponse getTask( internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 상태 조회에 성공했습니다.", - jobPostingAsyncFacadeService.getTask(taskId) + jobPostingAsyncFacadeService.getTaskInternal(taskId) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index aaa834e..0bfd4fa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -25,6 +25,9 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "task_id", nullable = false, updatable = false, length = 36) private String taskId; + @Column(name = "user_id") + private Long userId; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private TaskStatus status; @@ -45,9 +48,10 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "result_payload") private String resultPayload; - public static JobPostingAsyncTask pending() { + public static JobPostingAsyncTask pending(Long userId) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); + task.userId = userId; task.status = TaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; return task; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java index d867989..4841c0c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java @@ -3,5 +3,8 @@ import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface JobPostingAsyncTaskRepository extends JpaRepository { + Optional findByTaskIdAndUserId(String taskId, Long userId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index 4ca8451..11a1b33 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -21,7 +21,7 @@ public class JobPostingAsyncFacadeService { public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); - String taskId = jobPostingAsyncTaskService.createPendingTask(); + String taskId = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); JobPostingIngestCommand command = snapshot(validatedUser, request); try { @@ -40,7 +40,12 @@ public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest r } } - public JobPostingAsyncStatusResponse getTask(String taskId) { + public JobPostingAsyncStatusResponse getTask(User user, String taskId) { + User validatedUser = userService.validateUser(user); + return jobPostingAsyncTaskService.getTask(validatedUser, taskId); + } + + public JobPostingAsyncStatusResponse getTaskInternal(String taskId) { return jobPostingAsyncTaskService.getTask(taskId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 6212c2a..459bafd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -5,6 +5,8 @@ import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.entity.UserRole; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,8 +23,8 @@ public class JobPostingAsyncTaskService { private final ObjectMapper objectMapper; @Transactional - public String createPendingTask() { - JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending()); + public String createPendingTask(Long userId) { + JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId)); return task.getTaskId(); } @@ -68,6 +70,24 @@ public void markFailed(String taskId, String errorMessage) { @Transactional(readOnly = true) public JobPostingAsyncStatusResponse getTask(String taskId) { JobPostingAsyncTask taskState = getTaskState(taskId); + return toStatusResponse(taskState); + } + + @Transactional(readOnly = true) + public JobPostingAsyncStatusResponse getTask(User user, String taskId) { + if (user.getRole() == UserRole.ADMIN) { + return getTask(taskId); + } + + JobPostingAsyncTask taskState = jobPostingAsyncTaskRepository.findByTaskIdAndUserId(taskId, user.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, + "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + return toStatusResponse(taskState); + } + + private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskState) { return JobPostingAsyncStatusResponse.builder() .taskId(taskState.getTaskId()) .status(taskState.getStatus().name()) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java new file mode 100644 index 0000000..74f9b8e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java @@ -0,0 +1,89 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingAsyncFacadeServiceTest { + + @Mock + private JobPostingAsyncTaskService jobPostingAsyncTaskService; + + @Mock + private JobPostingAsyncProcessor jobPostingAsyncProcessor; + + @Mock + private UserService userService; + + @InjectMocks + private JobPostingAsyncFacadeService jobPostingAsyncFacadeService; + + @Test + @DisplayName("채용공고 비동기 작업 생성 시 작업 소유자 userId를 함께 저장한다") + void submitCreatesTaskWithUserId() { + User user = User.signup("테스트 사용자", "job-posting-submit@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn("task-1"); + + JobPostingAsyncSubmitResponse response = jobPostingAsyncFacadeService.submit(user, request); + + assertThat(response.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).createPendingTask(7L); + verify(jobPostingAsyncProcessor).process(any(), any()); + } + + @Test + @DisplayName("일반 사용자 조회는 본인 소유 task만 조회한다") + void getTaskUsesValidatedUserScope() { + User user = User.signup("테스트 사용자", "job-posting-owner@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncStatusResponse response = JobPostingAsyncStatusResponse.builder() + .taskId("task-1") + .status("PENDING") + .message("진행 중") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.getTask(user, "task-1")).thenReturn(response); + + JobPostingAsyncStatusResponse result = jobPostingAsyncFacadeService.getTask(user, "task-1"); + + assertThat(result.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).getTask(user, "task-1"); + } + + @Test + @DisplayName("내부 조회는 사용자 검증 없이 taskId로 조회한다") + void getTaskInternalReadsByTaskId() { + JobPostingAsyncStatusResponse response = JobPostingAsyncStatusResponse.builder() + .taskId("task-1") + .status("SUCCEEDED") + .message("완료") + .build(); + + when(jobPostingAsyncTaskService.getTask("task-1")).thenReturn(response); + + JobPostingAsyncStatusResponse result = jobPostingAsyncFacadeService.getTaskInternal("task-1"); + + assertThat(result.getTaskId()).isEqualTo("task-1"); + verify(jobPostingAsyncTaskService).getTask("task-1"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java new file mode 100644 index 0000000..6d2e034 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobPostingAsyncTaskServiceTest { + + @Mock + private JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; + + private JobPostingAsyncTaskService jobPostingAsyncTaskService; + + @BeforeEach + void setUp() { + jobPostingAsyncTaskService = new JobPostingAsyncTaskService(jobPostingAsyncTaskRepository, new ObjectMapper()); + } + + @Test + @DisplayName("일반 사용자는 본인 소유 task만 조회할 수 있다") + void getTaskAllowsOwnerOnly() { + User user = User.signup("테스트 사용자", "job-posting-owner-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(user, task.getTaskId()); + + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); + verify(jobPostingAsyncTaskRepository).findByTaskIdAndUserId(task.getTaskId(), 7L); + } + + @Test + @DisplayName("일반 사용자는 다른 사람 task를 조회할 수 없다") + void getTaskRejectsNonOwner() { + User user = User.signup("테스트 사용자", "job-posting-other-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId("task-1", 7L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jobPostingAsyncTaskService.getTask(user, "task-1")) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("관리자는 소유자와 관계없이 모든 task를 조회할 수 있다") + void getTaskAllowsAdmin() { + User admin = User.signup("관리자", "admin-task@example.com", "encoded-password"); + ReflectionTestUtils.setField(admin, "id", 99L); + admin.promoteToAdmin(); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(admin, task.getTaskId()); + + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); + verify(jobPostingAsyncTaskRepository).findById(task.getTaskId()); + } +} From 18b5eb925540f186e647fc96f3826be24edad429 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:47:16 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[Fix]=20=EC=B1=84=EC=9A=A9=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EB=B9=84=EB=8F=99=EA=B8=B0=20retry/timeout=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B0=8F=20MQ=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ob_posting_async_tasks_worker_metadata.sql | 20 +++ .../JobPostingWorkerInternalController.java | 20 ++- .../JobPostingAsyncStatusResponse.java | 7 ++ .../worker/JobPostingIngestTaskMessage.java | 4 +- .../JobPostingWorkerFailureRequest.java | 7 +- .../worker/JobPostingWorkerRetryRequest.java | 13 ++ .../JobPostingWorkerRunningRequest.java | 13 ++ .../entity/JobPostingAsyncTask.java | 64 +++++++++- .../service/JobPostingAsyncFacadeService.java | 6 +- .../service/JobPostingAsyncProcessor.java | 4 +- .../service/JobPostingAsyncTaskService.java | 84 +++++++++++-- .../JobPostingWorkerBridgeService.java | 14 ++- .../domain/payment/entity/Payment.java | 5 + .../payment/service/PaymentService.java | 11 ++ .../service/PaymentTransactionService.java | 11 ++ .../payment/service/TossPaymentClient.java | 8 ++ src/main/resources/application-dev.yaml | 3 + src/main/resources/application-prod.yaml | 3 + .../JobPostingAsyncFacadeServiceTest.java | 36 +++++- .../JobPostingAsyncTaskServiceTest.java | 37 +++++- .../payment/service/PaymentServiceTest.java | 37 ++++++ .../RabbitPublishSupportIntegrationTest.java | 115 ++++++++++++++++++ 22 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java diff --git a/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql new file mode 100644 index 0000000..d17e0e1 --- /dev/null +++ b/ops/db/migrations/20260704_job_posting_async_tasks_worker_metadata.sql @@ -0,0 +1,20 @@ +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(40); + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS worker_id VARCHAR(100); + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS max_retry_count INTEGER NOT NULL DEFAULT 3; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMP; + +ALTER TABLE job_posting_async_tasks + ADD COLUMN IF NOT EXISTS queue_latency_millis BIGINT; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index c8006c7..a5cfa8a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -8,6 +8,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerContextResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFailureRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFinalizeRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerRunningRequest; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingAsyncFacadeService; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingWorkerBridgeService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; @@ -38,13 +40,25 @@ public class JobPostingWorkerInternalController { @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, - @PathVariable String taskId + @PathVariable String taskId, + @Valid @RequestBody JobPostingWorkerRunningRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.markRunning(taskId); + jobPostingWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("채용 공고 worker 작업 시작 상태를 반영했습니다."); } + @PostMapping("/tasks/{taskId}/retry") + public ApiResponse markRetry( + @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, + @PathVariable String taskId, + @Valid @RequestBody JobPostingWorkerRetryRequest request + ) { + internalApiKeyValidator.validate(internalApiKey); + jobPostingWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + return ApiResponse.onSuccess("채용 공고 worker 작업 재시도 상태를 반영했습니다."); + } + @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @@ -65,7 +79,7 @@ public ApiResponse failTask( @Valid @RequestBody JobPostingWorkerFailureRequest request ) { internalApiKeyValidator.validate(internalApiKey); - jobPostingWorkerBridgeService.failTask(taskId, request.errorMessage()); + jobPostingWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); return ApiResponse.onSuccess("채용 공고 worker 작업 실패 상태를 반영했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java index 8e3d286..9c20cc0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java @@ -15,7 +15,14 @@ public class JobPostingAsyncStatusResponse { private String status; private String message; private String error; + private String failureReason; + private String workerId; + private Integer retryCount; + private Integer maxRetryCount; + private Long queueLatencyMillis; private LocalDateTime createdAt; + private LocalDateTime submittedAt; + private LocalDateTime lastAttemptAt; private LocalDateTime startedAt; private LocalDateTime completedAt; private JobPostingIngestResponse result; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java index 3d2d8cb..00faa8a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java @@ -15,10 +15,11 @@ public record JobPostingIngestTaskMessage( String rawText, String imageObjectKey, int retryCount, + int maxRetryCount, Instant submittedAt ) { - public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestCommand command) { + public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestCommand command, int maxRetryCount) { return JobPostingIngestTaskMessage.builder() .messageId(UUID.randomUUID().toString()) .taskType("JOB_POSTING_INGEST") @@ -27,6 +28,7 @@ public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestComm .rawText(command.getRawText()) .imageObjectKey(command.getImageObjectKey()) .retryCount(0) + .maxRetryCount(Math.max(0, maxRetryCount)) .submittedAt(Instant.now()) .build(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java index f66a287..3305fef 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java @@ -1,8 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record JobPostingWorkerFailureRequest( - @NotBlank String errorMessage + @NotNull FailureReason failureReason, + @NotBlank String errorMessage, + @Min(0) int retryCount ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java new file mode 100644 index 0000000..345b49f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.worker; + +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record JobPostingWorkerRetryRequest( + @NotNull FailureReason failureReason, + @NotBlank String errorMessage, + @Min(0) int retryCount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java new file mode 100644 index 0000000..dbeebb0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRunningRequest.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.worker; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; + +public record JobPostingWorkerRunningRequest( + @NotBlank String workerId, + @Min(0) int retryCount, + Instant submittedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index 0bfd4fa..a5d811e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -12,6 +12,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; @@ -38,6 +40,28 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(length = 2000) private String error; + @Enumerated(EnumType.STRING) + @Column(name = "failure_reason", length = 40) + private FailureReason failureReason; + + @Column(name = "worker_id", length = 100) + private String workerId; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "max_retry_count", nullable = false) + private int maxRetryCount; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + @Column(name = "last_attempt_at") + private LocalDateTime lastAttemptAt; + + @Column(name = "queue_latency_millis") + private Long queueLatencyMillis; + @Column(name = "started_at") private LocalDateTime startedAt; @@ -48,36 +72,68 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "result_payload") private String resultPayload; - public static JobPostingAsyncTask pending(Long userId) { + public static JobPostingAsyncTask pending(Long userId, int maxRetryCount) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); task.userId = userId; task.status = TaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; + task.retryCount = 0; + task.maxRetryCount = Math.max(0, maxRetryCount); + task.submittedAt = LocalDateTime.now(); return task; } - public void markRunning() { + public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { this.status = TaskStatus.RUNNING; this.message = "채용 공고 비동기 처리를 진행 중입니다."; - this.startedAt = LocalDateTime.now(); + this.failureReason = null; + this.error = null; + this.workerId = workerId; + this.retryCount = Math.max(0, retryCount); + this.lastAttemptAt = LocalDateTime.now(); + this.startedAt = this.lastAttemptAt; + if (messageSubmittedAt != null) { + this.queueLatencyMillis = Math.max(0L, Duration.between(messageSubmittedAt, Instant.now()).toMillis()); + } } public void markSuccess(String resultPayload) { this.status = TaskStatus.SUCCEEDED; this.message = "채용 공고 비동기 처리에 성공했습니다."; this.error = null; + this.failureReason = null; this.resultPayload = resultPayload; this.completedAt = LocalDateTime.now(); } - public void markFailed(String errorMessage) { + public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + this.status = TaskStatus.PENDING; + this.message = "채용 공고 비동기 재시도를 대기 중입니다."; + this.failureReason = failureReason; + this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); + this.completedAt = null; + } + + public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { this.status = TaskStatus.FAILED; this.message = "채용 공고 비동기 처리에 실패했습니다."; + this.failureReason = failureReason; this.error = errorMessage; + this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); } + public enum FailureReason { + RATE_LIMIT, + QUEUE_TIMEOUT, + WORKER_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR + } + public enum TaskStatus { PENDING, RUNNING, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index 11a1b33..cc134a3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -4,6 +4,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; @@ -21,11 +22,12 @@ public class JobPostingAsyncFacadeService { public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); - String taskId = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); + JobPostingAsyncTask task = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); + String taskId = task.getTaskId(); JobPostingIngestCommand command = snapshot(validatedUser, request); try { - jobPostingAsyncProcessor.process(taskId, command); + jobPostingAsyncProcessor.process(taskId, command, task.getMaxRetryCount()); return new JobPostingAsyncSubmitResponse( taskId, "PENDING", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java index 7cff597..6c529a2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncProcessor.java @@ -11,7 +11,7 @@ public class JobPostingAsyncProcessor { private final JobPostingTaskMessagePublisher jobPostingTaskMessagePublisher; - public void process(String taskId, JobPostingIngestCommand command) { - jobPostingTaskMessagePublisher.publish(JobPostingIngestTaskMessage.of(taskId, command)); + public void process(String taskId, JobPostingIngestCommand command, int maxRetryCount) { + jobPostingTaskMessagePublisher.publish(JobPostingIngestTaskMessage.of(taskId, command, maxRetryCount)); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 459bafd..4a4fd79 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -3,6 +3,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -12,9 +13,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class JobPostingAsyncTaskService { @@ -22,10 +28,18 @@ public class JobPostingAsyncTaskService { private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; private final ObjectMapper objectMapper; + @Value("${app.worker.job-posting.max-retry-count:3}") + private int maxRetryCount; + + @Value("${app.worker.job-posting.queue-timeout-minutes:10}") + private long queueTimeoutMinutes; + + @Value("${app.worker.job-posting.processing-timeout-minutes:20}") + private long processingTimeoutMinutes; + @Transactional - public String createPendingTask(Long userId) { - JobPostingAsyncTask task = jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId)); - return task.getTaskId(); + public JobPostingAsyncTask createPendingTask(Long userId) { + return jobPostingAsyncTaskRepository.save(JobPostingAsyncTask.pending(userId, maxRetryCount)); } @Transactional @@ -34,12 +48,12 @@ public void deleteTask(String taskId) { } @Transactional - public void markRunning(String taskId) { + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; } - task.markRunning(); + task.markRunning(workerId, retryCount, submittedAt); } @Transactional @@ -59,21 +73,31 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } @Transactional - public void markFailed(String taskId, String errorMessage) { + public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + JobPostingAsyncTask task = getTaskState(taskId); + if (isTerminal(task)) { + return; + } + task.markRetryScheduled(failureReason, errorMessage, retryCount); + } + + @Transactional + public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (task.getStatus() == TaskStatus.SUCCEEDED) { return; } - task.markFailed(errorMessage); + task.markFailed(failureReason, errorMessage, retryCount); } - @Transactional(readOnly = true) + @Transactional public JobPostingAsyncStatusResponse getTask(String taskId) { JobPostingAsyncTask taskState = getTaskState(taskId); + expireTimedOutTaskIfNeeded(taskState); return toStatusResponse(taskState); } - @Transactional(readOnly = true) + @Transactional public JobPostingAsyncStatusResponse getTask(User user, String taskId) { if (user.getRole() == UserRole.ADMIN) { return getTask(taskId); @@ -84,6 +108,7 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); + expireTimedOutTaskIfNeeded(taskState); return toStatusResponse(taskState); } @@ -93,7 +118,14 @@ private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskS .status(taskState.getStatus().name()) .message(taskState.getMessage()) .error(taskState.getError()) + .failureReason(taskState.getFailureReason() != null ? taskState.getFailureReason().name() : null) + .workerId(taskState.getWorkerId()) + .retryCount(taskState.getRetryCount()) + .maxRetryCount(taskState.getMaxRetryCount()) + .queueLatencyMillis(taskState.getQueueLatencyMillis()) .createdAt(taskState.getCreatedAt()) + .submittedAt(taskState.getSubmittedAt()) + .lastAttemptAt(taskState.getLastAttemptAt()) .startedAt(taskState.getStartedAt()) .completedAt(taskState.getCompletedAt()) .result(deserializeResult(taskState.getResultPayload())) @@ -112,6 +144,40 @@ private boolean isTerminal(JobPostingAsyncTask task) { return task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED; } + private void expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { + if (isTerminal(task)) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + if (task.getStatus() == TaskStatus.PENDING + && isExpired(task.getSubmittedAt(), now, queueTimeoutMinutes)) { + task.markFailed( + FailureReason.QUEUE_TIMEOUT, + "채용 공고 작업이 대기열에서 시간 내 처리되지 않았습니다.", + task.getRetryCount() + ); + return; + } + + LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); + if (task.getStatus() == TaskStatus.RUNNING + && isExpired(lastActivityAt, now, processingTimeoutMinutes)) { + task.markFailed( + FailureReason.WORKER_TIMEOUT, + "채용 공고 작업이 처리 제한 시간을 초과했습니다.", + task.getRetryCount() + ); + } + } + + private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutMinutes) { + if (baseTime == null || timeoutMinutes <= 0) { + return false; + } + return Duration.between(baseTime, now).toMinutes() >= timeoutMinutes; + } + private String serializeResult(JobPostingIngestResponse result) { try { return objectMapper.writeValueAsString(result); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 38fee88..768bf97 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -6,6 +6,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -15,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; @Service @@ -28,8 +30,8 @@ public class JobPostingWorkerBridgeService { private final JobPostingService jobPostingService; private final UserService userService; - public void markRunning(String taskId) { - jobPostingAsyncTaskService.markRunning(taskId); + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + jobPostingAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); } @Transactional @@ -37,8 +39,12 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp return jobPostingAsyncTaskService.markSuccess(taskId, result); } - public void failTask(String taskId, String errorMessage) { - jobPostingAsyncTaskService.markFailed(taskId, errorMessage); + public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + jobPostingAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + } + + public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + jobPostingAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } public String createReadableImageUrl(Long userId, String imageObjectKey) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 962c4ce..62b99f7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -81,6 +81,11 @@ public void fail() { this.status = PaymentStatus.FAILED; } + public void resetToPending() { + this.paymentKey = null; + this.status = PaymentStatus.PENDING; + } + public boolean belongsTo(Long userId) { return user != null && user.getId() != null && user.getId().equals(userId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 7614133..47de7c1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -81,6 +81,17 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) TossPaymentConfirmResponse tossResponse = tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount()); validateTossResponse(request, tossResponse); + } catch (GeneralException e) { + if (e.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { + paymentTransactionService.rollbackConfirmationToPending( + validatedUser.getId(), + request.orderId(), + request.paymentKey() + ); + throw e; + } + paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); + throw e; } catch (RuntimeException e) { paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey()); throw e; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index 3881a35..4ed0e4c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -76,6 +76,17 @@ public void failConfirmation(Long userId, String orderId, String paymentKey) { } } + @Transactional + public void rollbackConfirmationToPending(Long userId, String orderId, String paymentKey) { + Payment payment = getOwnedPaymentForUpdate(userId, orderId); + if (!payment.hasPaymentKey(paymentKey)) { + return; + } + if (payment.getStatus() == PaymentStatus.PROCESSING) { + payment.resetToPending(); + } + } + private Payment getOwnedPaymentForUpdate(Long userId, String orderId) { Payment payment = paymentRepository.findByOrderIdForUpdate(orderId) .orElseThrow(() -> new GeneralException( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index 2e9c1a8..fbf2584 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -13,6 +13,7 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; @@ -74,6 +75,13 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스페이먼츠 결제 승인 실패" ); + } catch (ResourceAccessException e) { + log.warn("Toss payment confirm request timed out. message={}", truncate(e.getMessage())); + log.warn("Toss payment confirm timeout exception", e); + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "토스페이먼츠 결제 승인 응답이 지연되고 있습니다." + ); } catch (RestClientException e) { log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage())); log.warn("Toss payment confirm request exception", e); diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 8bf3075..a75cffd 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -87,6 +87,9 @@ app: queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + max-retry-count: ${APP_WORKER_JOB_POSTING_MAX_RETRY_COUNT:3} + queue-timeout-minutes: ${APP_WORKER_JOB_POSTING_QUEUE_TIMEOUT_MINUTES:10} + processing-timeout-minutes: ${APP_WORKER_JOB_POSTING_PROCESSING_TIMEOUT_MINUTES:20} analysis: exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 53c869d..4276989 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -87,6 +87,9 @@ app: queue: ${APP_WORKER_JOB_POSTING_QUEUE:jobdri.job-posting.ingest} routing-key: ${APP_WORKER_JOB_POSTING_ROUTING_KEY:job-posting.ingest} dlq: ${APP_WORKER_JOB_POSTING_DLQ:jobdri.job-posting.ingest.dlq} + max-retry-count: ${APP_WORKER_JOB_POSTING_MAX_RETRY_COUNT:3} + queue-timeout-minutes: ${APP_WORKER_JOB_POSTING_QUEUE_TIMEOUT_MINUTES:10} + processing-timeout-minutes: ${APP_WORKER_JOB_POSTING_PROCESSING_TIMEOUT_MINUTES:20} analysis: exchange: ${APP_WORKER_ANALYSIS_EXCHANGE:${APP_WORKER_EXCHANGE:jobdri.worker.exchange}} queue: ${APP_WORKER_ANALYSIS_QUEUE:jobdri.analysis.execute} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java index 74f9b8e..63e3f84 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java @@ -3,8 +3,11 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,7 +17,11 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,15 +46,16 @@ void submitCreatesTaskWithUserId() { User user = User.signup("테스트 사용자", "job-posting-submit@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(userService.validateUser(user)).thenReturn(user); - when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn("task-1"); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn(task); JobPostingAsyncSubmitResponse response = jobPostingAsyncFacadeService.submit(user, request); - assertThat(response.getTaskId()).isEqualTo("task-1"); + assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); verify(jobPostingAsyncTaskService).createPendingTask(7L); - verify(jobPostingAsyncProcessor).process(any(), any()); + verify(jobPostingAsyncProcessor).process(eq(task.getTaskId()), any(), eq(3)); } @Test @@ -86,4 +94,26 @@ void getTaskInternalReadsByTaskId() { assertThat(result.getTaskId()).isEqualTo("task-1"); verify(jobPostingAsyncTaskService).getTask("task-1"); } + + @Test + @DisplayName("메시지 발행 실패 시 생성했던 task를 삭제하고 접수 실패를 반환한다") + void submitDeletesTaskWhenPublishFails() { + User user = User.signup("테스트 사용자", "job-posting-submit-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn(task); + doThrow(new GeneralException(GeneralErrorCode.SERVICE_UNAVAILABLE, "publish failed")) + .when(jobPostingAsyncProcessor) + .process(eq(task.getTaskId()), any(), eq(3)); + + assertThatThrownBy(() -> jobPostingAsyncFacadeService.submit(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE); + + verify(jobPostingAsyncTaskService, times(1)).deleteTask(task.getTaskId()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index 6d2e034..844754d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -38,7 +40,7 @@ void setUp() { void getTaskAllowsOwnerOnly() { User user = User.signup("테스트 사용자", "job-posting-owner-task@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); - JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); @@ -66,7 +68,7 @@ void getTaskAllowsAdmin() { User admin = User.signup("관리자", "admin-task@example.com", "encoded-password"); ReflectionTestUtils.setField(admin, "id", 99L); admin.promoteToAdmin(); - JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); @@ -75,4 +77,35 @@ void getTaskAllowsAdmin() { assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); verify(jobPostingAsyncTaskRepository).findById(task.getTaskId()); } + + @Test + @DisplayName("대기 시간이 임계치를 넘은 task는 QUEUE_TIMEOUT으로 실패 처리한다") + void getTaskMarksPendingTimeout() { + ReflectionTestUtils.setField(jobPostingAsyncTaskService, "queueTimeoutMinutes", 1L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + ReflectionTestUtils.setField(task, "submittedAt", java.time.LocalDateTime.now().minusMinutes(2)); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); + + assertThat(response.getStatus()).isEqualTo("FAILED"); + assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + } + + @Test + @DisplayName("실행 중 시간이 임계치를 넘은 task는 WORKER_TIMEOUT으로 실패 처리한다") + void getTaskMarksRunningTimeout() { + ReflectionTestUtils.setField(jobPostingAsyncTaskService, "processingTimeoutMinutes", 1L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markRunning("worker-1", 1, java.time.Instant.now().minusSeconds(120)); + ReflectionTestUtils.setField(task, "lastAttemptAt", java.time.LocalDateTime.now().minusMinutes(2)); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); + + assertThat(response.getStatus()).isEqualTo("FAILED"); + assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 37d489f..bb0e9fa 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -183,6 +183,43 @@ void confirmMarksPaymentAsFailedWhenTossConfirmFails() { )).isEmpty(); } + @Test + @DisplayName("토스 승인 타임아웃이면 결제를 PENDING으로 되돌리고 재시도를 허용한다") + void confirmAllowsRetryWhenTossConfirmTimesOut() { + User user = saveUser("payment-confirm-timeout@example.com"); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + PaymentConfirmRequest request = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenThrow(new GeneralException(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, "timeout")) + .thenReturn(new TossPaymentConfirmResponse( + paymentKey, + prepared.orderId(), + prepared.orderName(), + "DONE", + 2500, + "CARD" + )); + + assertThatThrownBy(() -> paymentService.confirm(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT); + + Payment timedOutPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(timedOutPayment.getStatus()).isEqualTo(PaymentStatus.PENDING); + + PaymentConfirmResponse retryResponse = paymentService.confirm(user, request); + + assertThat(retryResponse.status()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + @Test @DisplayName("동일 결제 승인 요청이 동시에 들어와도 한 번만 크레딧을 충전한다") void confirmConcurrentlyChargesOnlyOnce() throws Exception { diff --git a/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java new file mode 100644 index 0000000..ed55bdf --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/mq/service/RabbitPublishSupportIntegrationTest.java @@ -0,0 +1,115 @@ +package com.jobdri.jobdri_api.global.mq.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; +import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageDeliveryMode; +import org.springframework.amqp.rabbit.core.RabbitAdmin; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.rabbitmq.host=localhost", + "spring.rabbitmq.port=5672", + "spring.rabbitmq.username=guest", + "spring.rabbitmq.password=guest", + "spring.rabbitmq.publisher-confirm-type=correlated", + "spring.rabbitmq.publisher-returns=true", + "spring.rabbitmq.template.mandatory=true" +}) +class RabbitPublishSupportIntegrationTest { + + @Autowired + private RabbitPublishSupport rabbitPublishSupport; + + @Autowired + private RabbitTemplate rabbitTemplate; + + @Autowired + private RabbitAdmin rabbitAdmin; + + @Autowired + private DirectExchange workerExchange; + + @Value("${app.worker.job-posting.queue:jobdri.job-posting.ingest}") + private String queueName; + + @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") + private String routingKey; + + @BeforeEach + void purgeQueue() { + rabbitAdmin.purgeQueue(queueName, true); + } + + @Test + @DisplayName("RabbitMQ publisher confirm이 오면 메시지가 큐에 적재된다") + void publishStoresMessageInQueue() { + JobPostingIngestTaskMessage message = JobPostingIngestTaskMessage.of( + "task-1", + JobPostingIngestCommand.builder() + .userId(1L) + .rawText("채용 공고") + .build(), + 3 + ); + + rabbitPublishSupport.publish( + workerExchange.getName(), + routingKey, + message, + message.messageId(), + "메시지 발행 실패", + outgoingMessage -> { + outgoingMessage.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); + outgoingMessage.getMessageProperties().setMessageId(message.messageId()); + return outgoingMessage; + } + ); + + Message queuedMessage = rabbitTemplate.receive(queueName, 3000); + + assertThat(queuedMessage).isNotNull(); + assertThat(queuedMessage.getMessageProperties().getMessageId()).isEqualTo(message.messageId()); + } + + @Test + @DisplayName("routing key가 잘못되면 publisher return을 감지해 예외를 던진다") + void publishThrowsWhenRouteIsInvalid() { + JobPostingIngestTaskMessage message = JobPostingIngestTaskMessage.of( + "task-2", + JobPostingIngestCommand.builder() + .userId(1L) + .rawText("채용 공고") + .build(), + 3 + ); + + assertThatThrownBy(() -> rabbitPublishSupport.publish( + workerExchange.getName(), + "job-posting.ingest.invalid", + message, + message.messageId(), + "메시지 발행 실패", + outgoingMessage -> outgoingMessage + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.SERVICE_UNAVAILABLE); + } +} From ba9788fd5470a66ec9debf648915410eaa365d02 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 4 Jul 2026 18:57:56 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[Fix]=20analysis=20async=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=EC=9D=B4=EC=99=80=20worker=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisWorkerInternalController.java | 18 +++++++- .../worker/AnalysisWorkerCompleteRequest.java | 3 +- .../worker/AnalysisWorkerFailureRequest.java | 3 +- .../worker/AnalysisWorkerRetryRequest.java | 3 +- .../analysis/entity/AnalysisAsyncTask.java | 26 +++++++++++ .../service/AnalysisAsyncTaskService.java | 5 +++ .../service/AnalysisQueueProperties.java | 10 +++++ .../service/AnalysisTaskMessagePublisher.java | 14 +++--- .../service/AnalysisWorkerBridgeService.java | 35 ++++++++++++++- .../service/JobPostingQueueProperties.java | 10 +++++ .../JobPostingTaskMessagePublisher.java | 14 +++--- .../AnalysisAsyncFacadeServiceTest.java | 14 ++++++ .../AnalysisWorkerBridgeServiceTest.java | 45 +++++++++++++++++++ 13 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java index 0b33a7b..c5f9452 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -51,7 +51,14 @@ public ApiResponse markRetry( @Valid @RequestBody AnalysisWorkerRetryRequest request ) { internalApiKeyValidator.validate(internalApiKey); - analysisWorkerBridgeService.markRetry(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + analysisWorkerBridgeService.markRetry( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("자소서 분석 worker 작업 재시도 상태를 반영했습니다."); } @@ -62,7 +69,14 @@ public ApiResponse failTask( @Valid @RequestBody AnalysisWorkerFailureRequest request ) { internalApiKeyValidator.validate(internalApiKey); - analysisWorkerBridgeService.failTask(taskId, request.failureReason(), request.errorMessage(), request.retryCount()); + analysisWorkerBridgeService.failTask( + taskId, + request.failureReason(), + request.errorMessage(), + request.retryCount(), + request.workerId(), + request.queueLatencyMillis() + ); return ApiResponse.onSuccess("자소서 분석 worker 작업 실패 상태를 반영했습니다."); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java index 5f3fb7c..32eb1fd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerCompleteRequest( @NotNull Long mockApplyId, @Valid @NotNull AnalysisLlmResponse llmResponse, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java index 86b3433..1c9d902 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerFailureRequest( @NotNull FailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java index 2ea31a0..b8448db 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java @@ -10,7 +10,6 @@ public record AnalysisWorkerRetryRequest( @NotNull FailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, - Long queueLatencyMillis, - String openAiRequestId + Long queueLatencyMillis ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 2f5c0fa..513ce37 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -105,6 +105,9 @@ public void markCreditReleased() { } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { + if (isTerminal()) { + return; + } this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; this.failureReason = null; @@ -127,6 +130,13 @@ public void markSuccess() { } public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + if (isTerminal()) { + return; + } + if (retryCount >= maxRetryCount) { + markFailed(failureReason, errorMessage, retryCount); + return; + } this.status = TaskStatus.PENDING; this.message = "자소서 분석 재시도를 대기 중입니다."; this.failureReason = failureReason; @@ -136,6 +146,9 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + if (status == TaskStatus.SUCCEEDED) { + return; + } this.status = TaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; this.failureReason = failureReason; @@ -144,6 +157,19 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret this.completedAt = LocalDateTime.now(); } + public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { + if (workerId != null && !workerId.isBlank()) { + this.workerId = workerId; + } + if (queueLatencyMillis != null) { + this.queueLatencyMillis = Math.max(0L, queueLatencyMillis); + } + } + + private boolean isTerminal() { + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + } + public enum TaskStatus { PENDING, RUNNING, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java index 8459225..816cf5d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java @@ -67,6 +67,11 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM getTask(taskId).markFailed(failureReason, errorMessage, retryCount); } + @Transactional + public void updateWorkerMetadata(String taskId, String workerId, Long queueLatencyMillis) { + getTask(taskId).updateWorkerMetadata(workerId, queueLatencyMillis); + } + @Transactional public void markCreditReserved(String taskId, String creditReferenceId) { getTask(taskId).markCreditReserved(creditReferenceId); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java new file mode 100644 index 0000000..6c155eb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisQueueProperties.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public record AnalysisQueueProperties( + @Value("${app.worker.analysis.routing-key:analysis.execute}") String routingKey +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java index 89fb2f0..6a8e781 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisTaskMessagePublisher.java @@ -4,7 +4,7 @@ import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.amqp.core.DirectExchange; import org.springframework.stereotype.Service; @Service @@ -12,17 +12,13 @@ public class AnalysisTaskMessagePublisher { private final RabbitPublishSupport rabbitPublishSupport; - - @Value("${app.worker.analysis.exchange:jobdri.worker.exchange}") - private String exchange; - - @Value("${app.worker.analysis.routing-key:analysis.execute}") - private String routingKey; + private final DirectExchange workerExchange; + private final AnalysisQueueProperties analysisQueueProperties; public void publish(AnalysisTaskMessage message) { rabbitPublishSupport.publish( - exchange, - routingKey, + workerExchange.getName(), + analysisQueueProperties.routingKey(), message, message.messageId(), "자소서 분석 작업 메시지 발행에 실패했습니다.", diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index b8af231..1d28b78 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -31,20 +31,44 @@ public class AnalysisWorkerBridgeService { private final UserService userService; public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); } - public void markRetry(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetry( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return; + } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); } @Transactional - public void failTask(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void failTask( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { AnalysisAsyncTask task = getTask(taskId); if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { return; } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); releaseCreditIfNeeded(task); analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); } @@ -80,6 +104,12 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long @Transactional public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { AnalysisAsyncTask task = getTask(taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } if (task.getStatus() == TaskStatus.SUCCEEDED) { return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); } @@ -94,6 +124,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, request.mockApplyId()); AnalysisLlmResponse llmResponse = request.llmResponse(); AnalysisResponse response = analysisService.finalizeAnalysis(user, request.mockApplyId(), payload, llmResponse); + analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); confirmCreditIfNeeded(task, user); analysisAsyncTaskService.markSuccess(taskId); return response; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java new file mode 100644 index 0000000..967e8f2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingQueueProperties.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public record JobPostingQueueProperties( + @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") String routingKey +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java index 0126f37..1d5338d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingTaskMessagePublisher.java @@ -3,8 +3,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingIngestTaskMessage; import com.jobdri.jobdri_api.global.mq.service.RabbitPublishSupport; import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.DirectExchange; import org.springframework.amqp.core.MessageDeliveryMode; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service @@ -12,17 +12,13 @@ public class JobPostingTaskMessagePublisher { private final RabbitPublishSupport rabbitPublishSupport; - - @Value("${app.worker.job-posting.exchange:jobdri.worker.exchange}") - private String exchange; - - @Value("${app.worker.job-posting.routing-key:job-posting.ingest}") - private String routingKey; + private final DirectExchange workerExchange; + private final JobPostingQueueProperties jobPostingQueueProperties; public void publish(JobPostingIngestTaskMessage message) { rabbitPublishSupport.publish( - exchange, - routingKey, + workerExchange.getName(), + jobPostingQueueProperties.routingKey(), message, message.messageId(), "채용 공고 작업 메시지 발행에 실패했습니다.", diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java index 04b519f..443b93d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeServiceTest.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import org.junit.jupiter.api.DisplayName; @@ -99,4 +100,17 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); verify(analysisService, never()).reserveAnalysisCredit(eq(user), anyString()); } + + @Test + @DisplayName("재시도 횟수가 maxRetryCount에 도달하면 task를 FAILED로 전환한다") + void retryAtLimitMarksTaskFailed() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-1", 1); + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-2", 2); + task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-3", 3); + + assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTask.TaskStatus.FAILED); + assertThat(task.getRetryCount()).isEqualTo(3); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java index b9075a8..84b6857 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -1,11 +1,14 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +20,8 @@ import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -116,4 +121,44 @@ void getContextDoesNotReserveCreditTwice() { verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } + + @Test + @DisplayName("완료된 작업은 running이나 retry로 되돌리지 않는다") + void terminalTaskDoesNotReopen() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.markSuccess(); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + analysisWorkerBridgeService.markRunning(task.getTaskId(), "worker-1", 1, java.time.Instant.now()); + analysisWorkerBridgeService.markRetry( + task.getTaskId(), + FailureReason.INTERNAL_ERROR, + "retry", + 1, + "worker-1", + 10L + ); + + verify(analysisAsyncTaskService, never()).markRunning(eq(task.getTaskId()), eq("worker-1"), eq(1), any()); + verify(analysisAsyncTaskService, never()).markRetryScheduled(eq(task.getTaskId()), eq(FailureReason.INTERNAL_ERROR), eq("retry"), eq(1)); + } + + @Test + @DisplayName("완료 요청의 사용자나 mockApply가 task와 다르면 거부한다") + void completeTaskRejectsMismatchedIdentity() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 2L, + 11L, + mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class), + "worker-1", + 10L + ); + + assertThatThrownBy(() -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request)) + .isInstanceOf(GeneralException.class); + } }