diff --git a/analysis-server b/analysis-server new file mode 160000 index 00000000..892724a2 --- /dev/null +++ b/analysis-server @@ -0,0 +1 @@ +Subproject commit 892724a2ba9fd70aa15e4976006df1944c3315d2 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java index 6b62e49d..26a7b570 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java @@ -1,7 +1,7 @@ -package com.jobdri.jobdri_api.domain.analysis.service.core; +package com.jobdri.jobdri_api.domain.analysis.application.model; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java new file mode 100644 index 00000000..b7faf232 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.application.port; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; + +public interface AnalysisGenerator { + AnalysisLlmResponse analyze(AnalysisExecutionPayload payload); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java new file mode 100644 index 00000000..ce88e074 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java @@ -0,0 +1,176 @@ +package com.jobdri.jobdri_api.domain.analysis.application.usecase.async; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +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.service.async.AnalysisAsyncProcessor; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +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.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +// 분석 비동기 작업의 접수/조회/취소 유스케이스를 조율한다. +public class AnalysisAsyncUseCase { + private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; + + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncProcessor analysisAsyncProcessor; + private final AnalysisService analysisService; + private final UserService userService; + + public AnalysisAsyncUseCase( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncProcessor analysisAsyncProcessor, + AnalysisService analysisService, + UserService userService + ) { + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisAsyncProcessor = analysisAsyncProcessor; + this.analysisService = analysisService; + this.userService = userService; + } + + public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { + User validatedUser = userService.validateUser(user); + analysisService.validateAnalysisRequest(validatedUser, mockApplyId); + + return analysisAsyncTaskService.findActiveTask(validatedUser.getId(), mockApplyId) + .map(this::toInProgressResponse) + .orElseGet(() -> createCachedOrProcessTask(validatedUser, mockApplyId)); + } + + public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String taskId) { + User validatedUser = userService.validateUser(user); + AnalysisAsyncStatusResponse status = analysisAsyncTaskService.getTaskStatus(validatedUser.getId(), taskId); + if (!status.mockApplyId().equals(mockApplyId)) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); + } + if (!"SUCCEEDED".equals(status.status())) { + return status; + } + + return status.withResult(analysisService.getAnalysis(validatedUser, status.mockApplyId())); + } + + public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { + User validatedUser = userService.validateUser(user); + return analysisAsyncTaskService.cancelTask(validatedUser.getId(), mockApplyId, taskId); + } + + private AnalysisAsyncSubmitResponse createCachedOrProcessTask(User user, Long mockApplyId) { + Optional recoverableTask = + analysisAsyncTaskService.findRecoverablePublishFailureTask(user.getId(), mockApplyId); + if (recoverableTask.isPresent()) { + return reopenAndProcessTask(user, mockApplyId, recoverableTask.get()); + } + if (analysisService.hasReusableAnalysis(user, mockApplyId)) { + return toCachedResponse(); + } + return createAndProcessTask(user, mockApplyId); + } + + private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { + PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); + if (!pendingTaskResult.created()) { + return toInProgressResponse(pendingTaskResult.task()); + } + + AnalysisAsyncTask task = pendingTaskResult.task(); + return processTask(user, mockApplyId, task); + } + + private AnalysisAsyncSubmitResponse reopenAndProcessTask(User user, Long mockApplyId, AnalysisAsyncTask task) { + AnalysisAsyncTaskService.ReopenPublishFailureResult reopenResult = + analysisAsyncTaskService.reopenPublishFailureTask(task.getTaskId()); + if (!reopenResult.reopened()) { + return toInProgressResponse(reopenResult.task()); + } + return processTask(user, mockApplyId, reopenResult.task()); + } + + private AnalysisAsyncSubmitResponse processTask(User user, Long mockApplyId, AnalysisAsyncTask task) { + String taskId = task.getTaskId(); + try { + analysisAsyncProcessor.process( + taskId, + user.getId(), + mockApplyId, + task.getMaxRetryCount() + ); + return new AnalysisAsyncSubmitResponse( + taskId, + "PENDING", + "자소서 분석 비동기 작업이 접수되었습니다.", + false, + false + ); + } catch (RuntimeException e) { + analysisAsyncTaskService.markFailed( + taskId, + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + task.getRetryCount() + ); + throw e; + } + } + + private PendingTaskResult createPendingTask(User user, Long mockApplyId) { + try { + return new PendingTaskResult( + analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId), + true + ); + } catch (DataIntegrityViolationException e) { + if (!isActiveTaskUniqueConflict(e)) { + throw e; + } + AnalysisAsyncTask existingTask = analysisAsyncTaskService.findActiveTask(user.getId(), mockApplyId) + .orElseThrow(() -> e); + return new PendingTaskResult(existingTask, false); + } + } + + private AnalysisAsyncSubmitResponse toInProgressResponse(AnalysisAsyncTask task) { + return new AnalysisAsyncSubmitResponse( + task.getTaskId(), + task.getStatus().name(), + "이미 진행 중인 자소서 분석 작업이 있습니다.", + false, + false + ); + } + + private AnalysisAsyncSubmitResponse toCachedResponse() { + return new AnalysisAsyncSubmitResponse( + null, + "SUCCEEDED", + "동일 입력의 기존 자소서 분석 결과를 재사용했습니다.", + true, + true + ); + } + + private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof ConstraintViolationException constraintViolation + && ACTIVE_TASK_UNIQUE_CONSTRAINT.equalsIgnoreCase(constraintViolation.getConstraintName())) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { + } +} 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 901c23de..4cbc916a 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 @@ -2,13 +2,13 @@ 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.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRunningRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerFailureRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerRunningRequest; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java index 7f10e83d..586ae304 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java @@ -6,7 +6,8 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionService; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionCommandService; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionQueryService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.security.UserDetailsImpl; import io.swagger.v3.oas.annotations.Operation; @@ -32,7 +33,8 @@ @Tag(name = "Question", description = "자소서 문항 선택 및 답변 저장 API") public class QuestionController { - private final QuestionService questionService; + private final QuestionQueryService questionQueryService; + private final QuestionCommandService questionCommandService; @Operation(summary = "문항 후보 목록 조회", description = "문항 선택 화면에서 사용할 기본 문항 후보와 선택 여부를 조회합니다.") @GetMapping("/candidates") @@ -42,7 +44,7 @@ public ApiResponse> getQuestionCandidates( ) { return ApiResponse.onSuccess( "문항 후보 목록 조회에 성공했습니다.", - questionService.getQuestionCandidates(userDetails.getUser(), mockApplyId) + questionQueryService.getQuestionCandidates(userDetails.getUser(), mockApplyId) ); } @@ -55,7 +57,7 @@ public ApiResponse addCustomQuestionCandidate( ) { return ApiResponse.onSuccess( "직접 추가 문항 후보가 생성되었습니다.", - questionService.addCustomQuestionCandidate(userDetails.getUser(), mockApplyId, request) + questionCommandService.addCustomQuestionCandidate(userDetails.getUser(), mockApplyId, request) ); } @@ -67,7 +69,7 @@ public ApiResponse getSelectedQuestions( ) { return ApiResponse.onSuccess( "선택 문항 조회에 성공했습니다.", - questionService.getSelectedQuestions(userDetails.getUser(), mockApplyId) + questionQueryService.getSelectedQuestions(userDetails.getUser(), mockApplyId) ); } @@ -80,7 +82,7 @@ public ApiResponse saveSelectedQuestions( ) { return ApiResponse.onSuccess( "선택 문항이 저장되었습니다.", - questionService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request) + questionCommandService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request) ); } @@ -93,7 +95,7 @@ public ApiResponse saveAnswers( ) { return ApiResponse.onSuccess( "자소서 답변이 저장되었습니다.", - questionService.saveAnswers(userDetails.getUser(), mockApplyId, request) + questionCommandService.saveAnswers(userDetails.getUser(), mockApplyId, request) ); } @@ -106,7 +108,7 @@ public ApiResponse deleteQuestion( ) { return ApiResponse.onSuccess( "선택 문항이 삭제되었습니다.", - questionService.deleteQuestion(userDetails.getUser(), mockApplyId, questionId) + questionCommandService.deleteQuestion(userDetails.getUser(), mockApplyId, questionId) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java index 99ee4266..907547bd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java index 91d7c36e..a28c8a0b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java similarity index 90% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java index 722be6de..e33d0661 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; public record CandidateRecheckResponse( RecheckDecision decision, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java index 8f062d04..71a305e7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java similarity index 85% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java index 15733c57..e00da80c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.criteria; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria; import java.util.List; 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/internal/worker/AnalysisTaskMessage.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisTaskMessage.java index f8910e15..81a34b17 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/internal/worker/AnalysisTaskMessage.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; import lombok.Builder; 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/internal/worker/AnalysisWorkerCompleteRequest.java similarity index 71% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerCompleteRequest.java index 32eb1fd8..856c3bb7 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/internal/worker/AnalysisWorkerCompleteRequest.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; 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/internal/worker/AnalysisWorkerContextRequest.java similarity index 78% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextRequest.java index 0ec7a04b..19526389 100644 --- 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/internal/worker/AnalysisWorkerContextRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; 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/internal/worker/AnalysisWorkerContextResponse.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponse.java index 1c7db5c2..7e507f04 100644 --- 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/internal/worker/AnalysisWorkerContextResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import java.util.List; 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/internal/worker/AnalysisWorkerFailureRequest.java similarity index 61% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java index 1c9d9023..083a1c74 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/internal/worker/AnalysisWorkerFailureRequest.java @@ -1,13 +1,13 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record AnalysisWorkerFailureRequest( @NotBlank String errorMessage, - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, Long queueLatencyMillis diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java index 01e76b3c..6b213bc1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; 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/internal/worker/AnalysisWorkerRetryRequest.java similarity index 61% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java index b8448db9..b0f0312b 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/internal/worker/AnalysisWorkerRetryRequest.java @@ -1,13 +1,13 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record AnalysisWorkerRetryRequest( @NotBlank String errorMessage, - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, Long queueLatencyMillis 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/internal/worker/AnalysisWorkerRunningRequest.java similarity index 79% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRunningRequest.java index 08ecd1fe..52f2ed3d 100644 --- 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/internal/worker/AnalysisWorkerRunningRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java index a4873c3d..5d9ff93a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java index 40dd252f..cb8ec0cc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; public record SimilarJobPostingContext( Long jobPostingId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java index 81b994a2..d27cfb4b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; import com.fasterxml.jackson.annotation.JsonAlias; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -24,6 +26,7 @@ public record QuestionAnswerItem( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit, @NotNull(message = "답변 내용은 필수입니다.") diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java index 9e048475..ef4ecb05 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; @@ -8,6 +10,7 @@ public record QuestionCandidateCreateRequest( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java index c0716342..5b92b85c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; @@ -19,6 +21,7 @@ public record QuestionSelectionItem( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit, Boolean custom 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 55ca67f5..3d3f8865 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 @@ -30,4 +30,30 @@ public record AnalysisAsyncStatusResponse( List steps, AnalysisResponse result ) { + public AnalysisAsyncStatusResponse withResult(AnalysisResponse result) { + return new AnalysisAsyncStatusResponse( + taskId, + mockApplyId, + status, + message, + error, + failureReason, + workerId, + retryCount, + maxRetryCount, + queueLatencyMillis, + createdAt, + submittedAt, + lastAttemptAt, + startedAt, + completedAt, + cancelRequested, + cancelledAt, + currentStep, + progressPercent, + estimatedRemainingSeconds, + steps, + result + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java index 92a9e929..1611cc4e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.response; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; public record QuestionAnalysisResponse( Long questionAnalysisId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java index b9265d68..3d665e3e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java @@ -5,6 +5,7 @@ public record QuestionCandidateResponse( String content, int charLimit, boolean selected, - boolean custom + boolean custom, + String candidateKey ) { } 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 42cb8b5c..248f47dd 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 @@ -1,5 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.entity; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.entity.CreatedAtEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -35,13 +38,16 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(name = "credit_reference_id", length = 100) private String creditReferenceId; + @Column(name = "credit_reference_version", nullable = false) + private int creditReferenceVersion; + @Enumerated(EnumType.STRING) @Column(name = "credit_status", nullable = false, length = 20) - private CreditStatus creditStatus; + private AnalysisAsyncCreditStatus creditStatus; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - private TaskStatus status; + private AnalysisAsyncTaskStatus status; @Column(nullable = false, length = 255) private String message; @@ -51,7 +57,7 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(name = "failure_reason", length = 40) - private FailureReason failureReason; + private AnalysisAsyncFailureReason failureReason; @Column(name = "worker_id", length = 100) private String workerId; @@ -103,8 +109,9 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe task.taskId = UUID.randomUUID().toString(); task.userId = userId; task.mockApplyId = mockApplyId; - task.creditStatus = CreditStatus.NONE; - task.status = TaskStatus.PENDING; + task.creditStatus = AnalysisAsyncCreditStatus.NONE; + task.creditReferenceVersion = 0; + task.status = AnalysisAsyncTaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); @@ -115,24 +122,46 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe return task; } - public void markCreditReserved(String creditReferenceId) { + public boolean markCreditReserved(String creditReferenceId) { + if (!canReserveCredit() || creditReferenceId == null || creditReferenceId.isBlank()) { + return false; + } this.creditReferenceId = creditReferenceId; - this.creditStatus = CreditStatus.RESERVED; + this.creditReferenceVersion += 1; + this.creditStatus = AnalysisAsyncCreditStatus.RESERVED; + return true; } - public void markCreditConfirmed() { - this.creditStatus = CreditStatus.CONFIRMED; + public boolean markCreditConfirmed() { + if (creditStatus != AnalysisAsyncCreditStatus.RESERVED || creditReferenceId == null) { + return false; + } + this.creditStatus = AnalysisAsyncCreditStatus.CONFIRMED; + return true; } - public void markCreditReleased() { - this.creditStatus = CreditStatus.RELEASED; + public boolean markCreditReleased() { + if (creditStatus != AnalysisAsyncCreditStatus.RESERVED || creditReferenceId == null) { + return false; + } + this.creditStatus = AnalysisAsyncCreditStatus.RELEASED; + return true; + } + + public boolean canReserveCredit() { + return creditStatus == AnalysisAsyncCreditStatus.NONE + || creditStatus == AnalysisAsyncCreditStatus.RELEASED; + } + + public int nextCreditReferenceVersion() { + return creditReferenceVersion + 1; } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { if (isTerminal()) { return; } - this.status = TaskStatus.RUNNING; + this.status = AnalysisAsyncTaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; this.currentStep = "PREPARING_CONTEXT"; this.progressPercent = Math.max(resolveProgressPercent(), 10); @@ -148,10 +177,10 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte } public void markSuccess() { - if (isTerminal()) { + if (isTerminal() && !isRecoverablePublishFailure()) { return; } - this.status = TaskStatus.SUCCEEDED; + this.status = AnalysisAsyncTaskStatus.SUCCEEDED; this.message = "자소서 분석이 완료되었습니다."; this.error = null; this.failureReason = null; @@ -161,7 +190,31 @@ public void markSuccess() { this.estimatedRemainingSeconds = 0; } - public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + public boolean isRecoverablePublishFailure() { + return status == AnalysisAsyncTaskStatus.FAILED + && failureReason == AnalysisAsyncFailureReason.PUBLISH_FAILED; + } + + public void reopenForRepublish() { + if (!isRecoverablePublishFailure()) { + return; + } + this.status = AnalysisAsyncTaskStatus.PENDING; + this.message = "자소서 분석 비동기 작업이 다시 접수되었습니다."; + this.error = null; + this.failureReason = null; + this.workerId = null; + this.submittedAt = LocalDateTime.now(); + this.lastAttemptAt = null; + this.queueLatencyMillis = null; + this.startedAt = null; + this.completedAt = null; + this.currentStep = "VALIDATING_INPUT"; + this.progressPercent = 0; + this.estimatedRemainingSeconds = null; + } + + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } @@ -169,7 +222,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, markFailed(failureReason, errorMessage, retryCount); return; } - this.status = TaskStatus.PENDING; + this.status = AnalysisAsyncTaskStatus.PENDING; this.message = "자소서 분석 재시도를 대기 중입니다."; this.currentStep = "VALIDATING_INPUT"; this.progressPercent = 0; @@ -179,11 +232,11 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, this.completedAt = null; } - public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } - this.status = TaskStatus.FAILED; + this.status = AnalysisAsyncTaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; this.failureReason = failureReason; this.error = errorMessage; @@ -194,17 +247,17 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret } public void requestCancel() { - if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + if (status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED) { return; } this.cancelRequested = true; - if (status == TaskStatus.CANCELLED) { + if (status == AnalysisAsyncTaskStatus.CANCELLED) { if (cancelledAt == null) { this.cancelledAt = LocalDateTime.now(); } return; } - this.status = TaskStatus.CANCELLED; + this.status = AnalysisAsyncTaskStatus.CANCELLED; this.message = "자소서 분석 작업이 취소되었습니다."; this.error = null; this.failureReason = null; @@ -232,33 +285,13 @@ public void captureExecutionSnapshot(String executionContextSnapshot, String inp } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + return status == AnalysisAsyncTaskStatus.SUCCEEDED + || status == AnalysisAsyncTaskStatus.FAILED + || status == AnalysisAsyncTaskStatus.CANCELLED; } private int resolveProgressPercent() { return progressPercent == null ? 0 : progressPercent; } - public enum TaskStatus { - PENDING, - RUNNING, - SUCCEEDED, - FAILED, - CANCELLED - } - - public enum CreditStatus { - NONE, - RESERVED, - 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/entity/QuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java index 1e5fc38c..2b7a02d6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.entity; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java new file mode 100644 index 00000000..49ba7b6f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java @@ -0,0 +1,70 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.ai; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisResponseParser; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import com.openai.client.OpenAIClient; +import com.openai.core.RequestOptions; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class OpenAiAnalysisAdapter { + private final OpenAIClient openAIClient; + private final LlmConcurrencyLimiter llmConcurrencyLimiter; + private final AsyncMetricsRecorder asyncMetricsRecorder; + private final AnalysisResponseParser analysisResponseParser; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel; + + public T createStructuredResponse(String operationName, String prompt, Class responseType) { + return createStructuredResponse(operationName, prompt, responseType, null); + } + + public T createStructuredResponse( + String operationName, + String prompt, + Class responseType, + Duration timeout + ) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(prompt) + .temperature(0.2) + .text(responseType) + .build(); + RequestOptions requestOptions = timeout == null + ? null + : RequestOptions.builder().timeout(timeout).build(); + long startedAt = System.nanoTime(); + boolean success = false; + try { + StructuredResponse response = llmConcurrencyLimiter.execute( + operationName, + () -> requestOptions == null + ? openAIClient.responses().create(params) + : openAIClient.responses().create(params, requestOptions) + ); + T structuredContent = analysisResponseParser.extractStructuredContent(response); + success = true; + return structuredContent; + } finally { + asyncMetricsRecorder.recordLlmRequest( + operationName, + success ? "success" : "error", + elapsedMillis(startedAt) + ); + } + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java new file mode 100644 index 00000000..3cc9377d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisTaskMessagePublisher; + +// 분석 비동기 작업을 MQ 워커 메시지로 변환해 발행한다. +public class AnalysisAsyncQueueProcessor { + private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; + + public AnalysisAsyncQueueProcessor(AnalysisTaskMessagePublisher analysisTaskMessagePublisher) { + this.analysisTaskMessagePublisher = analysisTaskMessagePublisher; + } + + public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { + analysisTaskMessagePublisher.publish( + AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java new file mode 100644 index 00000000..90b0f9df --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -0,0 +1,156 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +// timeout/retry 기준으로 만료된 분석 async task를 정리한다. +public class AnalysisAsyncTaskSweepCoordinator { + private static final int SWEEP_BATCH_SIZE = 100; + + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + private final TransactionTemplate transactionTemplate; + private final AnalysisQueueProperties analysisQueueProperties; + private final Clock clock; + + public AnalysisAsyncTaskSweepCoordinator( + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, + TransactionTemplate transactionTemplate, + AnalysisQueueProperties analysisQueueProperties, + Clock clock + ) { + this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisAsyncCreditCoordinator = analysisAsyncCreditCoordinator; + this.transactionTemplate = transactionTemplate; + this.analysisQueueProperties = analysisQueueProperties; + this.clock = clock; + } + + public int sweepTimedOutTasks() { + LocalDateTime now = LocalDateTime.now(clock); + int expiredCount = sweepTimedOutPendingTasks(now); + expiredCount += sweepTimedOutRunningTasks(now); + return expiredCount; + } + + private int sweepTimedOutPendingTasks(LocalDateTime now) { + LocalDateTime deadline = now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds()); + return sweepTimedOutTaskIds( + () -> analysisAsyncTaskRepository.findTimedOutPendingTaskIds( + deadline, + PageRequest.of(0, SWEEP_BATCH_SIZE) + ) + ); + } + + private int sweepTimedOutRunningTasks(LocalDateTime now) { + LocalDateTime deadline = now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds()); + return sweepTimedOutTaskIds( + () -> analysisAsyncTaskRepository.findTimedOutRunningTaskIds( + deadline, + PageRequest.of(0, SWEEP_BATCH_SIZE) + ) + ); + } + + private int sweepTimedOutTaskIds(TaskIdBatchLoader taskIdBatchLoader) { + int expiredCount = 0; + while (true) { + List taskIds = taskIdBatchLoader.load(); + if (taskIds.isEmpty()) { + return expiredCount; + } + for (String taskId : taskIds) { + expiredCount += sweepTimedOutTask(taskId); + } + if (taskIds.size() < SWEEP_BATCH_SIZE) { + return expiredCount; + } + } + } + + private int sweepTimedOutTask(String taskId) { + try { + return transactionTemplate.execute(status -> sweepTimedOutTaskInTransaction(taskId)); + } catch (RuntimeException e) { + log.error("Analysis async task sweep failed for taskId={}", taskId, e); + return 0; + } + } + + private int sweepTimedOutTaskInTransaction(String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findByIdForUpdate(taskId).orElse(null); + if (task == null || task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED || task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { + return 0; + } + + ExpirationDecision expirationDecision = resolveExpiration(task); + if (expirationDecision == null) { + return 0; + } + + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + analysisAsyncTaskService.markFailed( + task.getTaskId(), + expirationDecision.failureReason(), + expirationDecision.errorMessage(), + task.getRetryCount() + ); + return 1; + } + + private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { + LocalDateTime now = LocalDateTime.now(clock); + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING + && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { + return new ExpirationDecision( + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, + "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." + ); + } + + LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); + if (task.getStatus() == AnalysisAsyncTaskStatus.RUNNING + && isExpired(lastActivityAt, now, analysisQueueProperties.getProcessingTimeoutSeconds())) { + return new ExpirationDecision( + AnalysisAsyncFailureReason.INTERNAL_ERROR, + "자소서 분석 작업이 처리 제한 시간을 초과했습니다." + ); + } + + return null; + } + + private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutSeconds) { + if (baseTime == null || timeoutSeconds <= 0) { + return false; + } + return Duration.between(baseTime, now).getSeconds() >= timeoutSeconds; + } + + private record ExpirationDecision(AnalysisAsyncFailureReason failureReason, String errorMessage) { + } + + @FunctionalInterface + private interface TaskIdBatchLoader { + List load(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java new file mode 100644 index 00000000..081e3ee7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -0,0 +1,436 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; +import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; +import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.logging.LoggingContext; +import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +// 외부 분석 워커와 내부 분석 도메인 상태를 연결한다. +public class AnalysisAsyncWorkerBridge { + private static final ConcurrentMap CONTEXT_LOCKS = new ConcurrentHashMap<>(); + + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisService analysisService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + private final UserService userService; + private final WorkerTaskResultService workerTaskResultService; + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + public AnalysisAsyncWorkerBridge( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisService analysisService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, + UserService userService, + WorkerTaskResultService workerTaskResultService, + AnalysisInputFingerprintProvider analysisInputFingerprintProvider, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate + ) { + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; + this.analysisService = analysisService; + this.analysisAsyncCreditCoordinator = analysisAsyncCreditCoordinator; + this.userService = userService; + this.workerTaskResultService = workerTaskResultService; + this.analysisInputFingerprintProvider = analysisInputFingerprintProvider; + this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; + } + + @Transactional + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (isTerminal(task)) { + return; + } + analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); + try (var ignored = LoggingContext.with("worker.task.running", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, null))) { + log.info("Analysis worker marked task as running"); + } + } + + @Transactional + public void markRetry( + String taskId, + AnalysisAsyncFailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (isTerminal(task)) { + return; + } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); + analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + try (var ignored = LoggingContext.with("worker.task.retry", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { + log.warn("Analysis worker scheduled retry: failureReason={}", failureReason); + } + } + + @Transactional + public void failTask( + String taskId, + AnalysisAsyncFailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (isTerminal(task)) { + return; + } + + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); + try (var ignored = LoggingContext.with("worker.task.failed", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { + log.warn("Analysis worker failed task: failureReason={}", failureReason); + } + } + + public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { + ReentrantLock contextLock = CONTEXT_LOCKS.computeIfAbsent(taskId, ignored -> new ReentrantLock()); + contextLock.lock(); + try { + ContextAccess contextAccess = transactionTemplate.execute( + status -> loadContextAccess(taskId, userId, mockApplyId) + ); + if (contextAccess == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트를 초기화할 수 없습니다. taskId=" + taskId + ); + } + if (contextAccess.snapshot() != null) { + return contextAccess.snapshot(); + } + + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); + AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); + String contextSnapshot = writeContextSnapshot(context); + String inputFingerprint = analysisInputFingerprintProvider.create(payload); + return transactionTemplate.execute( + status -> persistContextSnapshot(taskId, userId, mockApplyId, context, contextSnapshot, inputFingerprint) + ); + } catch (RuntimeException exception) { + releaseCreditAfterContextFailure(taskId); + throw exception; + } finally { + contextLock.unlock(); + if (!contextLock.hasQueuedThreads()) { + CONTEXT_LOCKS.remove(taskId, contextLock); + } + } + } + + @Transactional + public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "취소된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + workerTaskResultService.upsertGenerated( + TaskType.ANALYSIS_COMPLETE, + taskId, + new AnalysisWorkerResultStoreRequest(request.userId(), request.mockApplyId(), request.llmResponse()) + ); + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { + workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); + return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); + } + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { + if (!task.isRecoverablePublishFailure()) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "이미 실패 처리된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + } + + User user = userService.getUser(request.userId()); + AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( + user, + request.mockApplyId(), + contextSnapshot.similarJobPostings() + ).withAnswerSnapshots(contextSnapshot.questions().stream() + .filter(question -> question.answer() != null && !question.answer().isBlank()) + .map(question -> new AnalysisExecutionPayload.AnswerSnapshot( + question.questionId(), + question.answer() + )) + .toList()); + AnalysisLlmResponse llmResponse = request.llmResponse(); + AnalysisResponse response = analysisService.finalizeAnalysis( + user, + request.mockApplyId(), + payload, + llmResponse, + task.getInputFingerprintSnapshot() + ); + analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); + confirmCreditIfNeeded(task); + analysisAsyncTaskService.markSuccess(taskId, response); + workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); + try (var ignored = LoggingContext.with( + "worker.task.completed", + null, + workerContext(taskId, "ANALYSIS", request.workerId(), task.getRetryCount(), request.queueLatencyMillis()) + )) { + log.info("Analysis worker completed task"); + } + return response; + } + + @Transactional + public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { + AnalysisAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 결과 저장 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + workerTaskResultService.upsertGenerated(TaskType.ANALYSIS_COMPLETE, taskId, request); + try (var ignored = LoggingContext.with("worker.result.stored", null, workerContext(taskId, "ANALYSIS", null, task.getRetryCount(), null))) { + log.info("Analysis worker result stored"); + } + } + + @Transactional(readOnly = true) + public WorkerTaskResultResponse getStoredResult(String taskId) { + getTask(taskId); + return workerTaskResultService.get(taskId); + } + + private List toQuestionItems(List questions) { + return questions.stream() + .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( + question.getId(), + question.getContent(), + question.getAnswer(), + question.getLimit() + )) + .toList(); + } + + private ContextAccess loadContextAccess(String taskId, Long userId, Long mockApplyId) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + validateContextRequest(task, userId, mockApplyId); + if (task.getExecutionContextSnapshot() != null) { + return new ContextAccess(readContextSnapshot(task)); + } + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + return new ContextAccess(null); + } + + private AnalysisWorkerContextResponse persistContextSnapshot( + String taskId, + Long userId, + Long mockApplyId, + AnalysisWorkerContextResponse context, + String contextSnapshot, + String inputFingerprint + ) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + validateContextRequest(task, userId, mockApplyId); + if (task.getExecutionContextSnapshot() != null) { + return readContextSnapshot(task); + } + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + task.captureExecutionSnapshot(contextSnapshot, inputFingerprint); + return context; + } + + private void validateContextRequest(AnalysisAsyncTask task, Long userId, Long mockApplyId) { + rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + task.getTaskId()); + if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + } + + private AnalysisWorkerContextResponse buildContext( + Long userId, + Long mockApplyId, + AnalysisExecutionPayload payload + ) { + 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()), + CorpusReferenceContext.from(payload.retrievalContext()), + payload.similarJobPostings() + ); + } + + private String writeContextSnapshot(AnalysisWorkerContextResponse context) { + try { + return objectMapper.writeValueAsString(context); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다.", + exception + ); + } + } + + private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) { + if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId() + ); + } + try { + return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId(), + exception + ); + } + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + + private AnalysisAsyncTask getTaskForUpdate(String taskId) { + return analysisAsyncTaskRepository.findByIdForUpdate(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + + private boolean isTerminal(AnalysisAsyncTask task) { + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; + } + + private void rejectIfCancelled(AnalysisAsyncTask task, String message) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED || task.isCancelRequested()) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + } + + private void confirmCreditIfNeeded(AnalysisAsyncTask task) { + analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); + } + + private void releaseCreditAfterContextFailure(String taskId) { + transactionTemplate.execute(status -> { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (task.getExecutionContextSnapshot() != null) { + return null; + } + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + return null; + }); + } + + private Map workerContext( + String taskId, + String taskType, + String workerId, + Integer retryCount, + Long queueLatencyMillis + ) { + Map context = new LinkedHashMap<>(); + context.put(LoggingMdcKeys.TASK_ID, taskId); + context.put(LoggingMdcKeys.TASK_TYPE, taskType); + if (workerId != null) { + context.put(LoggingMdcKeys.WORKER_ID, workerId); + } + if (retryCount != null) { + context.put(LoggingMdcKeys.RETRY_COUNT, String.valueOf(retryCount)); + } + if (queueLatencyMillis != null) { + context.put(LoggingMdcKeys.QUEUE_LATENCY_MILLIS, String.valueOf(queueLatencyMillis)); + } + return context; + } + + private record ContextAccess(AnalysisWorkerContextResponse snapshot) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java new file mode 100644 index 00000000..610f1c3f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java @@ -0,0 +1,239 @@ +package com.jobdri.jobdri_api.domain.analysis.policy; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisImprovementRules; + +public final class AnalysisPromptPolicy { + private AnalysisPromptPolicy() { + } + + public static final String OUTPUT_SCHEMA = """ + [출력 규칙] + - Structured Output 스키마에 맞는 JSON object만 반환한다. + - jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - feedback: 한 줄 피드백 + - keyStrengths: 없으면 [] + - keyWeaknesses: 없으면 [] + - missingKeywords: 없으면 [] + - questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 + - improvement: 안전한 개선문을 만들 수 없으면 null + - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. + - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. + - Structured Output은 JSON 형식과 타입만 보장한다. + - sentence 원문 존재 여부, status/reason 정합성, improvement 사실 생성 여부, missingKeyword JD 근거 여부는 서버가 다시 검증한다. + """; + + public static final String EVALUATION_CRITERIA = """ + [평가 절차] + 최종 JSON을 작성하기 전에 내부적으로 다음 순서를 따른다. + 내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다. + 별도의 reasoning 필드나 analysis 필드를 API 응답에 추가하지 않는다. + 모델의 상세 사고과정을 로그나 DB에 저장하지 않는다. + 1. 문장 유형을 경험/성과, 포부/계획, 지원동기, 역량/자격으로 판단한다. + 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. + 3. preference만 근거인 경우 첨삭 대상에서 제외한다. + 4. 문장 유형에 맞는 평가 기준만 적용한다. + 5. 문항을 대표해 평가할 문장인지 판단한다. + 6. proven, mentioned 또는 fabricated 상태를 결정한다. + 7. reason이 status 및 문장 유형과 일치하는지 확인한다. + 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. + 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. + 10. 최종 JSON만 반환한다. + + [문장 유형 구분] + - 경험/성과: 과거에 수행한 행동, 역할, 문제 해결, 결과, 수치, 산출물이 드러나는 문장 + - 포부/계획: 입사 후 하겠다, 기여하겠다, 성장하겠다처럼 미래 실행 의지를 말하는 문장 + - 지원동기: 회사/직무를 선택한 이유, 관심, 가치관, 동기를 설명하는 문장 + - 역량/자격: 보유 기술, 자격, 면허, 전공, 경력, 교육 이수 여부를 설명하는 문장 + + [포부/계획 문장 평가 규칙] + - "~하겠습니다", "~되겠습니다", "~기여하겠습니다", "~노력하겠습니다", "~성장하고 싶습니다" 등 미래 시점 문장은 포부/계획으로 우선 판단한다. + - 포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다. + - 포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다. + - 포부/계획 문장을 "성과 수치가 부족하다"는 이유로 questionAnalyses에 포함하지 않는다. + - 포부/계획 문장의 reason에는 "성과 수치가 부족", "정량적 결과가 부족", "Before-After가 부족", "과거 성과가 드러나지 않음"을 사용하지 않는다. + - 포부가 너무 추상적일 때만 실행 대상, 방법, 단계, 직무 연결성을 보완하도록 reason을 작성한다. + + [문장 유형별 평가 기준] + - 경험/성과: 과거 수행 행동, 역할, 문제, 결과를 평가한다. 모든 경험에 반드시 수치를 요구하지 않는다. + - 경험/성과: 과정, 역할, 산출물, 검증 결과 중 하나 이상이 충분히 구체적이면 인정한다. + - 경험/성과: 이미 수치나 명확한 결과가 있으면 "성과 수치 부족" reason을 생성하지 않는다. + - 포부/계획: 미래 시점 문장에는 과거 성과 수치나 Before-After를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 평가한다. + - 지원동기: 회사 또는 직무를 선택한 이유가 개인 경험·관심과 연결되는지 평가하며, 지원동기를 성과 문장처럼 평가하지 않는다. + - 지원동기: 해당 회사만의 이유가 없는 일반론인지 확인한다. + - 역량/자격: 보유 기술이나 역량의 실제 사용 맥락을 평가한다. + - 역량/자격: 자격증이나 전공 자체를 문장 첨삭의 핵심 문제로 삼지 않는다. + - 역량/자격: 해당 기술을 실제로 어떻게 사용했는지가 있으면 그 근거를 평가한다. + + [JD 반영 우선순위] + - 판단 우선순위는 mainTask > qualification >>> preference다. + - mainTask와 qualification은 직무 적합성 판단의 핵심 기준이다. + - preference는 reason과 점수에 보조적으로만 반영한다. + - preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다. + - preference만 근거로 jobFit을 크게 감점하지 않는다. + + [점수 산정 규칙] + - Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다. + - 점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다. + - 예시의 수치나 특정 고정 점수를 추론해서 사용하지 않는다. + - 서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다. + + [jobFit 평가 기준] + JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. + 체크 항목: + - 필수 자격요건 매칭 + - 우대사항 반영 + - 주요 업무 연관성 + - 직무 키워드 활용 + - 암묵적 직무 역량 충족 + 점수 구간: + - 85~100: 필수 자격요건 대부분 proven, 직무 키워드가 풍부하고 업무 경험과 JD가 직접 연결됨 + - 70~84: 주요 자격요건이 증명되고 일부 우대사항도 반영됨 + - 55~69: 자격요건 일부만 증명되고 JD와 간접적으로 연결됨 + - 40~54: 자격요건 증명이 거의 없고 직무 관련성이 낮음 + - 40 미만: JD와 자기소개서가 거의 무관함 + + [impact 평가 기준] + 주장을 뒷받침하는 근거가 얼마나 구체적이고 설득력 있는지 평가한다. + 체크 항목: + - 정량적 성과 + - STAR 구조 활용 + - 주장과 근거의 연결 + - Before-After 비교 + - 차별적 경험 + 점수 구간: + - 85~100: 주요 주장에 정량 성과 또는 구체적 에피소드가 있고 STAR 구조가 명확함 + - 70~84: 핵심 주장 대부분에 근거가 있고 일부 수치와 STAR 구조가 존재함 + - 55~69: 경험은 있으나 근거가 모호하고 수치가 거의 없음 + - 40~54: 대부분 근거 없는 주장과 추상 표현으로 구성됨 + - 40 미만: 구체적 근거가 전혀 없음 + 감점 금지: + - 문제 상황 설명에 수치가 없다는 이유만으로 감점하지 않는다. + - 과정이나 학습 경험에 정량 지표가 없다는 이유만으로 감점하지 않는다. + - STAR 구조를 형식적으로 완벽하게 따르지 않는다는 이유만으로 감점하지 않는다. + 단, 전체 맥락과 행동, 결과는 이해 가능해야 한다. + + [completeness 평가 기준] + 질문 적합성, 논리 흐름, 문장 표현 품질을 종합 평가한다. + 체크 항목: + - 질문 적합성 + - 문단 구조와 흐름 + - 논리적 일관성 + - 문장 가독성 + - 설득력 있는 마무리 + 점수 구간: + - 85~100: 모든 문항에 정확히 답하고 논리 흐름과 마무리가 매우 자연스러움 + - 70~84: 대부분 적절하게 답하고 전반적으로 읽기 좋음 + - 55~69: 일부 동문서답, 논리 비약, 반복 표현 또는 마무리 부족이 있음 + - 40~54: 질문과 답변 불일치가 많고 구조와 논리 문제가 큼 + - 40 미만: 대부분 질문 의도와 무관하거나 미완성임 + """; + + public static final String STATUS_AND_WRITING_RULES = """ + [status 판정 기준] + - questionAnalyses의 허용 status는 proven, mentioned, fabricated다. + - proven: JD와 관련된 구체적인 행동, 근거 또는 결과가 충분히 드러난 문장 + - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 + - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 + - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 + + [status 중요 규칙] + - 충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다. + - proven 문장의 improvement는 null로 반환한다. + - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. + - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. + - missing은 관련 언급이 전혀 없을 때만 사용한다. + - fabricated는 단순히 근거가 부족하다는 이유로 사용하지 않는다. + - fabricated는 명시적 사실과 직접 충돌하거나 지원자가 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성을 만들기 위해 억지로 fabricated를 생성하지 않는다. + - 구체적인 경험이나 수치가 부족하다는 이유만으로 fabricated를 사용하지 않는다. + + [sentence 규칙] + - questionAnalyses의 sentence는 반드시 해당 questionId의 answer에 실제 포함된 정확한 substring이어야 한다. + - 원문에 없는 문장을 생성하지 않는다. + - sentence를 요약하거나 수정하지 않는다. + - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. + - answer가 비어 있지 않은 모든 문항은 가장 평가 가치가 큰 실제 문장을 최소 1개 반환한다. + - questionAnalyses는 비어 있지 않은 문항마다 1~3개 반환한다. + - 구체적인 강점 문장은 proven, 보완이 필요한 문장은 mentioned 또는 fabricated로 반환한다. + - 실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. + - 동일한 문제를 반복하는 문장은 하나만 선택한다. + - answer가 비어 있는 문항만 분석을 반환하지 않는다. + - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. + - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. + - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. + - missing은 questionAnalyses에 억지로 넣지 않는다. + + [소제목 처리 규칙] + - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. + - 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다. + - 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다. + - 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다. + - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다. + - 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다. + + [missingKeywords 규칙] + - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. + - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. + - JD 원문에 없는 SQL, Python, AWS, 대용량 트래픽 같은 키워드를 생성하지 않는다. + - 자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부, 나이, 국적, 졸업 여부 같은 정형 자격요건은 missingKeywords에 넣지 않는다. + - preference에만 존재하는 항목은 원칙적으로 missingKeywords에서 제외한다. + - mainTask와 qualification에서 근거를 찾지 못한 경우 preference만으로 문장을 문제 삼지 않는다. + - reason에서 preference를 핵심 결격 사유처럼 표현하지 않는다. + - preference가 없다는 이유만으로 mentioned를 생성하지 않는다. + - questionAnalyses와 분리해서 missingKeywords에만 넣는다. + - 최대 3개만 반환한다. + - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + - 우선순위는 주요 업무(mainTask) > 자격요건(qualification) >>> 우대사항(preference)다. + - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. + - 반드시 실제 입력 JD에 들어간 표현을 유지한다. + - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. + - source는 qualification, preference, mainTask 중 하나만 사용한다. + + [핵심 강점/약점 작성 규칙] + - keyStrengths와 keyWeaknesses는 각각 최대 3개만 반환한다. + - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. + - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. + - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. + - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. + - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. + - proven 문장은 keyStrengths의 quote와 questionAnalyses의 sentence에 함께 사용할 수 있다. + - mentioned 또는 fabricated 문장은 keyStrengths와 중복하지 않는다. + - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. + - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. + - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. + - quote는 너무 길게 붙이지 말고 사용자가 근거를 확인할 수 있는 핵심 구절만 사용한다. + - 적절한 강점이나 약점이 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + + [reason 작성 규칙] + - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. + - 가능하면 JD의 어떤 업무, 자격요건, 우대사항과 관련된 문제인지 설명한다. + - 1~2문장으로 간결하게 작성한다. + - 사용자의 경험을 부정하거나 비난하지 않는다. + - "잘못되었다"보다 "근거가 부족하다", "결과가 드러나지 않는다"처럼 진단형 표현을 사용한다. + - 단순 문법 교정보다 직무 적합성, 구체성, 논리 완성도 관점의 이유를 우선한다. + + [improvement 작성 규칙] + - improvement는 첨삭 조언이 아니라 사용자가 sentence를 대체해 사용할 수 있는 완성된 한국어 문장이어야 한다. + - improvement는 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 하며 첨삭 행위를 설명하는 메타 문장이 아니어야 한다. + - 개선이 필요하지 않으면 improvement는 null로 반환한다. + - 원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다. + - 반드시 한국어 평서문으로 작성한다. + - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. + - "구체적으로 작성했습니다", "명확히 설명했습니다" 같은 메타 조언을 improvement로 반환하지 않는다. + - 원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다. + - 답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다. + - JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다. + - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. + - 원문이 과거 경험이면 개선문도 과거 경험을 유지한다. + - 원문이 포부이면 개선문도 포부를 유지한다. + - 새로운 경험이나 계획을 추가하지 않는다. + - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. + - 원래 경험과 맥락을 최대한 유지한다. + - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. + - 너무 길거나 과도하게 화려한 문장으로 만들지 않는다. + - 금지 표현: %s + """.formatted(AnalysisImprovementRules.bannedPhrasesText()); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index 4a849e68..bd407c6a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -1,8 +1,14 @@ package com.jobdri.jobdri_api.domain.analysis.repository; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -14,14 +20,45 @@ public interface AnalysisAsyncTaskRepository extends JpaRepository findFirstByUserIdAndMockApplyIdAndStatusInOrderByCreatedAtDesc( Long userId, Long mockApplyId, - Collection statuses + Collection statuses + ); + + Optional findFirstByUserIdAndMockApplyIdAndStatusAndFailureReasonOrderByCreatedAtDesc( + Long userId, + Long mockApplyId, + AnalysisAsyncTaskStatus status, + AnalysisAsyncFailureReason failureReason ); List findByUserIdAndMockApplyIdInAndStatusIn( Long userId, Collection mockApplyIds, - Collection statuses + Collection statuses ); - List findByStatusIn(Collection statuses); + @Query(""" + select task.taskId + from AnalysisAsyncTask task + where task.status = 'PENDING' + and task.submittedAt is not null + and task.submittedAt <= :deadline + order by task.submittedAt asc + """) + List findTimedOutPendingTaskIds(@Param("deadline") java.time.LocalDateTime deadline, Pageable pageable); + + @Query(""" + select task.taskId + from AnalysisAsyncTask task + where task.status = 'RUNNING' + and ( + (task.lastAttemptAt is not null and task.lastAttemptAt <= :deadline) + or (task.lastAttemptAt is null and task.startedAt is not null and task.startedAt <= :deadline) + ) + order by coalesce(task.lastAttemptAt, task.startedAt) asc + """) + List findTimedOutRunningTaskIds(@Param("deadline") java.time.LocalDateTime deadline, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select task from AnalysisAsyncTask task where task.taskId = :taskId") + Optional findByIdForUpdate(@Param("taskId") String taskId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index dd248b9b..56e72fbb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -1,37 +1,24 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse.RecheckDecision; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse.RecheckDecision; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse.RejectionCode; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; -import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import com.openai.client.OpenAIClient; -import com.openai.models.responses.ResponseCreateParams; -import com.openai.models.responses.StructuredResponse; -import com.openai.models.responses.StructuredResponseOutputMessage; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -39,6 +26,8 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.HashMap; @@ -48,258 +37,21 @@ import java.util.ArrayList; import java.util.Optional; import java.util.stream.Collectors; -import java.util.concurrent.TimeUnit; @Component @Slf4j @RequiredArgsConstructor // 자소서 분석에 필요한 프롬프트를 만들고 LLM 호출을 수행하는 클라이언트다. public class AnalysisAiClient { - private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; - private static final int MAX_REFERENCE_FIELD_LENGTH = 300; - private static final int MAX_CRITERIA_ITEMS = 5; private static final int MAX_CANDIDATES_PER_QUESTION = 3; private static final int RECHECK_MIN_PROBLEM_CLARITY = 4; private static final int RECHECK_MIN_JOB_RELEVANCE = 4; private static final int RECHECK_MIN_IMPROVEMENT_USEFULNESS = 4; private static final int RECHECK_MIN_FABRICATION_CONFIDENCE = 4; - private static final String OUTPUT_SCHEMA = """ - [출력 규칙] - - Structured Output 스키마에 맞는 JSON object만 반환한다. - - jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - feedback: 한 줄 피드백 - - keyStrengths: 없으면 [] - - keyWeaknesses: 없으면 [] - - missingKeywords: 없으면 [] - - questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 - - improvement: 안전한 개선문을 만들 수 없으면 null - - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. - - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. - - Structured Output은 JSON 형식과 타입만 보장한다. - - sentence 원문 존재 여부, status/reason 정합성, improvement 사실 생성 여부, missingKeyword JD 근거 여부는 서버가 다시 검증한다. - """; - private static final String EVALUATION_CRITERIA = """ - [평가 절차] - 최종 JSON을 작성하기 전에 내부적으로 다음 순서를 따른다. - 내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다. - 별도의 reasoning 필드나 analysis 필드를 API 응답에 추가하지 않는다. - 모델의 상세 사고과정을 로그나 DB에 저장하지 않는다. - 1. 문장 유형을 경험/성과, 포부/계획, 지원동기, 역량/자격으로 판단한다. - 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. - 3. preference만 근거인 경우 첨삭 대상에서 제외한다. - 4. 문장 유형에 맞는 평가 기준만 적용한다. - 5. 문항을 대표해 평가할 문장인지 판단한다. - 6. proven, mentioned 또는 fabricated 상태를 결정한다. - 7. reason이 status 및 문장 유형과 일치하는지 확인한다. - 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. - 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. - 10. 최종 JSON만 반환한다. - - [문장 유형 구분] - - 경험/성과: 과거에 수행한 행동, 역할, 문제 해결, 결과, 수치, 산출물이 드러나는 문장 - - 포부/계획: 입사 후 하겠다, 기여하겠다, 성장하겠다처럼 미래 실행 의지를 말하는 문장 - - 지원동기: 회사/직무를 선택한 이유, 관심, 가치관, 동기를 설명하는 문장 - - 역량/자격: 보유 기술, 자격, 면허, 전공, 경력, 교육 이수 여부를 설명하는 문장 - - [포부/계획 문장 평가 규칙] - - "~하겠습니다", "~되겠습니다", "~기여하겠습니다", "~노력하겠습니다", "~성장하고 싶습니다" 등 미래 시점 문장은 포부/계획으로 우선 판단한다. - - 포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다. - - 포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다. - - 포부/계획 문장을 "성과 수치가 부족하다"는 이유로 questionAnalyses에 포함하지 않는다. - - 포부/계획 문장의 reason에는 "성과 수치가 부족", "정량적 결과가 부족", "Before-After가 부족", "과거 성과가 드러나지 않음"을 사용하지 않는다. - - 포부가 너무 추상적일 때만 실행 대상, 방법, 단계, 직무 연결성을 보완하도록 reason을 작성한다. - - [문장 유형별 평가 기준] - - 경험/성과: 과거 수행 행동, 역할, 문제, 결과를 평가한다. 모든 경험에 반드시 수치를 요구하지 않는다. - - 경험/성과: 과정, 역할, 산출물, 검증 결과 중 하나 이상이 충분히 구체적이면 인정한다. - - 경험/성과: 이미 수치나 명확한 결과가 있으면 "성과 수치 부족" reason을 생성하지 않는다. - - 포부/계획: 미래 시점 문장에는 과거 성과 수치나 Before-After를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 평가한다. - - 지원동기: 회사 또는 직무를 선택한 이유가 개인 경험·관심과 연결되는지 평가하며, 지원동기를 성과 문장처럼 평가하지 않는다. - - 지원동기: 해당 회사만의 이유가 없는 일반론인지 확인한다. - - 역량/자격: 보유 기술이나 역량의 실제 사용 맥락을 평가한다. - - 역량/자격: 자격증이나 전공 자체를 문장 첨삭의 핵심 문제로 삼지 않는다. - - 역량/자격: 해당 기술을 실제로 어떻게 사용했는지가 있으면 그 근거를 평가한다. - - [JD 반영 우선순위] - - 판단 우선순위는 mainTask > qualification >>> preference다. - - mainTask와 qualification은 직무 적합성 판단의 핵심 기준이다. - - preference는 reason과 점수에 보조적으로만 반영한다. - - preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다. - - preference만 근거로 jobFit을 크게 감점하지 않는다. - - [점수 산정 규칙] - - Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다. - - 점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다. - - 예시의 수치나 특정 고정 점수를 추론해서 사용하지 않는다. - - 서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다. - - [jobFit 평가 기준] - JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. - 체크 항목: - - 필수 자격요건 매칭 - - 우대사항 반영 - - 주요 업무 연관성 - - 직무 키워드 활용 - - 암묵적 직무 역량 충족 - 점수 구간: - - 85~100: 필수 자격요건 대부분 proven, 직무 키워드가 풍부하고 업무 경험과 JD가 직접 연결됨 - - 70~84: 주요 자격요건이 증명되고 일부 우대사항도 반영됨 - - 55~69: 자격요건 일부만 증명되고 JD와 간접적으로 연결됨 - - 40~54: 자격요건 증명이 거의 없고 직무 관련성이 낮음 - - 40 미만: JD와 자기소개서가 거의 무관함 - - [impact 평가 기준] - 주장을 뒷받침하는 근거가 얼마나 구체적이고 설득력 있는지 평가한다. - 체크 항목: - - 정량적 성과 - - STAR 구조 활용 - - 주장과 근거의 연결 - - Before-After 비교 - - 차별적 경험 - 점수 구간: - - 85~100: 주요 주장에 정량 성과 또는 구체적 에피소드가 있고 STAR 구조가 명확함 - - 70~84: 핵심 주장 대부분에 근거가 있고 일부 수치와 STAR 구조가 존재함 - - 55~69: 경험은 있으나 근거가 모호하고 수치가 거의 없음 - - 40~54: 대부분 근거 없는 주장과 추상 표현으로 구성됨 - - 40 미만: 구체적 근거가 전혀 없음 - 감점 금지: - - 문제 상황 설명에 수치가 없다는 이유만으로 감점하지 않는다. - - 과정이나 학습 경험에 정량 지표가 없다는 이유만으로 감점하지 않는다. - - STAR 구조를 형식적으로 완벽하게 따르지 않는다는 이유만으로 감점하지 않는다. - 단, 전체 맥락과 행동, 결과는 이해 가능해야 한다. - - [completeness 평가 기준] - 질문 적합성, 논리 흐름, 문장 표현 품질을 종합 평가한다. - 체크 항목: - - 질문 적합성 - - 문단 구조와 흐름 - - 논리적 일관성 - - 문장 가독성 - - 설득력 있는 마무리 - 점수 구간: - - 85~100: 모든 문항에 정확히 답하고 논리 흐름과 마무리가 매우 자연스러움 - - 70~84: 대부분 적절하게 답하고 전반적으로 읽기 좋음 - - 55~69: 일부 동문서답, 논리 비약, 반복 표현 또는 마무리 부족이 있음 - - 40~54: 질문과 답변 불일치가 많고 구조와 논리 문제가 큼 - - 40 미만: 대부분 질문 의도와 무관하거나 미완성임 - """; - private static final String STATUS_AND_WRITING_RULES = """ - [status 판정 기준] - - questionAnalyses의 허용 status는 proven, mentioned, fabricated다. - - proven: JD와 관련된 구체적인 행동, 근거 또는 결과가 충분히 드러난 문장 - - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 - - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 - - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 - - [status 중요 규칙] - - 충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다. - - proven 문장의 improvement는 null로 반환한다. - - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. - - missing은 관련 언급이 전혀 없을 때만 사용한다. - - fabricated는 단순히 근거가 부족하다는 이유로 사용하지 않는다. - - fabricated는 명시적 사실과 직접 충돌하거나 지원자가 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. - - status 다양성을 만들기 위해 억지로 fabricated를 생성하지 않는다. - - 구체적인 경험이나 수치가 부족하다는 이유만으로 fabricated를 사용하지 않는다. - - [sentence 규칙] - - questionAnalyses의 sentence는 반드시 해당 questionId의 answer에 실제 포함된 정확한 substring이어야 한다. - - 원문에 없는 문장을 생성하지 않는다. - - sentence를 요약하거나 수정하지 않는다. - - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. - - answer가 비어 있지 않은 모든 문항은 가장 평가 가치가 큰 실제 문장을 최소 1개 반환한다. - - questionAnalyses는 비어 있지 않은 문항마다 1~3개 반환한다. - - 구체적인 강점 문장은 proven, 보완이 필요한 문장은 mentioned 또는 fabricated로 반환한다. - - 실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. - - 동일한 문제를 반복하는 문장은 하나만 선택한다. - - answer가 비어 있는 문항만 분석을 반환하지 않는다. - - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. - - missing은 questionAnalyses에 억지로 넣지 않는다. - - [소제목 처리 규칙] - - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. - - 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다. - - 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다. - - 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다. - - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다. - - 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다. - - [missingKeywords 규칙] - - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. - - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. - - JD 원문에 없는 SQL, Python, AWS, 대용량 트래픽 같은 키워드를 생성하지 않는다. - - 자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부, 나이, 국적, 졸업 여부 같은 정형 자격요건은 missingKeywords에 넣지 않는다. - - preference에만 존재하는 항목은 원칙적으로 missingKeywords에서 제외한다. - - mainTask와 qualification에서 근거를 찾지 못한 경우 preference만으로 문장을 문제 삼지 않는다. - - reason에서 preference를 핵심 결격 사유처럼 표현하지 않는다. - - preference가 없다는 이유만으로 mentioned를 생성하지 않는다. - - questionAnalyses와 분리해서 missingKeywords에만 넣는다. - - 최대 3개만 반환한다. - - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. - - 우선순위는 주요 업무(mainTask) > 자격요건(qualification) >>> 우대사항(preference)다. - - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. - - 반드시 실제 입력 JD에 들어간 표현을 유지한다. - - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. - - source는 qualification, preference, mainTask 중 하나만 사용한다. - - [핵심 강점/약점 작성 규칙] - - keyStrengths와 keyWeaknesses는 각각 최대 3개만 반환한다. - - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. - - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. - - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. - - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. - - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. - - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. - - proven 문장은 keyStrengths의 quote와 questionAnalyses의 sentence에 함께 사용할 수 있다. - - mentioned 또는 fabricated 문장은 keyStrengths와 중복하지 않는다. - - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. - - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. - - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. - - quote는 너무 길게 붙이지 말고 사용자가 근거를 확인할 수 있는 핵심 구절만 사용한다. - - 적절한 강점이나 약점이 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. - - [reason 작성 규칙] - - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. - - 가능하면 JD의 어떤 업무, 자격요건, 우대사항과 관련된 문제인지 설명한다. - - 1~2문장으로 간결하게 작성한다. - - 사용자의 경험을 부정하거나 비난하지 않는다. - - "잘못되었다"보다 "근거가 부족하다", "결과가 드러나지 않는다"처럼 진단형 표현을 사용한다. - - 단순 문법 교정보다 직무 적합성, 구체성, 논리 완성도 관점의 이유를 우선한다. - - [improvement 작성 규칙] - - improvement는 첨삭 조언이 아니라 사용자가 sentence를 대체해 사용할 수 있는 완성된 한국어 문장이어야 한다. - - improvement는 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 하며 첨삭 행위를 설명하는 메타 문장이 아니어야 한다. - - 개선이 필요하지 않으면 improvement는 null로 반환한다. - - 원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다. - - 반드시 한국어 평서문으로 작성한다. - - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. - - "구체적으로 작성했습니다", "명확히 설명했습니다" 같은 메타 조언을 improvement로 반환하지 않는다. - - 원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다. - - 답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다. - - JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다. - - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. - - 원문이 과거 경험이면 개선문도 과거 경험을 유지한다. - - 원문이 포부이면 개선문도 포부를 유지한다. - - 새로운 경험이나 계획을 추가하지 않는다. - - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. - - 원래 경험과 맥락을 최대한 유지한다. - - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. - - 너무 길거나 과도하게 화려한 문장으로 만들지 않는다. - - 금지 표현: %s - """; - - private final OpenAIClient openAIClient; private final CorpusRetrievalService corpusRetrievalService; - private final LlmConcurrencyLimiter llmConcurrencyLimiter; - private final FewShotPromptProvider fewShotPromptProvider; - private final FewShotSearchService fewShotSearchService; - private final FewShotProperties fewShotProperties; - private final AsyncMetricsRecorder asyncMetricsRecorder; - private final ObjectMapper objectMapper; + private final AnalysisPromptBuilder analysisPromptBuilder; + private final AnalysisResponseParser analysisResponseParser; + private final OpenAiAnalysisAdapter openAiAnalysisAdapter; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") private String analysisModel; @@ -350,19 +102,22 @@ public AnalysisLlmResponse analyze( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); case HYBRID_EXACT -> analyzeHybridExact( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); case SINGLE_PASS -> analyzeSinglePass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); }; } catch (GeneralException e) { @@ -405,6 +160,14 @@ public AnalysisLlmResponse analyzeForEvaluation( public AnalysisAiCallResult analyzeForEvaluationResult( AnalysisPromptInput promptInput, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return analyzeForEvaluationResult(promptInput, jobCategoryEvaluationCriteria, null); + } + + public AnalysisAiCallResult analyzeForEvaluationResult( + AnalysisPromptInput promptInput, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + Instant deadline ) { try { return switch (resolveAnalysisMode()) { @@ -412,19 +175,22 @@ public AnalysisAiCallResult analyzeForEvaluationResult( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); case HYBRID_EXACT -> analyzeHybridExact( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); case SINGLE_PASS -> analyzeSinglePass( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); }; } catch (GeneralException e) { @@ -442,15 +208,17 @@ private AnalysisAiCallResult analyzeSinglePass( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { long startedAt = System.nanoTime(); AnalysisLlmResponse response = createStructuredResponse( operationName, buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), - AnalysisLlmResponse.class + AnalysisLlmResponse.class, + deadline ); - response = sanitizeSinglePassSubheadings(promptInput, response); + response = analysisResponseParser.sanitizeSinglePassSubheadings(promptInput, response); return AnalysisAiCallResult.singlePass(response, elapsedMillis(startedAt)); } @@ -458,13 +226,15 @@ private AnalysisAiCallResult analyzeTwoPass( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { long candidateStartedAt = System.nanoTime(); AnalysisCandidateResponse rawCandidates = createStructuredResponse( operationName + "-candidates", buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), - AnalysisCandidateResponse.class + AnalysisCandidateResponse.class, + deadline ); long candidateLatencyMs = elapsedMillis(candidateStartedAt); AnalysisCandidateResponse sanitizedCandidates = sanitizeCandidates(promptInput, rawCandidates); @@ -485,7 +255,8 @@ private AnalysisAiCallResult analyzeTwoPass( CandidateReviewResponse reviewResponse = createStructuredResponse( operationName + "-final", buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates), - CandidateReviewResponse.class + CandidateReviewResponse.class, + deadline ); CandidateReviewResponse validatedReviewResponse = validateCandidateReview( promptInput, @@ -507,7 +278,8 @@ private AnalysisAiCallResult analyzeTwoPass( jobCategoryEvaluationCriteria, sanitizedCandidates, validatedReviewResponse, - operationName + operationName, + deadline ); AnalysisLlmResponse response = buildFinalResponse(promptInput, sanitizedCandidates, recheckedReviewResponse); long finalLatencyMs = elapsedMillis(finalStartedAt); @@ -544,19 +316,22 @@ private AnalysisAiCallResult analyzeHybridExact( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { AnalysisAiCallResult singlePassResult = analyzeSinglePass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - operationName + "-single-pass" + operationName + "-single-pass", + deadline ); AnalysisAiCallResult twoPassResult = analyzeTwoPass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - operationName + "-two-pass" + operationName + "-two-pass", + deadline ); AnalysisLlmResponse merged = mergeHybridExact( singlePassResult.response(), @@ -582,27 +357,30 @@ private AnalysisAiCallResult analyzeHybridExact( } private T createStructuredResponse(String operationName, String prompt, Class responseType) { - var params = ResponseCreateParams.builder() - .model(analysisModel) - .input(prompt) - .temperature(0.2) - .text(responseType) - .build(); - long startedAt = System.nanoTime(); - try { - StructuredResponse response = llmConcurrencyLimiter.execute( - operationName, - () -> openAIClient.responses().create(params) + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + } + + private T createStructuredResponse( + String operationName, + String prompt, + Class responseType, + Instant deadline + ) { + if (deadline == null) { + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + } + Duration remaining = Duration.between(Instant.now(), deadline); + if (remaining.isZero() || remaining.isNegative()) { + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "평가 사례 처리 시간이 제한을 초과했습니다." ); - asyncMetricsRecorder.recordLlmRequest(operationName, "success", elapsedMillis(startedAt)); - return extractStructuredContent(response); - } catch (RuntimeException e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; - } catch (Error e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; } + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType, remaining); + } + + private T createStructuredResponse(String operationName, String prompt, Class responseType, Duration timeout) { + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); } String buildPrompt( @@ -611,11 +389,7 @@ String buildPrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - return buildPrompt( - AnalysisPromptInput.from(jobPosting, questions), - referenceContext, - jobCategoryEvaluationCriteria - ); + return analysisPromptBuilder.buildPrompt(jobPosting, questions, referenceContext, jobCategoryEvaluationCriteria); } String buildPrompt( @@ -623,7 +397,7 @@ String buildPrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); + return analysisPromptBuilder.buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); } String buildCandidatePrompt( @@ -631,178 +405,7 @@ String buildCandidatePrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - String questionText = formatQuestions(promptInput); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 자기소개서 분석을 위한 1차 후보 판정기다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - reasoning, analysis 같은 별도 필드를 추가하지 않는다. - - [1차 출력 필드] - - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 최종 PROVEN 문장 후보. 없으면 []. - - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 문항별 최대 3개. - - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. - - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. - - analysisCandidates.candidateId는 각 후보마다 고유한 문자열로 반드시 채운다. - - analysisCandidates.contextBefore/contextAfter는 sentence 앞뒤 문맥을 짧게 넣는다. 서버가 원문 기준으로 다시 검증한다. - - [허용값] - - sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY - - relatedSource: MAIN_TASK, QUALIFICATION, PREFERENCE, NONE - - status: MENTIONED, FABRICATED - - issueType: LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE, LACK_OF_JOB_CONNECTION, ABSTRACT_PLAN, GENERIC_MOTIVATION, UNSUPPORTED_CLAIM, DIRECT_CONTRADICTION - - [1차 판정 절차] - 1. 답변을 문장 단위로 확인한다. - 2. 각 문장의 유형을 분류한다. - 3. mainTask와 qualification의 직접 근거를 찾는다. - 4. preference만 근거인 후보는 제외한다. - 5. 문장이 이미 충분히 구체적인지 판단한다. - 6. 충분한 문장은 strengthCandidates로 분류한다. - 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. - 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. - 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. - 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 평가 근거가 있으면 문항별 1~3개를 반환한다. - - [1차 후보 규칙] - - 후보 수보다 정확도를 우선한다. - - 후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다. - - 애매한 문장은 후보로 만들지 않는다. - - 각 문장을 앞뒤 문맥과 함께 판단한다. - - 문장 자체 또는 주변 문맥에 충분한 근거가 있으면 제외한다. - - 대부분의 답변에서 후보가 0개 또는 1개일 수 있다. - - 2개는 서로 다른 명확한 문제가 있을 때만 허용한다. - - 3개는 독립적이고 중대한 문제가 정확히 3개 있을 때만 허용한다. - - 개수를 채우기 위해 후보를 생성하지 않는다. - - 대표 문장 하나만 기계적으로 고르지도 않는다. - - questionId는 입력된 questionId 중 하나만 사용한다. - - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. - - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. - - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. - - strengthCandidates로 검증된 문장은 서버가 status=PROVEN, improvement=null인 questionAnalyses로 변환한다. - - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. - - 지원동기는 수치 부족으로 분류하지 않는다. - - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. - - FABRICATED는 JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. - - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. - - improvement를 생성하지 않는다. - - [소제목 처리 규칙] - - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. - - 소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다. - - 소제목에 행동, 역할, 방법, 결과가 없다는 이유로 LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE 후보를 만들지 않는다. - - 소제목 자체의 구체성을 평가하거나 점수 근거로 사용하지 않는다. - - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용하고, 후보 판정은 본문 문장을 기준으로 한다. - - 소제목이 문단을 요약하는지는 문단 이해에만 참고하며, 소제목 문구 자체를 첨삭 후보로 만들지 않는다. - - [strengthCandidates 생성 규칙] - - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. - - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. - - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. - - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. - - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. - - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. - - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. - - [missingKeywordCandidates 생성 규칙] - - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. - - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. - - keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다. - - 유사 JD, preferences, 유사 자소서 문항, 직무 일반 지식, 모델의 추론에서 keyword를 만들지 않는다. - - JD에 없는 개념으로 확장하거나 일반화하지 않는다. - - "상세페이지 제작"을 "UX/UI 인터페이스 설계"로 바꾸지 않는다. - - "브랜드 운영"을 "브랜드 BI/CI 수립 경험"으로 바꾸지 않는다. - - "사업 및 행정지원"을 "복지 프로그램 기획"으로 바꾸지 않는다. - - keyword는 가능하면 JD 원문 표현을 유지한다. 추상화하거나 더 넓은 상위 개념으로 바꾸지 않는다. - - relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다. - - relatedRequirement에 JD 원문에 없는 새 표현을 만들지 않는다. - - 답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다. - - "행정 업무", "행정 지원", "사업 및 행정지원"처럼 같은 역량이 답변에 입증되어 있으면 누락으로 만들지 않는다. - - [missingKeywordCandidates Negative Examples] - - Example 1 - JD main_tasks: 상세페이지 제작 - JD qualifications: 포토샵 - Answer: 포토샵으로 상세페이지를 제작했습니다. - missingKeywordCandidates: [] - 이유: 이미 답변에 있으며, 상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다. - - Example 2 - JD main_tasks: 브랜드 운영 - Answer: 브랜드를 운영했습니다. - missingKeywordCandidates: [] - 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. - - Example 3 - JD main_tasks: 사업 및 행정지원 - Answer: 총무부에서 사업 및 행정지원 업무와 예산 관리를 담당했습니다. - missingKeywordCandidates: [] - 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. - - [strengthCandidates Positive Examples] - - Example 1 - JD main_tasks: 상세페이지 제작 - JD qualifications: 포토샵·일러스트 활용 - JD preferences: 라이노·키샷 우대 - Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. - strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. - 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. - - Example 2 - JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 - JD preferences: 더존 활용 우대 - Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. - strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. - 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. - - [문장 유형별 후보 기준] - - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. - - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. - - PLAN: 과거 성과나 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성만 본다. - - MOTIVATION: 수치를 요구하지 않고 회사·직무 선택 이유와 개인 경험의 연결성을 본다. - - COMPETENCY: 자격증 또는 역량 언급 자체를 문제로 보지 않고 실제 활용 맥락이 필요한 경우에만 후보화한다. - - FABRICATED는 명시적 사실 충돌이 있을 때만 사용한다. 단순 근거 부족은 MENTIONED다. - - [1차 few-shot 후보 개수 분포] - - 예시 A: 충분히 구체적인 성과 문장은 strengthCandidates 1개, analysisCandidates 0개. - - 예시 B: 주변 문맥에 방법과 결과가 이어지면 analysisCandidates 0개. - - 예시 C: 추상적인 포부 문장 1개만 명확하면 analysisCandidates 1개. - - 예시 D: 서로 다른 명확한 문제가 2개일 때만 analysisCandidates 2개. - - 3개 후보 예시는 사용하지 않는다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText - ); + return analysisPromptBuilder.buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); } String buildFinalPrompt( @@ -811,162 +414,7 @@ String buildFinalPrompt( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, AnalysisCandidateResponse candidates ) { - String questionText = formatQuestions(promptInput); - String candidateJson = writeJson(candidates); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - - %s - - [2차 출력 필드] - - decisions: 검증된 analysisCandidates 각각에 대해 승인 또는 거절 결정을 반환한다. - - strengths: 검증된 strengthCandidates 범위 안에서 최종 keyStrengths 후보를 반환한다. - - missingKeywords: 검증된 missingKeywordCandidates 범위 안에서 최종 missingKeywords 후보를 반환한다. - - jobFit, impact, completeness, feedback을 함께 반환한다. - - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 검증된 strengths는 PROVEN으로, accepted decision은 MENTIONED 또는 FABRICATED로 최종 questionAnalyses에 변환한다. - - 최종 questionAnalyses 개수 제한은 전체 합계가 아니라 questionId별 1~3개로 적용한다. - - [rejectionCode 허용값] - - ALREADY_SPECIFIC - - CONTEXT_PROVIDES_EVIDENCE - - WRONG_SENTENCE_TYPE_CRITERIA - - PREFERENCE_ONLY - - NOT_JOB_RELEVANT - - DUPLICATE_ISSUE - - UNSUPPORTED_JUDGMENT - - NOT_ACTIONABLE - - INVALID_SOURCE - - NONE - - [2차 후보 검증 절차] - 1. sentence가 원문에 존재하는지 확인한다. - 2. 앞뒤 문맥을 확인한다. - 3. mainTask 또는 qualification과 직접 관련 있는지 확인한다. - 4. preference-only인지 확인한다. - 5. sentenceType이 맞는지 확인한다. - 6. 해당 문장 유형에 맞는 평가 기준이 적용됐는지 확인한다. - 7. 주변 문맥에 이미 역할·방법·성과가 제공되는지 확인한다. - 8. 실제로 사용자에게 첨삭 가치가 있는지 확인한다. - 9. 승인 또는 거절 결정한다. - 10. 승인된 후보에 대해서만 reason과 improvement를 작성한다. - - 내부 판단 과정은 출력하지 않는다. - - [후보 유지 조건] - - 2차 단계의 역할은 새로운 분석 생성이 아니라 1차 후보가 실제 수정 대상인지 검증하는 것이다. - - 확실하지 않으면 무조건 제거하지 않는다. - - mentioned는 문장 자체는 사실일 수 있지만 구체적인 행동, 역할, 성과/결과, 문제 해결 과정, JD 요구 역량 연결, 기여 범위 중 하나가 부족한 경우 유지한다. - - mentioned는 추상적인 표현만 있거나 주장에 비해 근거가 부족하거나 지원자 기여와 팀 성과가 구분되지 않는 경우 유지한다. - - 단순히 숫자가 없다는 이유만으로 제거하거나 유지하지 않는다. - - fabricated는 존재하지 않는 경험을 사실처럼 기술, 실제 역할보다 과장된 역할 주장, 성과 수치나 책임 범위 임의 확대, 앞뒤 문장과 명백히 모순되는 경우에만 유지한다. - - 근거가 부족하다는 이유만으로 fabricated를 사용하지 않는다. - - [후보 제거 조건] - - 문장이 이미 충분히 구체적이고 행동, 역할, 결과, JD 연결성이 충분할 때만 제거한다. - - 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다. - - 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다. - - 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다. - - 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다. - - 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다. - - [decision 정합성] - - accepted=true이면 rejectionCode=NONE, status는 MENTIONED 또는 FABRICATED, reason은 사용자 노출 가능한 최종 사유다. - - accepted=false이면 rejectionCode는 NONE이 아니어야 하고, improvement는 반드시 null이다. - - accepted=false의 reason은 내부 거절 근거이며 최종 API에는 노출되지 않는다. - - 후보가 이미 충분하면 ALREADY_SPECIFIC으로 거절한다. - - 주변 문맥이 근거를 제공하면 CONTEXT_PROVIDES_EVIDENCE로 거절한다. - - 문장 유형에 맞지 않는 기준이 적용됐으면 WRONG_SENTENCE_TYPE_CRITERIA로 거절한다. - - 1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다. - - [문장 유형별 첨삭] - - 경험/성과: 행동, 역할, 방법, 결과 중 실제 부족한 요소만 보완한다. - - 경험/성과: 이미 수치와 방법이 있으면 부족하다고 평가하지 않는다. - - 포부/계획: 과거 성과 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 중심으로 reason을 작성한다. - - 지원동기: 수치를 요구하지 않고 회사 또는 직무 선택 이유와 개인 경험의 연결성을 평가한다. - - 역량/자격: 자격증이나 전공 자체보다 실제 활용 맥락을 평가한다. - - 역량/자격: 정형 자격요건은 missingKeywords에서 제외한다. - - [improvement 안전 규칙] - - 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 한다. - - 첨삭 행위를 설명하는 메타 문장을 금지한다. - - 원문과 실질적으로 동일한 문장을 금지한다. - - 답변의 다른 문장 복사를 금지한다. - - 원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다. - - JD 문구를 지원자의 실제 경험처럼 변환하지 않는다. - - 과거 문장을 미래 포부로 변경하지 않는다. - - 미래 포부를 과거 경험으로 변경하지 않는다. - - 원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다. - - 금지 예시: 구체적으로 설명했습니다. 경험을 추가하면 좋겠습니다. 성과를 강조했습니다. 구체적인 계획을 추가하겠습니다. 명확하게 작성할 수 있습니다. - - 금지 패턴: 추가해 보, 추가하면 좋, 설명해 보, 구체적으로 작성, 구체적으로 설명, 강조하겠, 추가하겠, 명확히 작성, 작성할 수 있, 설명할 수 있, 보완하겠, 드러내겠, 제시하겠. - - [keyStrengths 복구 규칙] - - 검증된 strengthCandidates 안에서 strengths를 구성한다. - - 충분히 구체적이고 JD와 직접 연결된 문장을 strengthCandidates에서 적극 활용한다. - - 수치, 도구, 역할, 결과가 구체적인 문장은 문제 후보보다 strength 후보를 우선한다. - - quote는 답변 원문 exact substring이어야 한다. - - mainTask 또는 qualification 연결을 우선한다. - - preference-only 강점은 제외한다. - - 최소 개수를 강제하지 않고 없으면 []를 반환한다. - - [missingKeywords 복구 규칙] - - 검증된 missingKeywordCandidates 범위 안에서만 missingKeywords를 정리한다. - - questionAnalyses 후보 개수와 독립적으로 판단한다. - - 누락 키워드가 없어도 문장 첨삭은 존재할 수 있다. - - 누락 키워드가 있어도 문장 자체는 정상일 수 있다. - - 문장 분석이 0건이라고 missingKeywords를 비우지 않는다. - - mainTask와 qualification만 사용하고 preference는 제외한다. - - 정형 자격요건은 제외하고 경험형 요구사항은 유지 가능하다. - - 새 keyword를 임의 생성하지 않는다. - - [점수 산정] - - 1차 후보 개수와 점수를 연결하지 않는다. - - questionAnalyses가 0개여도 무조건 고득점으로 처리하지 않는다. - - questionAnalyses가 많다고 기계적으로 감점하지 않는다. - - 점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다. - - Few-shot의 점수나 고정 숫자 예시는 사용하지 않는다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [검증된 1차 후보] - %s - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - OUTPUT_SCHEMA, - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - candidateJson, - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText - ); + return analysisPromptBuilder.buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, candidates); } String buildRecheckPrompt( @@ -976,256 +424,15 @@ String buildRecheckPrompt( AnalysisCandidateResponse candidates, CandidateReviewResponse reviewResponse ) { - String questionText = formatQuestions(promptInput); - String candidateJson = writeJson(candidates); - String reviewJson = writeJson(reviewResponse); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 자기소개서 two-pass 분석의 재검증기다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - - [재검증 목적] - 1차 후보가 하나 이상 있었지만 2차 검증 후 accepted 후보가 0개다. - 바로 빈 배열로 확정하지 말고 다음 질문만 판단한다. - - 1차 후보 중 사용자가 실제로 수정하면 도움이 되는 문장이 정말 하나도 없는가? - - [decision] - - NO_CORRECTION_NEEDED: 후보들이 모두 실제 첨삭 대상이 아니다. - - KEEP_BEST_CANDIDATE: 가장 명확한 수정 필요성이 있는 후보 1건만 유지한다. - - [KEEP_BEST_CANDIDATE 선택 기준] - - 단순히 첫 번째 후보를 선택하지 않는다. - - problemClarity, jobRelevance, evidenceGap, improvementUsefulness, fabricationConfidence를 1~5로 내부 평가한다. - - KEEP_BEST_CANDIDATE는 problemClarity >= 4, jobRelevance >= 4, improvementUsefulness >= 4일 때만 선택한다. - - 문제 명확성, JD 관련성, 근거 부족 정도, 안전한 개선 가능성이 높은 후보를 선택한다. - - questionTypeMatched, contextConsistent, reasonSpecific, improvementActionable은 모두 true여야 한다. - - status는 MENTIONED 또는 FABRICATED만 사용한다. - - MENTIONED: 관련 경험이나 의도는 있으나 행동, 역할, 결과, 문제 해결 과정, JD 연결, 기여 범위 중 하나가 부족하다. - - FABRICATED: 원문 또는 제공 정보와 명백히 충돌할 때만 사용한다. - - FABRICATED는 fabricationConfidence >= 4이고 directContradiction=true일 때만 선택한다. - - 근거 부족만으로 FABRICATED를 사용하지 않는다. - - 원문에 없는 사실, 수치, 경험, 계획을 추가하지 않는다. - - [재검증 입력 확인] - - 문항 원문, 전체 답변, JD 주요 업무, 자격 요건, 우대 사항을 함께 확인한다. - - 후보 문장, 1차 status, 1차 reasonBasis, 2차 제거 decision, 2차 제거 reason을 함께 확인한다. - - 후보 문장 하나만 보고 복원하지 않는다. - - 후보가 문항 의도에 맞는지 확인한다. - - 전체 답변 문맥에서 실제 수정이 필요한지 확인한다. - - JD 요구사항과 직접 관련되는지 확인한다. - - reason이 원문에서 확인 가능한지 확인한다. - - improvement가 실제로 더 유용한지 확인한다. - - [NO_CORRECTION_NEEDED 판단 기준] - - 모든 후보 문장이 이미 충분히 구체적이다. - - 후보가 단순 문체 선호 차이이거나 질문 의도와 무관하다. - - 후보가 한 줄 전체가 대괄호로 감싸진 소제목이다. - - 다짐/포부 문장을 성과 부족으로 잘못 평가했다. - - 자격증, 학력, 기술명 등 단순 정량 키워드 부재만 문제 삼았다. - - 안전하고 실질적인 개선문을 만들 수 없고 reason도 사용자에게 도움이 되지 않는다. - - [improvement] - - 안전한 교체 문장을 만들 수 없으면 null이다. - - 첨삭 행위를 설명하는 메타 문장을 쓰지 않는다. - - 원문과 같은 문장, 다른 원문 문장 복사, JD 경험 생성, 시제 변경을 금지한다. - - KEEP_BEST_CANDIDATE를 선택했다면 improvement는 reason에서 지적한 문제를 실제로 개선해야 한다. - - 더 구체적으로 작성하겠습니다, 직무 역량을 강화하겠습니다, 성과를 명확히 보여주었습니다 같은 문장은 improvementActionable=false다. - - [문항 유형별 복원 기준] - - 지원 동기: 직무 선택 이유, 본인 경험과 직무 연결, 지원자 근거가 부족할 때만 복원한다. 과거 성과 수치나 STAR 구조가 없다는 이유로 복원하지 않는다. - - 포부/다짐: 실행 방향, 직무 연결, 성장 또는 기여 계획이 지나치게 추상적일 때만 복원한다. 과거 성과, 정량 수치, 과거 행동 근거가 없다는 이유로 복원하지 않는다. - - 경험/성과: 역할, 행동 과정, 결과, 개인 기여가 부족할 때 복원할 수 있다. - - 협업/갈등: 갈등 원인, 조율 행동, 결과, 상호작용이 부족할 때 복원할 수 있다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [검증된 1차 후보] - %s - - [2차 검증 결과] - %s - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - candidateJson, - reviewJson, - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText - ); - } - - String buildSinglePassPrompt( - AnalysisPromptInput promptInput, - RetrievalContext referenceContext, - JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria - ) { - String questionText = promptInput.questions().stream() - .map(question -> """ - - questionId: %d - question: %s - answer: %s - """.formatted( - question.questionId(), - defaultString(question.question()), - defaultString(question.answer()) - )) - .collect(Collectors.joining("\n")); - - String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); - String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); - - return """ - [시스템 지시] - 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. - 반드시 JSON만 출력한다. - 자소서 원문에 없는 sentence를 만들지 않는다. - sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. - 한국어 사용자 노출 라벨을 만들거나 추정하지 않는다. - - %s - - %s - - %s - - [Few-shot 예시] - %s - - [채용 공고] - 회사명: %s - 직무명: %s - 아래 JD 영역의 역할: - - 는 최우선 업무 기준이다. - - 는 핵심 자격요건 기준이다. - - 는 보조 기준이며 단독 결격 사유로 사용하지 않는다. - - - %s - - - - %s - - - - %s - - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - - [출력 전 자체 검증] - - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. - - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. - - questionAnalyses의 status는 proven, mentioned, fabricated 중 하나만 사용한다. - - sentence는 answer에 포함된 정확한 substring만 사용한다. - - missing은 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. - - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. - - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. - - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. - - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. - - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. - - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. - - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. - - 한 줄 전체가 대괄호로 감싸진 소제목을 questionAnalyses 또는 keyStrengths에 포함하지 않았는지 확인한다. - - jobFit, impact, completeness는 0~100 정수로 출력한다. - - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. - """.formatted( - OUTPUT_SCHEMA, - EVALUATION_CRITERIA, - STATUS_AND_WRITING_RULES.formatted(AnalysisImprovementRules.bannedPhrasesText()), - fewShotPromptBlock, - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - similarJobPostingText, - similarQuestionText, - jobCategoryCriteriaSection, - questionText + return analysisPromptBuilder.buildRecheckPrompt( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + candidates, + reviewResponse ); } - private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { - if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { - return fewShotPromptProvider.getPrompt(); - } - try { - List selectedFewShots = fewShotSearchService.searchRelevantFewShots( - FewShotSearchQuery.from(promptInput), - fewShotProperties.getSearch().getTopK() - ); - if (selectedFewShots.isEmpty()) { - log.warn( - "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", - promptInput.caseId(), - fewShotProperties.getDatasetVersion() - ); - return fewShotPromptProvider.getPrompt(); - } - log.debug( - "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", - promptInput.caseId(), - selectedFewShots.stream().map(item -> item.fewShotCase().id()).toList(), - selectedFewShots.stream().map(item -> item.fewShotCase().source()).toList(), - selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), - fewShotProperties.getDatasetVersion() - ); - return fewShotPromptProvider.buildPromptBlock(selectedFewShots); - } catch (Exception e) { - log.warn( - "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", - promptInput.caseId(), - fewShotProperties.getDatasetVersion(), - e.getClass().getSimpleName(), - e.getMessage() - ); - log.debug("dynamic few-shot selection exception", e); - return fewShotPromptProvider.getPrompt(); - } - } - AnalysisCandidateResponse sanitizeCandidates( AnalysisPromptInput promptInput, AnalysisCandidateResponse candidates @@ -1394,7 +601,8 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, AnalysisCandidateResponse sanitizedCandidates, CandidateReviewResponse reviewResponse, - String operationName + String operationName, + Instant deadline ) { int firstPassCandidates = sanitizedCandidates == null || sanitizedCandidates.analysisCandidates() == null ? 0 @@ -1414,7 +622,8 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( CandidateRecheckResponse recheckResponse = createStructuredResponse( operationName + "-recheck", buildRecheckPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates, reviewResponse), - CandidateRecheckResponse.class + CandidateRecheckResponse.class, + deadline ); CandidateReviewResponse rechecked = applyRecheckResponse(promptInput, sanitizedCandidates, reviewResponse, recheckResponse); int recoveredMentionedCount = recoveredDecisionCount(rechecked, QuestionAnalysisStatus.MENTIONED); @@ -2374,125 +1583,6 @@ private String contextAfter(String answer, String sentence) { return answer.substring(from, Math.min(answer.length(), nextEnd + 1)).trim(); } - private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { - if (criteria == null) { - return ""; - } - - return """ - [직무별 보조 평가 기준] - 중분류: %s - 주의: - - 이 직무별 기준은 실제 JD를 대체하지 않는다. - - 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다. - - 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다. - - missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다. - - 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다. - - 원문에 없는 수치, 도구, 경험을 만들어내지 않는다. - 핵심 역량: %s - 관련 행동: %s - 관련 키워드: %s - 좋은 근거 예시: %s - 누락 키워드 문구 예시: %s - """.formatted( - defaultString(criteria.jobCategoryMiddle()), - formatCriteriaList(criteria.coreCompetencies()), - formatCriteriaList(criteria.relatedActions()), - formatCriteriaList(criteria.relatedKeywords()), - truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH), - formatCriteriaList(criteria.missingKeywordExamples()) - ).trim(); - } - - private String formatCriteriaList(List values) { - if (values == null || values.isEmpty()) { - return "없음"; - } - String formatted = values.stream() - .filter(value -> value != null && !value.isBlank()) - .limit(MAX_CRITERIA_ITEMS) - .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) - .collect(Collectors.joining("\n")); - return formatted.isBlank() ? "없음" : "\n" + formatted; - } - - private String formatJobPostingReferences(List references) { - if (references == null || references.isEmpty()) { - return "없음"; - } - String formatted = references.stream() - .map(reference -> """ - - 회사명: %s - 직무명: %s - 주요 업무: %s - 자격 요건: %s - 우대 사항: %s - 거리: %.4f - """.formatted( - truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH), - reference.distance() - )) - .reduce("", (left, right) -> left + "\n" + right) - .trim(); - return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); - } - - private String formatQuestionReferences(List references) { - if (references == null || references.isEmpty()) { - return "없음"; - } - String formatted = references.stream() - .map(reference -> """ - - 회사명: %s - 직무명: %s - 문항 유형: %s - 글자 수 제한: %s - 문항: %s - 거리: %.4f - """.formatted( - truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH), - reference.charLimit() == null ? "" : reference.charLimit(), - truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH), - reference.distance() - )) - .reduce("", (left, right) -> left + "\n" + right) - .trim(); - return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); - } - - private String formatQuestions(AnalysisPromptInput promptInput) { - return promptInput.questions().stream() - .map(question -> """ - - questionId: %d - question: %s - answer: %s - """.formatted( - question.questionId(), - defaultString(question.question()), - defaultString(question.answer()) - )) - .collect(Collectors.joining("\n")); - } - - private T extractStructuredContent(StructuredResponse response) { - return response.output().stream() - .filter(item -> item.message().isPresent()) - .flatMap(item -> item.asMessage().content().stream()) - .filter(content -> content.outputText().isPresent()) - .map(StructuredResponseOutputMessage.Content::asOutputText) - .findFirst() - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." - )); - } - private boolean isPrimarySource(String relatedSource) { return "MAIN_TASK".equalsIgnoreCase(defaultString(relatedSource)) || "QUALIFICATION".equalsIgnoreCase(defaultString(relatedSource)); @@ -2583,14 +1673,6 @@ private int size(List values) { return values == null ? 0 : values.size(); } - private String writeJson(Object value) { - try { - return objectMapper.writeValueAsString(value == null ? List.of() : value); - } catch (JsonProcessingException e) { - return "[]"; - } - } - private String normalize(String value) { return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(); } @@ -2599,18 +1681,11 @@ private String defaultString(String value) { return value == null ? "" : value; } - private String truncate(String value, int maxLength) { - if (value == null || value.length() <= maxLength) { - return value; - } - return value.substring(0, maxLength) + "..."; - } - private RetrievalContext emptyContext() { return new RetrievalContext(List.of(), List.of()); } - AnalysisMode resolveAnalysisMode() { + public AnalysisMode resolveAnalysisMode() { if (StringUtils.hasText(analysisMode)) { String normalized = analysisMode.trim().replace('-', '_').toUpperCase(java.util.Locale.ROOT); try { @@ -2622,7 +1697,7 @@ AnalysisMode resolveAnalysisMode() { return twoPassEnabled ? AnalysisMode.TWO_PASS : AnalysisMode.SINGLE_PASS; } - enum AnalysisMode { + public enum AnalysisMode { SINGLE_PASS, TWO_PASS, HYBRID_EXACT diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java new file mode 100644 index 00000000..db622fdd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnalysisAiGenerator implements AnalysisGenerator { + private final AnalysisAiClient analysisAiClient; + + @Override + public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { + return analysisAiClient.analyze(payload); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java index 15da87d0..ec28a1d8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java @@ -28,7 +28,7 @@ public final class AnalysisImprovementRules { private AnalysisImprovementRules() { } - static String bannedPhrasesText() { + public static String bannedPhrasesText() { return String.join(", ", BANNED_PHRASES); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java new file mode 100644 index 00000000..ce3e740a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java @@ -0,0 +1,776 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.policy.AnalysisPromptPolicy; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AnalysisPromptBuilder { + private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; + private static final int MAX_REFERENCE_FIELD_LENGTH = 300; + private static final int MAX_CRITERIA_ITEMS = 5; + + private final FewShotPromptProvider fewShotPromptProvider; + private final FewShotSearchService fewShotSearchService; + private final FewShotProperties fewShotProperties; + private final ObjectMapper objectMapper; + + String buildPrompt( + JobPosting jobPosting, + List questions, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildPrompt( + AnalysisPromptInput.from(jobPosting, questions), + referenceContext, + jobCategoryEvaluationCriteria + ); + } + + String buildPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); + } + + String buildCandidatePrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = formatQuestions(promptInput); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 자기소개서 분석을 위한 1차 후보 판정기다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + reasoning, analysis 같은 별도 필드를 추가하지 않는다. + + [1차 출력 필드] + - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 최종 PROVEN 문장 후보. 없으면 []. + - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 문항별 최대 3개. + - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. + - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. + - analysisCandidates.candidateId는 각 후보마다 고유한 문자열로 반드시 채운다. + - analysisCandidates.contextBefore/contextAfter는 sentence 앞뒤 문맥을 짧게 넣는다. 서버가 원문 기준으로 다시 검증한다. + + [허용값] + - sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY + - relatedSource: MAIN_TASK, QUALIFICATION, PREFERENCE, NONE + - status: MENTIONED, FABRICATED + - issueType: LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE, LACK_OF_JOB_CONNECTION, ABSTRACT_PLAN, GENERIC_MOTIVATION, UNSUPPORTED_CLAIM, DIRECT_CONTRADICTION + + [1차 판정 절차] + 1. 답변을 문장 단위로 확인한다. + 2. 각 문장의 유형을 분류한다. + 3. mainTask와 qualification의 직접 근거를 찾는다. + 4. preference만 근거인 후보는 제외한다. + 5. 문장이 이미 충분히 구체적인지 판단한다. + 6. 충분한 문장은 strengthCandidates로 분류한다. + 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. + 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. + 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. + 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 평가 근거가 있으면 문항별 1~3개를 반환한다. + + [1차 후보 규칙] + - 후보 수보다 정확도를 우선한다. + - 후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다. + - 애매한 문장은 후보로 만들지 않는다. + - 각 문장을 앞뒤 문맥과 함께 판단한다. + - 문장 자체 또는 주변 문맥에 충분한 근거가 있으면 제외한다. + - 대부분의 답변에서 후보가 0개 또는 1개일 수 있다. + - 2개는 서로 다른 명확한 문제가 있을 때만 허용한다. + - 3개는 독립적이고 중대한 문제가 정확히 3개 있을 때만 허용한다. + - 개수를 채우기 위해 후보를 생성하지 않는다. + - 대표 문장 하나만 기계적으로 고르지도 않는다. + - questionId는 입력된 questionId 중 하나만 사용한다. + - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. + - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. + - strengthCandidates로 검증된 문장은 서버가 status=PROVEN, improvement=null인 questionAnalyses로 변환한다. + - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. + - 지원동기는 수치 부족으로 분류하지 않는다. + - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. + - FABRICATED는 JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. + - improvement를 생성하지 않는다. + + [소제목 처리 규칙] + - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. + - 소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다. + - 소제목에 행동, 역할, 방법, 결과가 없다는 이유로 LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE 후보를 만들지 않는다. + - 소제목 자체의 구체성을 평가하거나 점수 근거로 사용하지 않는다. + - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용하고, 후보 판정은 본문 문장을 기준으로 한다. + - 소제목이 문단을 요약하는지는 문단 이해에만 참고하며, 소제목 문구 자체를 첨삭 후보로 만들지 않는다. + + [strengthCandidates 생성 규칙] + - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. + - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. + - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. + - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. + - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. + - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. + - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. + + [missingKeywordCandidates 생성 규칙] + - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. + - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. + - keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다. + - 유사 JD, preferences, 유사 자소서 문항, 직무 일반 지식, 모델의 추론에서 keyword를 만들지 않는다. + - JD에 없는 개념으로 확장하거나 일반화하지 않는다. + - "상세페이지 제작"을 "UX/UI 인터페이스 설계"로 바꾸지 않는다. + - "브랜드 운영"을 "브랜드 BI/CI 수립 경험"으로 바꾸지 않는다. + - "사업 및 행정지원"을 "복지 프로그램 기획"으로 바꾸지 않는다. + - keyword는 가능하면 JD 원문 표현을 유지한다. 추상화하거나 더 넓은 상위 개념으로 바꾸지 않는다. + - relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다. + - relatedRequirement에 JD 원문에 없는 새 표현을 만들지 않는다. + - 답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다. + - "행정 업무", "행정 지원", "사업 및 행정지원"처럼 같은 역량이 답변에 입증되어 있으면 누락으로 만들지 않는다. + + [missingKeywordCandidates Negative Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵 + Answer: 포토샵으로 상세페이지를 제작했습니다. + missingKeywordCandidates: [] + 이유: 이미 답변에 있으며, 상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다. + - Example 2 + JD main_tasks: 브랜드 운영 + Answer: 브랜드를 운영했습니다. + missingKeywordCandidates: [] + 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. + - Example 3 + JD main_tasks: 사업 및 행정지원 + Answer: 총무부에서 사업 및 행정지원 업무와 예산 관리를 담당했습니다. + missingKeywordCandidates: [] + 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. + + [strengthCandidates Positive Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵·일러스트 활용 + JD preferences: 라이노·키샷 우대 + Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. + strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. + - Example 2 + JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 + JD preferences: 더존 활용 우대 + Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. + strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. + + [문장 유형별 후보 기준] + - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. + - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. + - PLAN: 과거 성과나 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성만 본다. + - MOTIVATION: 수치를 요구하지 않고 회사·직무 선택 이유와 개인 경험의 연결성을 본다. + - COMPETENCY: 자격증 또는 역량 언급 자체를 문제로 보지 않고 실제 활용 맥락이 필요한 경우에만 후보화한다. + - FABRICATED는 명시적 사실 충돌이 있을 때만 사용한다. 단순 근거 부족은 MENTIONED다. + + [1차 few-shot 후보 개수 분포] + - 예시 A: 충분히 구체적인 성과 문장은 strengthCandidates 1개, analysisCandidates 0개. + - 예시 B: 주변 문맥에 방법과 결과가 이어지면 analysisCandidates 0개. + - 예시 C: 추상적인 포부 문장 1개만 명확하면 analysisCandidates 1개. + - 예시 D: 서로 다른 명확한 문제가 2개일 때만 analysisCandidates 2개. + - 3개 후보 예시는 사용하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildFinalPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + AnalysisCandidateResponse candidates + ) { + String questionText = formatQuestions(promptInput); + String candidateJson = writeJson(candidates); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + + %s + + [2차 출력 필드] + - decisions: 검증된 analysisCandidates 각각에 대해 승인 또는 거절 결정을 반환한다. + - strengths: 검증된 strengthCandidates 범위 안에서 최종 keyStrengths 후보를 반환한다. + - missingKeywords: 검증된 missingKeywordCandidates 범위 안에서 최종 missingKeywords 후보를 반환한다. + - jobFit, impact, completeness, feedback을 함께 반환한다. + - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 검증된 strengths는 PROVEN으로, accepted decision은 MENTIONED 또는 FABRICATED로 최종 questionAnalyses에 변환한다. + - 최종 questionAnalyses 개수 제한은 전체 합계가 아니라 questionId별 1~3개로 적용한다. + + [rejectionCode 허용값] + - ALREADY_SPECIFIC + - CONTEXT_PROVIDES_EVIDENCE + - WRONG_SENTENCE_TYPE_CRITERIA + - PREFERENCE_ONLY + - NOT_JOB_RELEVANT + - DUPLICATE_ISSUE + - UNSUPPORTED_JUDGMENT + - NOT_ACTIONABLE + - INVALID_SOURCE + - NONE + + [2차 후보 검증 절차] + 1. sentence가 원문에 존재하는지 확인한다. + 2. 앞뒤 문맥을 확인한다. + 3. mainTask 또는 qualification과 직접 관련 있는지 확인한다. + 4. preference-only인지 확인한다. + 5. sentenceType이 맞는지 확인한다. + 6. 해당 문장 유형에 맞는 평가 기준이 적용됐는지 확인한다. + 7. 주변 문맥에 이미 역할·방법·성과가 제공되는지 확인한다. + 8. 실제로 사용자에게 첨삭 가치가 있는지 확인한다. + 9. 승인 또는 거절 결정한다. + 10. 승인된 후보에 대해서만 reason과 improvement를 작성한다. + - 내부 판단 과정은 출력하지 않는다. + + [후보 유지 조건] + - 2차 단계의 역할은 새로운 분석 생성이 아니라 1차 후보가 실제 수정 대상인지 검증하는 것이다. + - 확실하지 않으면 무조건 제거하지 않는다. + - mentioned는 문장 자체는 사실일 수 있지만 구체적인 행동, 역할, 성과/결과, 문제 해결 과정, JD 요구 역량 연결, 기여 범위 중 하나가 부족한 경우 유지한다. + - mentioned는 추상적인 표현만 있거나 주장에 비해 근거가 부족하거나 지원자 기여와 팀 성과가 구분되지 않는 경우 유지한다. + - 단순히 숫자가 없다는 이유만으로 제거하거나 유지하지 않는다. + - fabricated는 존재하지 않는 경험을 사실처럼 기술, 실제 역할보다 과장된 역할 주장, 성과 수치나 책임 범위 임의 확대, 앞뒤 문장과 명백히 모순되는 경우에만 유지한다. + - 근거가 부족하다는 이유만으로 fabricated를 사용하지 않는다. + + [후보 제거 조건] + - 문장이 이미 충분히 구체적이고 행동, 역할, 결과, JD 연결성이 충분할 때만 제거한다. + - 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다. + - 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다. + - 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다. + - 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다. + - 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다. + + [decision 정합성] + - accepted=true이면 rejectionCode=NONE, status는 MENTIONED 또는 FABRICATED, reason은 사용자 노출 가능한 최종 사유다. + - accepted=false이면 rejectionCode는 NONE이 아니어야 하고, improvement는 반드시 null이다. + - accepted=false의 reason은 내부 거절 근거이며 최종 API에는 노출되지 않는다. + - 후보가 이미 충분하면 ALREADY_SPECIFIC으로 거절한다. + - 주변 문맥이 근거를 제공하면 CONTEXT_PROVIDES_EVIDENCE로 거절한다. + - 문장 유형에 맞지 않는 기준이 적용됐으면 WRONG_SENTENCE_TYPE_CRITERIA로 거절한다. + - 1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다. + + [문장 유형별 첨삭] + - 경험/성과: 행동, 역할, 방법, 결과 중 실제 부족한 요소만 보완한다. + - 경험/성과: 이미 수치와 방법이 있으면 부족하다고 평가하지 않는다. + - 포부/계획: 과거 성과 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 중심으로 reason을 작성한다. + - 지원동기: 수치를 요구하지 않고 회사 또는 직무 선택 이유와 개인 경험의 연결성을 평가한다. + - 역량/자격: 자격증이나 전공 자체보다 실제 활용 맥락을 평가한다. + - 역량/자격: 정형 자격요건은 missingKeywords에서 제외한다. + + [improvement 안전 규칙] + - 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 한다. + - 첨삭 행위를 설명하는 메타 문장을 금지한다. + - 원문과 실질적으로 동일한 문장을 금지한다. + - 답변의 다른 문장 복사를 금지한다. + - 원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다. + - JD 문구를 지원자의 실제 경험처럼 변환하지 않는다. + - 과거 문장을 미래 포부로 변경하지 않는다. + - 미래 포부를 과거 경험으로 변경하지 않는다. + - 원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다. + - 금지 예시: 구체적으로 설명했습니다. 경험을 추가하면 좋겠습니다. 성과를 강조했습니다. 구체적인 계획을 추가하겠습니다. 명확하게 작성할 수 있습니다. + - 금지 패턴: 추가해 보, 추가하면 좋, 설명해 보, 구체적으로 작성, 구체적으로 설명, 강조하겠, 추가하겠, 명확히 작성, 작성할 수 있, 설명할 수 있, 보완하겠, 드러내겠, 제시하겠. + + [keyStrengths 복구 규칙] + - 검증된 strengthCandidates 안에서 strengths를 구성한다. + - 충분히 구체적이고 JD와 직접 연결된 문장을 strengthCandidates에서 적극 활용한다. + - 수치, 도구, 역할, 결과가 구체적인 문장은 문제 후보보다 strength 후보를 우선한다. + - quote는 답변 원문 exact substring이어야 한다. + - mainTask 또는 qualification 연결을 우선한다. + - preference-only 강점은 제외한다. + - 최소 개수를 강제하지 않고 없으면 []를 반환한다. + + [missingKeywords 복구 규칙] + - 검증된 missingKeywordCandidates 범위 안에서만 missingKeywords를 정리한다. + - questionAnalyses 후보 개수와 독립적으로 판단한다. + - 누락 키워드가 없어도 문장 첨삭은 존재할 수 있다. + - 누락 키워드가 있어도 문장 자체는 정상일 수 있다. + - 문장 분석이 0건이라고 missingKeywords를 비우지 않는다. + - mainTask와 qualification만 사용하고 preference는 제외한다. + - 정형 자격요건은 제외하고 경험형 요구사항은 유지 가능하다. + - 새 keyword를 임의 생성하지 않는다. + + [점수 산정] + - 1차 후보 개수와 점수를 연결하지 않는다. + - questionAnalyses가 0개여도 무조건 고득점으로 처리하지 않는다. + - questionAnalyses가 많다고 기계적으로 감점하지 않는다. + - 점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다. + - Few-shot의 점수나 고정 숫자 예시는 사용하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [검증된 1차 후보] + %s + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + AnalysisPromptPolicy.OUTPUT_SCHEMA, + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + candidateJson, + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildRecheckPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + AnalysisCandidateResponse candidates, + com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse reviewResponse + ) { + String questionText = formatQuestions(promptInput); + String candidateJson = writeJson(candidates); + String reviewJson = writeJson(reviewResponse); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 자기소개서 two-pass 분석의 재검증기다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + + [재검증 목적] + 1차 후보가 하나 이상 있었지만 2차 검증 후 accepted 후보가 0개다. + 바로 빈 배열로 확정하지 말고 다음 질문만 판단한다. + - 1차 후보 중 사용자가 실제로 수정하면 도움이 되는 문장이 정말 하나도 없는가? + + [decision] + - NO_CORRECTION_NEEDED: 후보들이 모두 실제 첨삭 대상이 아니다. + - KEEP_BEST_CANDIDATE: 가장 명확한 수정 필요성이 있는 후보 1건만 유지한다. + + [KEEP_BEST_CANDIDATE 선택 기준] + - 단순히 첫 번째 후보를 선택하지 않는다. + - problemClarity, jobRelevance, evidenceGap, improvementUsefulness, fabricationConfidence를 1~5로 내부 평가한다. + - KEEP_BEST_CANDIDATE는 problemClarity >= 4, jobRelevance >= 4, improvementUsefulness >= 4일 때만 선택한다. + - 문제 명확성, JD 관련성, 근거 부족 정도, 안전한 개선 가능성이 높은 후보를 선택한다. + - questionTypeMatched, contextConsistent, reasonSpecific, improvementActionable은 모두 true여야 한다. + - status는 MENTIONED 또는 FABRICATED만 사용한다. + - MENTIONED: 관련 경험이나 의도는 있으나 행동, 역할, 결과, 문제 해결 과정, JD 연결, 기여 범위 중 하나가 부족하다. + - FABRICATED: 원문 또는 제공 정보와 명백히 충돌할 때만 사용한다. + - FABRICATED는 fabricationConfidence >= 4이고 directContradiction=true일 때만 선택한다. + - 근거 부족만으로 FABRICATED를 사용하지 않는다. + - 원문에 없는 사실, 수치, 경험, 계획을 추가하지 않는다. + + [재검증 입력 확인] + - 문항 원문, 전체 답변, JD 주요 업무, 자격 요건, 우대 사항을 함께 확인한다. + - 후보 문장, 1차 status, 1차 reasonBasis, 2차 제거 decision, 2차 제거 reason을 함께 확인한다. + - 후보 문장 하나만 보고 복원하지 않는다. + - 후보가 문항 의도에 맞는지 확인한다. + - 전체 답변 문맥에서 실제 수정이 필요한지 확인한다. + - JD 요구사항과 직접 관련되는지 확인한다. + - reason이 원문에서 확인 가능한지 확인한다. + - improvement가 실제로 더 유용한지 확인한다. + + [NO_CORRECTION_NEEDED 판단 기준] + - 모든 후보 문장이 이미 충분히 구체적이다. + - 후보가 단순 문체 선호 차이이거나 질문 의도와 무관하다. + - 후보가 한 줄 전체가 대괄호로 감싸진 소제목이다. + - 다짐/포부 문장을 성과 부족으로 잘못 평가했다. + - 자격증, 학력, 기술명 등 단순 정량 키워드 부재만 문제 삼았다. + - 안전하고 실질적인 개선문을 만들 수 없고 reason도 사용자에게 도움이 되지 않는다. + + [improvement] + - 안전한 교체 문장을 만들 수 없으면 null이다. + - 첨삭 행위를 설명하는 메타 문장을 쓰지 않는다. + - 원문과 같은 문장, 다른 원문 문장 복사, JD 경험 생성, 시제 변경을 금지한다. + - KEEP_BEST_CANDIDATE를 선택했다면 improvement는 reason에서 지적한 문제를 실제로 개선해야 한다. + - 더 구체적으로 작성하겠습니다, 직무 역량을 강화하겠습니다, 성과를 명확히 보여주었습니다 같은 문장은 improvementActionable=false다. + + [문항 유형별 복원 기준] + - 지원 동기: 직무 선택 이유, 본인 경험과 직무 연결, 지원자 근거가 부족할 때만 복원한다. 과거 성과 수치나 STAR 구조가 없다는 이유로 복원하지 않는다. + - 포부/다짐: 실행 방향, 직무 연결, 성장 또는 기여 계획이 지나치게 추상적일 때만 복원한다. 과거 성과, 정량 수치, 과거 행동 근거가 없다는 이유로 복원하지 않는다. + - 경험/성과: 역할, 행동 과정, 결과, 개인 기여가 부족할 때 복원할 수 있다. + - 협업/갈등: 갈등 원인, 조율 행동, 결과, 상호작용이 부족할 때 복원할 수 있다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [검증된 1차 후보] + %s + + [2차 검증 결과] + %s + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + candidateJson, + reviewJson, + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildSinglePassPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = formatQuestions(promptInput); + String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); + String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); + + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 JSON만 출력한다. + 자소서 원문에 없는 sentence를 만들지 않는다. + sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. + 한국어 사용자 노출 라벨을 만들거나 추정하지 않는다. + + %s + + %s + + %s + + [Few-shot 예시] + %s + + [채용 공고] + 회사명: %s + 직무명: %s + 아래 JD 영역의 역할: + - 는 최우선 업무 기준이다. + - 는 핵심 자격요건 기준이다. + - 는 보조 기준이며 단독 결격 사유로 사용하지 않는다. + + + %s + + + + %s + + + + %s + + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + + [출력 전 자체 검증] + - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. + - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - questionAnalyses의 status는 proven, mentioned, fabricated 중 하나만 사용한다. + - sentence는 answer에 포함된 정확한 substring만 사용한다. + - missing은 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. + - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. + - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. + - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. + - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. + - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. + - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. + - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. + - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. + - 한 줄 전체가 대괄호로 감싸진 소제목을 questionAnalyses 또는 keyStrengths에 포함하지 않았는지 확인한다. + - jobFit, impact, completeness는 0~100 정수로 출력한다. + - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. + """.formatted( + AnalysisPromptPolicy.OUTPUT_SCHEMA, + AnalysisPromptPolicy.EVALUATION_CRITERIA, + AnalysisPromptPolicy.STATUS_AND_WRITING_RULES, + fewShotPromptBlock, + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + similarJobPostingText, + similarQuestionText, + jobCategoryCriteriaSection, + questionText + ); + } + + private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { + if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { + return fewShotPromptProvider.getPrompt(); + } + try { + List selectedFewShots = fewShotSearchService.searchRelevantFewShots( + FewShotSearchQuery.from(promptInput), + fewShotProperties.getSearch().getTopK() + ); + if (selectedFewShots.isEmpty()) { + log.warn( + "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", + promptInput.caseId(), + fewShotProperties.getDatasetVersion() + ); + return fewShotPromptProvider.getPrompt(); + } + log.debug( + "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", + promptInput.caseId(), + selectedFewShots.stream().map(item -> item.fewShotCase().id()).toList(), + selectedFewShots.stream().map(item -> item.fewShotCase().source()).toList(), + selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), + fewShotProperties.getDatasetVersion() + ); + return fewShotPromptProvider.buildPromptBlock(selectedFewShots); + } catch (Exception e) { + log.warn( + "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", + promptInput.caseId(), + fewShotProperties.getDatasetVersion(), + e.getClass().getSimpleName(), + e.getMessage() + ); + log.debug("dynamic few-shot selection exception", e); + return fewShotPromptProvider.getPrompt(); + } + } + + private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { + if (criteria == null) { + return ""; + } + + return """ + [직무별 보조 평가 기준] + 중분류: %s + 주의: + - 이 직무별 기준은 실제 JD를 대체하지 않는다. + - 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다. + - 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다. + - missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다. + - 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다. + - 원문에 없는 수치, 도구, 경험을 만들어내지 않는다. + 핵심 역량: %s + 관련 행동: %s + 관련 키워드: %s + 좋은 근거 예시: %s + 누락 키워드 문구 예시: %s + """.formatted( + defaultString(criteria.jobCategoryMiddle()), + formatCriteriaList(criteria.coreCompetencies()), + formatCriteriaList(criteria.relatedActions()), + formatCriteriaList(criteria.relatedKeywords()), + truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH), + formatCriteriaList(criteria.missingKeywordExamples()) + ).trim(); + } + + private String formatCriteriaList(List values) { + if (values == null || values.isEmpty()) { + return "없음"; + } + String formatted = values.stream() + .filter(value -> value != null && !value.isBlank()) + .limit(MAX_CRITERIA_ITEMS) + .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) + .collect(Collectors.joining("\n")); + return formatted.isBlank() ? "없음" : "\n" + formatted; + } + + private String formatJobPostingReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 주요 업무: %s + 자격 요건: %s + 우대 사항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + + private String formatQuestionReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 문항 유형: %s + 글자 수 제한: %s + 문항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH), + reference.charLimit() == null ? "" : reference.charLimit(), + truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + + private String formatQuestions(AnalysisPromptInput promptInput) { + return promptInput.questions().stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.questionId(), + defaultString(question.question()), + defaultString(question.answer()) + )) + .collect(Collectors.joining("\n")); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value == null ? List.of() : value); + } catch (JsonProcessingException e) { + log.warn( + "Failed to serialize analysis prompt value. fallback=[] valueType={}, value={}", + value == null ? "null" : value.getClass().getName(), + value, + e + ); + return "[]"; + } + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength) + "..."; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java new file mode 100644 index 00000000..7f4f024c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java @@ -0,0 +1,103 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseOutputMessage; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class AnalysisResponseParser { + + public T extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(StructuredResponseOutputMessage.Content::asOutputText) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + )); + } + + public AnalysisLlmResponse sanitizeSinglePassSubheadings( + AnalysisPromptInput promptInput, + AnalysisLlmResponse response + ) { + if (response == null || promptInput == null || promptInput.questions() == null) { + return response; + } + Map answerByQuestionId = promptInput.questions().stream() + .collect(Collectors.toMap( + AnalysisPromptInput.QuestionAnswer::questionId, + AnalysisPromptInput.QuestionAnswer::answer + )); + List answers = new ArrayList<>(answerByQuestionId.values()); + List keyStrengths = response.keyStrengths() == null + ? null + : response.keyStrengths().stream() + .filter(item -> item != null && !isBracketedSubheadingInAnyAnswer(answers, item.quote())) + .toList(); + List keyWeaknesses = response.keyWeaknesses() == null + ? null + : response.keyWeaknesses().stream() + .filter(item -> item != null && !isBracketedSubheadingInAnyAnswer(answers, item.quote())) + .toList(); + List questionAnalyses = response.questionAnalyses() == null + ? null + : response.questionAnalyses().stream() + .filter(item -> item != null + && !isBracketedSubheading(answerByQuestionId.get(item.questionId()), item.sentence())) + .toList(); + return new AnalysisLlmResponse( + response.jobFit(), + response.impact(), + response.completeness(), + response.feedback(), + keyStrengths, + keyWeaknesses, + response.missingKeywords(), + questionAnalyses + ); + } + + public QuestionAnalysisStatus parseQuestionAnalysisStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private boolean isBracketedSubheadingInAnyAnswer(List answers, String candidateText) { + return answers.stream().anyMatch(answer -> isBracketedSubheading(answer, candidateText)); + } + + private boolean isBracketedSubheading(String answer, String candidateText) { + if (!StringUtils.hasText(answer) || !StringUtils.hasText(candidateText)) { + return false; + } + String trimmedCandidate = candidateText.trim(); + if (trimmedCandidate.indexOf('\n') >= 0 + || trimmedCandidate.indexOf('\r') >= 0 + || !trimmedCandidate.startsWith("[") + || !trimmedCandidate.endsWith("]") + || trimmedCandidate.length() <= 2) { + return false; + } + return answer.lines().map(String::trim).anyMatch(trimmedCandidate::equals); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java index ef571578..6cc501ff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java new file mode 100644 index 00000000..88a78c5a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java @@ -0,0 +1,50 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.springframework.stereotype.Service; + +@Service +public class AnalysisAsyncCreditCoordinator { + private final AnalysisCreditService analysisCreditService; + private final UserService userService; + + public AnalysisAsyncCreditCoordinator( + AnalysisCreditService analysisCreditService, + UserService userService + ) { + this.analysisCreditService = analysisCreditService; + this.userService = userService; + } + + public boolean releaseReservedCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return false; + } + + User user = userService.getUser(task.getUserId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); + return task.markCreditReleased(); + } + + public boolean reserveCreditIfNeeded(AnalysisAsyncTask task) { + if (!task.canReserveCredit()) { + return false; + } + + User user = userService.getUser(task.getUserId()); + String creditReferenceId = analysisCreditService.createAsyncReferenceId( + task.getTaskId(), + task.nextCreditReferenceVersion() + ); + analysisCreditService.deduct(user, creditReferenceId); + return task.markCreditReserved(creditReferenceId); + } + + public boolean confirmReservedCreditIfNeeded(AnalysisAsyncTask task) { + return task.markCreditConfirmed(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java index 0c890c9d..0d2b5c9b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java @@ -1,172 +1,42 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; +import com.jobdri.jobdri_api.domain.analysis.application.usecase.async.AnalysisAsyncUseCase; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; 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.service.core.AnalysisService; 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.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import java.util.Locale; - @Service -@RequiredArgsConstructor // 분석 비동기 작업의 접수와 상태 조회를 외부 API 관점에서 조율하는 서비스다. public class AnalysisAsyncFacadeService { - private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; - - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisAsyncProcessor analysisAsyncProcessor; - private final AnalysisService analysisService; - private final UserService userService; + private final AnalysisAsyncUseCase analysisAsyncUseCase; + + public AnalysisAsyncFacadeService( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncProcessor analysisAsyncProcessor, + AnalysisService analysisService, + UserService userService + ) { + this.analysisAsyncUseCase = new AnalysisAsyncUseCase( + analysisAsyncTaskService, + analysisAsyncProcessor, + analysisService, + userService + ); + } public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { - User validatedUser = userService.validateUser(user); - analysisService.validateAnalysisRequest(validatedUser, mockApplyId); - - return analysisAsyncTaskService.findActiveTask(validatedUser.getId(), mockApplyId) - .map(this::toInProgressResponse) - .orElseGet(() -> createCachedOrProcessTask(validatedUser, mockApplyId)); + return analysisAsyncUseCase.submit(user, mockApplyId); } public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String taskId) { - User validatedUser = userService.validateUser(user); - AnalysisAsyncStatusResponse status = analysisAsyncTaskService.getTaskStatus(validatedUser.getId(), taskId); - if (!status.mockApplyId().equals(mockApplyId)) { - throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); - } - if (!"SUCCEEDED".equals(status.status())) { - return status; - } - - return AnalysisAsyncStatusResponse.builder() - .taskId(status.taskId()) - .mockApplyId(status.mockApplyId()) - .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()) - .cancelRequested(status.cancelRequested()) - .cancelledAt(status.cancelledAt()) - .currentStep(status.currentStep()) - .progressPercent(status.progressPercent()) - .estimatedRemainingSeconds(status.estimatedRemainingSeconds()) - .steps(status.steps()) - .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) - .build(); + return analysisAsyncUseCase.getTask(user, mockApplyId, taskId); } public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { - User validatedUser = userService.validateUser(user); - return analysisAsyncTaskService.cancelTask(validatedUser.getId(), mockApplyId, taskId); - } - - private AnalysisAsyncSubmitResponse createCachedOrProcessTask(User user, Long mockApplyId) { - if (analysisService.hasReusableAnalysis(user, mockApplyId)) { - return toCachedResponse(); - } - return createAndProcessTask(user, mockApplyId); - } - - private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { - PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); - if (!pendingTaskResult.created()) { - return toInProgressResponse(pendingTaskResult.task()); - } - - AnalysisAsyncTask task = pendingTaskResult.task(); - String taskId = task.getTaskId(); - - try { - analysisAsyncProcessor.process( - taskId, - user.getId(), - mockApplyId, - task.getMaxRetryCount() - ); - return new AnalysisAsyncSubmitResponse( - taskId, - "PENDING", - "자소서 분석 비동기 작업이 접수되었습니다.", - false, - false - ); - } catch (RuntimeException e) { - analysisAsyncTaskService.deleteTask(taskId); - throw e; - } - } - - private PendingTaskResult createPendingTask(User user, Long mockApplyId) { - try { - return new PendingTaskResult( - analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId), - true - ); - } catch (DataIntegrityViolationException e) { - if (!isActiveTaskUniqueConflict(e)) { - throw e; - } - AnalysisAsyncTask existingTask = analysisAsyncTaskService.findActiveTask(user.getId(), mockApplyId) - .orElseThrow(() -> e); - return new PendingTaskResult(existingTask, false); - } - } - - private AnalysisAsyncSubmitResponse toInProgressResponse(AnalysisAsyncTask task) { - return new AnalysisAsyncSubmitResponse( - task.getTaskId(), - task.getStatus().name(), - "이미 진행 중인 자소서 분석 작업이 있습니다.", - false, - false - ); - } - - private AnalysisAsyncSubmitResponse toCachedResponse() { - return new AnalysisAsyncSubmitResponse( - null, - "SUCCEEDED", - "동일 입력의 기존 자소서 분석 결과를 재사용했습니다.", - true, - true - ); - } - - private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { - Throwable cause = exception; - while (cause != null) { - if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation - && containsConstraintName(constraintViolation.getConstraintName())) { - return true; - } - if (containsConstraintName(cause.getMessage())) { - return true; - } - cause = cause.getCause(); - } - return false; - } - - private boolean containsConstraintName(String value) { - return value != null && value.toLowerCase(Locale.ROOT).contains(ACTIVE_TASK_UNIQUE_CONSTRAINT); - } - - private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { + return analysisAsyncUseCase.cancel(user, mockApplyId, taskId); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java index 36ef74e9..068a958c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java @@ -1,19 +1,18 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; -import lombok.RequiredArgsConstructor; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncQueueProcessor; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor // 분석 비동기 작업을 MQ 메시지로 변환해 워커 실행 경로로 넘기는 서비스다. public class AnalysisAsyncProcessor { + private final AnalysisAsyncQueueProcessor analysisAsyncQueueProcessor; - private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; + public AnalysisAsyncProcessor(AnalysisTaskMessagePublisher analysisTaskMessagePublisher) { + this.analysisAsyncQueueProcessor = new AnalysisAsyncQueueProcessor(analysisTaskMessagePublisher); + } public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { - analysisTaskMessagePublisher.publish( - AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) - ); + analysisAsyncQueueProcessor.process(taskId, userId, mockApplyId, maxRetryCount); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java index eb770598..906b14bf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,7 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(AnalysisAsyncStatusResponse statusResponse) { - TaskStatus status = TaskStatus.valueOf(statusResponse.status()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + AnalysisAsyncTaskStatus status = AnalysisAsyncTaskStatus.valueOf(statusResponse.status()); + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index 9183603e..32b77267 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -1,107 +1,35 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -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.infrastructure.async.AnalysisAsyncTaskSweepCoordinator; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.EnumSet; +import java.time.Clock; -@Slf4j @Service -@RequiredArgsConstructor public class AnalysisAsyncSweepService { - - private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisService analysisService; - private final UserService userService; - private final TransactionTemplate transactionTemplate; - private final AnalysisQueueProperties analysisQueueProperties; - - public int sweepTimedOutTasks() { - int expiredCount = 0; - for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { - try { - expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); - } catch (RuntimeException e) { - log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); - } - } - return expiredCount; - } - - private int sweepTimedOutTask(String taskId) { - AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); - if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { - return 0; - } - - ExpirationDecision expirationDecision = resolveExpiration(task); - if (expirationDecision == null) { - return 0; - } - - releaseCreditIfNeeded(task); - analysisAsyncTaskService.markFailed( - task.getTaskId(), - expirationDecision.failureReason(), - expirationDecision.errorMessage(), - task.getRetryCount() + private final AnalysisAsyncTaskSweepCoordinator analysisAsyncTaskSweepCoordinator; + + public AnalysisAsyncSweepService( + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, + TransactionTemplate transactionTemplate, + AnalysisQueueProperties analysisQueueProperties, + Clock clock + ) { + this.analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( + analysisAsyncTaskRepository, + analysisAsyncTaskService, + analysisAsyncCreditCoordinator, + transactionTemplate, + analysisQueueProperties, + clock ); - return 1; } - private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { - LocalDateTime now = LocalDateTime.now(); - if (task.getStatus() == TaskStatus.PENDING - && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { - return new ExpirationDecision( - FailureReason.QUEUE_TIMEOUT, - "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." - ); - } - - LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); - if (task.getStatus() == TaskStatus.RUNNING - && isExpired(lastActivityAt, now, analysisQueueProperties.getProcessingTimeoutSeconds())) { - return new ExpirationDecision( - FailureReason.INTERNAL_ERROR, - "자소서 분석 작업이 처리 제한 시간을 초과했습니다." - ); - } - - return null; - } - - private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutSeconds) { - if (baseTime == null || timeoutSeconds <= 0) { - return false; - } - return Duration.between(baseTime, now).getSeconds() >= timeoutSeconds; - } - - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - - User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - - private record ExpirationDecision(FailureReason failureReason, String errorMessage) { + public int sweepTimedOutTasks() { + return analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index 012eaddc..74beed0a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -5,16 +5,13 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisProgressStepResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; 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.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -57,8 +54,7 @@ public class AnalysisAsyncTaskService { private final NotificationService notificationService; private final AsyncMetricsRecorder asyncMetricsRecorder; private final AnalysisQueueProperties analysisQueueProperties; - private final AnalysisService analysisService; - private final UserService userService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; private final AsyncProgressCalculator asyncProgressCalculator; @Transactional @@ -78,10 +74,21 @@ public Optional findActiveTask(Long userId, Long mockApplyId) return analysisAsyncTaskRepository.findFirstByUserIdAndMockApplyIdAndStatusInOrderByCreatedAtDesc( userId, mockApplyId, - EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING) + EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING) ); } + @Transactional(readOnly = true) + public Optional findRecoverablePublishFailureTask(Long userId, Long mockApplyId) { + return analysisAsyncTaskRepository + .findFirstByUserIdAndMockApplyIdAndStatusAndFailureReasonOrderByCreatedAtDesc( + userId, + mockApplyId, + AnalysisAsyncTaskStatus.FAILED, + AnalysisAsyncFailureReason.PUBLISH_FAILED + ); + } + @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { AnalysisAsyncTask task = getTask(taskId); @@ -95,20 +102,22 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public void markSuccess(String taskId, AnalysisResponse result) { AnalysisAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { return; } - if (task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId ); } - if (task.getStatus() == TaskStatus.FAILED) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { + if (!task.isRecoverablePublishFailure()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } } task.markSuccess(); recordProcessingMetric(task, "succeeded"); @@ -117,7 +126,7 @@ public void markSuccess(String taskId, AnalysisResponse result) { } @Transactional - public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); recordProcessingMetric(task, "retry"); task.markRetryScheduled(failureReason, errorMessage, retryCount); @@ -125,7 +134,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin } @Transactional - public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); recordProcessingMetric(task, "failed"); task.markFailed(failureReason, errorMessage, retryCount); @@ -144,13 +153,13 @@ public AnalysisAsyncCancelResponse cancelTask(Long userId, Long mockApplyId, Str throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); } - TaskStatus previousStatus = task.getStatus(); + AnalysisAsyncTaskStatus previousStatus = task.getStatus(); LocalDateTime previousCancelledAt = task.getCancelledAt(); task.requestCancel(); - boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; - boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + boolean cancelled = task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != AnalysisAsyncTaskStatus.CANCELLED && cancelled; if (newlyCancelled) { - releaseCreditIfNeeded(task); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); recordProcessingMetric(task, "cancelled"); } if (newlyCancelled || previousCancelledAt == null && cancelled) { @@ -183,8 +192,30 @@ public void markCreditReleased(String taskId) { getTask(taskId).markCreditReleased(); } + @Transactional + public ReopenPublishFailureResult reopenPublishFailureTask(String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findByIdForUpdate(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + if (task.isRecoverablePublishFailure()) { + task.reopenForRepublish(); + publishAfterCommit(toStatusResponse(task)); + return new ReopenPublishFailureResult(task, true); + } + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING + || task.getStatus() == AnalysisAsyncTaskStatus.RUNNING) { + return new ReopenPublishFailureResult(task, false); + } + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "재접수할 수 없는 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + @Transactional(readOnly = true) - public CreditStatus getCreditStatus(String taskId) { + public AnalysisAsyncCreditStatus getCreditStatus(String taskId) { return getTask(taskId).getCreditStatus(); } @@ -251,19 +282,10 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task, Ana DEFAULT_ESTIMATED_REMAINING_SECONDS )) .steps(buildSteps(task)) - .result(task.getStatus() == TaskStatus.SUCCEEDED ? result : null) + .result(task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED ? result : null) .build(); } - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); - task.markCreditReleased(); - } - private String resolveCurrentStep(AnalysisAsyncTask task) { return asyncProgressCalculator.resolveCurrentStep( toProgressStatus(task.getStatus()), @@ -281,7 +303,7 @@ private List buildSteps(AnalysisAsyncTask task) { ); } - private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + private AsyncTaskProgressStatus toProgressStatus(AnalysisAsyncTaskStatus status) { return AsyncTaskProgressStatus.valueOf(status.name()); } @@ -356,4 +378,7 @@ private void createFailureNotification(AnalysisAsyncTask task) { ); } + public record ReopenPublishFailureResult(AnalysisAsyncTask task, boolean reopened) { + } + } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java index 5f366e11..2399481c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisTaskMessage; import com.jobdri.jobdri_api.global.apiPayload.code.BaseErrorCode; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index 322fdb6c..0ab933f9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -1,351 +1,112 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; 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.dto.worker.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; -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.infrastructure.async.AnalysisAsyncWorkerBridge; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; -import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; -import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import com.jobdri.jobdri_api.global.logging.LoggingContext; -import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; @Service -@Slf4j -@RequiredArgsConstructor // 외부 분석 워커와 내부 분석 도메인 상태를 연결해 주는 브리지 서비스다. public class AnalysisWorkerBridgeService { - - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; - private final AnalysisService analysisService; - private final UserService userService; - private final WorkerTaskResultService workerTaskResultService; - private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; - private final ObjectMapper objectMapper; + private final AnalysisAsyncWorkerBridge analysisAsyncWorkerBridge; + + public AnalysisWorkerBridgeService( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisService analysisService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, + UserService userService, + WorkerTaskResultService workerTaskResultService, + AnalysisInputFingerprintProvider analysisInputFingerprintProvider, + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate + ) { + this.analysisAsyncWorkerBridge = new AnalysisAsyncWorkerBridge( + analysisAsyncTaskService, + analysisAsyncTaskRepository, + analysisService, + analysisAsyncCreditCoordinator, + userService, + workerTaskResultService, + analysisInputFingerprintProvider, + objectMapper, + transactionTemplate + ); + } @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); - try (var ignored = LoggingContext.with("worker.task.running", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, null))) { - log.info("Analysis worker marked task as running"); - } + analysisAsyncWorkerBridge.markRunning(taskId, workerId, retryCount, submittedAt); } @Transactional public void markRetry( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, Long queueLatencyMillis ) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); - analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); - try (var ignored = LoggingContext.with("worker.task.retry", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { - log.warn("Analysis worker scheduled retry: failureReason={}", failureReason); - } + analysisAsyncWorkerBridge.markRetry( + taskId, + failureReason, + errorMessage, + retryCount, + workerId, + queueLatencyMillis + ); } @Transactional public void failTask( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, Long queueLatencyMillis ) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - - analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); - releaseCreditIfNeeded(task); - analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); - try (var ignored = LoggingContext.with("worker.task.failed", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { - log.warn("Analysis worker failed task: failureReason={}", failureReason); - } + analysisAsyncWorkerBridge.failTask( + taskId, + failureReason, + errorMessage, + retryCount, + workerId, + queueLatencyMillis + ); } - @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { - AnalysisAsyncTask task = getTask(taskId); - rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); - if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." - ); - } - reserveCreditIfNeeded(task); - if (task.getExecutionContextSnapshot() != null) { - return readContextSnapshot(task); - } - - User user = userService.getUser(userId); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); - AnalysisWorkerContextResponse context = 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()), - CorpusReferenceContext.from(payload.retrievalContext()), - payload.similarJobPostings() - ); - task.captureExecutionSnapshot( - writeContextSnapshot(context), - analysisInputFingerprintProvider.create(payload) - ); - return context; + return analysisAsyncWorkerBridge.getContext(taskId, userId, mockApplyId); } @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.CANCELLED) { - workerTaskResultService.markDeliveryFailedIfPresent( - TaskType.ANALYSIS_COMPLETE, - taskId, - "취소된 자소서 분석 비동기 작업입니다." - ); - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); - } - workerTaskResultService.upsertGenerated( - TaskType.ANALYSIS_COMPLETE, - taskId, - new AnalysisWorkerResultStoreRequest(request.userId(), request.mockApplyId(), request.llmResponse()) - ); - if (task.getStatus() == TaskStatus.SUCCEEDED) { - workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); - return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); - } - if (task.getStatus() == TaskStatus.FAILED) { - workerTaskResultService.markDeliveryFailedIfPresent( - TaskType.ANALYSIS_COMPLETE, - taskId, - "이미 실패 처리된 자소서 분석 비동기 작업입니다." - ); - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); - } - - User user = userService.getUser(request.userId()); - AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( - user, - request.mockApplyId(), - contextSnapshot.similarJobPostings() - ).withAnswerSnapshots(contextSnapshot.questions().stream() - .filter(question -> question.answer() != null && !question.answer().isBlank()) - .map(question -> new AnalysisExecutionPayload.AnswerSnapshot( - question.questionId(), - question.answer() - )) - .toList()); - AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.finalizeAnalysis( - user, - request.mockApplyId(), - payload, - llmResponse, - task.getInputFingerprintSnapshot() - ); - analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); - confirmCreditIfNeeded(task); - analysisAsyncTaskService.markSuccess(taskId, response); - workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); - try (var ignored = LoggingContext.with( - "worker.task.completed", - null, - workerContext(taskId, "ANALYSIS", request.workerId(), task.getRetryCount(), request.queueLatencyMillis()) - )) { - log.info("Analysis worker completed task"); - } - return response; + return analysisAsyncWorkerBridge.completeTask(taskId, request); } @Transactional public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { - AnalysisAsyncTask task = getTask(taskId); - rejectIfCancelled(task, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId); - if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 결과 저장 요청 정보가 작업 정보와 일치하지 않습니다." - ); - } - workerTaskResultService.upsertGenerated(TaskType.ANALYSIS_COMPLETE, taskId, request); - try (var ignored = LoggingContext.with("worker.result.stored", null, workerContext(taskId, "ANALYSIS", null, task.getRetryCount(), null))) { - log.info("Analysis worker result stored"); - } + analysisAsyncWorkerBridge.storeGeneratedResult(taskId, request); } @Transactional(readOnly = true) public WorkerTaskResultResponse getStoredResult(String taskId) { - getTask(taskId); - return workerTaskResultService.get(taskId); - } - - private List toQuestionItems(List questions) { - return questions.stream() - .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( - question.getId(), - question.getContent(), - question.getAnswer(), - question.getLimit() - )) - .toList(); - } - - private String writeContextSnapshot(AnalysisWorkerContextResponse context) { - try { - return objectMapper.writeValueAsString(context); - } catch (JsonProcessingException exception) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다." - ); - } - } - - private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) { - if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId() - ); - } - try { - return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class); - } catch (JsonProcessingException exception) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId() - ); - } - } - - private AnalysisAsyncTask getTask(String taskId) { - return analysisAsyncTaskRepository.findById(taskId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, - "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId - )); - } - - private boolean isTerminal(AnalysisAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; - } - - private void rejectIfCancelled(AnalysisAsyncTask task, String message) { - if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); - } - } - - private void reserveCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.NONE) { - return; - } - - User user = userService.getUser(task.getUserId()); - String creditReferenceId = "analysisTaskId=" + task.getTaskId(); - analysisService.deductAnalysisCredit(user, creditReferenceId); - analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); - } - - private void confirmCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); - } - - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - - private Map workerContext( - String taskId, - String taskType, - String workerId, - Integer retryCount, - Long queueLatencyMillis - ) { - Map context = new LinkedHashMap<>(); - context.put(LoggingMdcKeys.TASK_ID, taskId); - context.put(LoggingMdcKeys.TASK_TYPE, taskType); - if (workerId != null) { - context.put(LoggingMdcKeys.WORKER_ID, workerId); - } - if (retryCount != null) { - context.put(LoggingMdcKeys.RETRY_COUNT, String.valueOf(retryCount)); - } - if (queueLatencyMillis != null) { - context.put(LoggingMdcKeys.QUEUE_LATENCY_MILLIS, String.valueOf(queueLatencyMillis)); - } - return context; + return analysisAsyncWorkerBridge.getStoredResult(taskId); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java new file mode 100644 index 00000000..6799a265 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java @@ -0,0 +1,32 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.payment.service.CreditService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AnalysisCreditService { + + private final CreditService creditService; + + public String createSyncReferenceId(Long mockApplyId, String inputFingerprint) { + return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; + } + + public String createAsyncReferenceId(String taskId, int creditReferenceVersion) { + return "analysisTaskId=" + taskId + ":creditVersion=" + creditReferenceVersion; + } + + @Transactional + public void deduct(User user, String referenceId) { + creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); + } + + @Transactional + public void refund(User user, String referenceId) { + creditService.refund(user, 1, "자소서 분석 크레딧 환불", referenceId); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index 4992db01..4a8aaec1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -2,9 +2,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java new file mode 100644 index 00000000..aa23ac37 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java @@ -0,0 +1,40 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; + +import java.util.List; + +public record AnalysisPreparationResult( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + RetrievalContext retrievalContext, + List similarJobPostings +) { + public AnalysisPreparationResult { + questions = questions == null ? List.of() : List.copyOf(questions); + answeredQuestions = answeredQuestions == null ? List.of() : List.copyOf(answeredQuestions); + similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); + } + + public AnalysisExecutionPayload toExecutionPayload() { + return new AnalysisExecutionPayload( + userId, + mockApplyId, + jobPosting, + questions, + answeredQuestions, + jobCategoryEvaluationCriteria, + retrievalContext, + similarJobPostings + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java new file mode 100644 index 00000000..45440697 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java @@ -0,0 +1,146 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisPreparationService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final CorpusRetrievalService corpusRetrievalService; + private final JobPostingRagContextAssembler jobPostingRagContextAssembler; + + public AnalysisPreparationResult prepare(User user, Long mockApplyId) { + PreparedMockApplyContext context = prepareMockApplyContext(user, mockApplyId); + return prepare( + user.getId(), + context, + retrieveAnalysisReferences(context.mockApply().getJobPosting(), context.answeredQuestions()), + jobPostingRagContextAssembler.assemble(context.mockApply().getJobPosting().getId()) + ); + } + + public AnalysisPreparationResult prepare( + User user, + Long mockApplyId, + List similarJobPostings + ) { + PreparedMockApplyContext context = prepareMockApplyContext(user, mockApplyId); + return prepare( + user.getId(), + context, + new RetrievalContext(List.of(), List.of()), + similarJobPostings + ); + } + + private AnalysisPreparationResult prepare( + Long userId, + PreparedMockApplyContext context, + RetrievalContext retrievalContext, + List similarJobPostings + ) { + initializePreparationHierarchy(context.mockApply()); + JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider + .findByMiddleName(context.mockApply().getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) + .orElse(null); + + return new AnalysisPreparationResult( + userId, + context.mockApply().getId(), + context.mockApply().getJobPosting(), + context.questions(), + context.answeredQuestions(), + evaluationCriteria, + retrievalContext, + similarJobPostings + ); + } + + private PreparedMockApplyContext prepareMockApplyContext(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + return new PreparedMockApplyContext( + mockApply, + questions, + answeredQuestionsOrThrow(questions) + ); + } + + private void initializePreparationHierarchy(MockApply mockApply) { + Hibernate.initialize(mockApply.getJobPosting()); + Hibernate.initialize(mockApply.getJobPosting().getCompany()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification().getMiddleClassification()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification()); + } + + private List answeredQuestionsOrThrow(List questions) { + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + return answeredQuestions; + } + + private RetrievalContext retrieveAnalysisReferences(JobPosting jobPosting, List answeredQuestions) { + try { + return corpusRetrievalService.retrieveForAnalysis(jobPosting, answeredQuestions); + } catch (Exception exception) { + log.warn("자소서 분석 Curated Corpus retrieval 실패. fallback without references. message={}", exception.getMessage()); + log.debug("analysis Curated Corpus retrieval exception", exception); + return new RetrievalContext(List.of(), List.of()); + } + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + private record PreparedMockApplyContext( + MockApply mockApply, + List questions, + List answeredQuestions + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java new file mode 100644 index 00000000..cf5d5ff6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -0,0 +1,745 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.COMPLETENESS_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.IMPACT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.JOB_FIT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisResultPersistenceService { + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; + private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { + }; + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final AnalysisRepository analysisRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final ObjectMapper objectMapper; + + @Transactional + public AnalysisResponse finalizeAnalysis( + MockApply mockApply, + List questions, + List payloadSnapshots, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + MockApply lockedMockApply = mockApplyRepository.findByIdForUpdate(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApply.getId() + )); + VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payloadSnapshots); + validateRequiredScores(llmResponse); + int jobFit = validateScore("jobFit", llmResponse.jobFit()); + int impact = validateScore("impact", llmResponse.impact()); + int completeness = validateScore("completeness", llmResponse.completeness()); + List keyStrengths = buildHighlights(llmResponse.keyStrengths()); + List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); + List missingKeywords = buildMissingKeywords( + lockedMockApply.getJobPosting(), + answerSnapshot.combinedAnswers(), + llmResponse + ); + replaceExistingAnalysis(lockedMockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + lockedMockApply, + calculateScore(jobFit, impact, completeness), + jobFit, + impact, + completeness, + normalizeFeedback(llmResponse.feedback()), + serializeMissingKeywords(missingKeywords), + serializeHighlights(keyStrengths, "keyStrengths"), + serializeHighlights(keyWeaknesses, "keyWeaknesses"), + inputFingerprint + )); + + List questionAnalyses = buildQuestionAnalyses( + analysis, + questions, + answerSnapshot.answerByQuestionId(), + llmResponse + ); + questionAnalysisRepository.saveAll(questionAnalyses); + lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); + mockApplyRepository.flush(); + + return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + } + + @Transactional + public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_NOT_FOUND, + "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + sanitizeAndPersistAnalysisPayload(analysis, true) + ); + } + + @Transactional(readOnly = true) + public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analysis) { + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + sanitizeAndPersistAnalysisPayload(analysis, false) + ); + } + + private void replaceExistingAnalysis(MockApply mockApply) { + Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); + if (existingAnalysis.isEmpty()) { + return; + } + + Analysis analysis = existingAnalysis.get(); + mockApply.clearAnalysis(); + questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); + analysisRepository.delete(analysis); + analysisRepository.flush(); + } + + private VerifiedAnswerSnapshot verifyAnswerSnapshot( + List databaseQuestions, + List payloadSnapshots + ) { + String databaseFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprintFromQuestions(databaseQuestions); + String payloadFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprint(payloadSnapshots); + if (!databaseFingerprint.equals(payloadFingerprint)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." + ); + } + + List immutableSnapshots = List.copyOf(payloadSnapshots); + Map answerByQuestionId = new LinkedHashMap<>(); + for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { + if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { + continue; + } + if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() + ); + } + } + return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); + } + + private record VerifiedAnswerSnapshot( + List answers, + Map answerByQuestionId + ) { + private String combinedAnswers() { + return answers.stream() + .map(AnalysisExecutionPayload.AnswerSnapshot::answer) + .filter(StringUtils::hasText) + .collect(Collectors.joining("\n")); + } + } + + private List buildQuestionAnalyses( + Analysis analysis, + List questions, + Map answerByQuestionId, + AnalysisLlmResponse llmResponse + ) { + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + List result = new ArrayList<>(); + Map analysisCountByQuestionId = new HashMap<>(); + Map nextSearchIndexByQuestionId = new HashMap<>(); + Set seenSentences = new HashSet<>(); + Set fabricatedQuestionIds = new HashSet<>(); + Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); + + if (llmResponse.questionAnalyses() == null) { + return result; + } + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + + Question question = questionMap.get(item.questionId()); + if (question == null) { + continue; + } + + String answer = answerByQuestionId.get(item.questionId()); + if (!StringUtils.hasText(answer)) { + continue; + } + QuestionAnalysisStatus status = parseStatus(item.status()); + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + if (status == QuestionAnalysisStatus.PROVEN + && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( + item.sentence(), + item.reason() + )) { + continue; + } + int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); + if (currentCount >= MAX_ANALYSES_PER_QUESTION) { + continue; + } + String sentence = item.sentence(); + if (status != QuestionAnalysisStatus.PROVEN + && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + continue; + } + String dedupeKey = question.getId() + ":" + sentence.trim(); + if (!seenSentences.add(dedupeKey)) { + continue; + } + int start = findNextSentenceStart( + answer, + sentence, + nextSearchIndexByQuestionId.getOrDefault(question.getId(), 0) + ); + if (start < 0) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !fabricatedQuestionIds.add(question.getId())) { + continue; + } + nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); + analysisCountByQuestionId.put(question.getId(), currentCount + 1); + + result.add(QuestionAnalysis.create( + question, + analysis, + sentence, + defaultString(item.reason()), + normalizeImprovement(sentence, answer, item.improvement(), status), + status, + start, + start + sentence.length() + )); + } + + return result; + } + + private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.keyStrengths() == null) { + return Set.of(); + } + return llmResponse.keyStrengths().stream() + .filter(item -> item != null && StringUtils.hasText(item.quote())) + .map(item -> normalizeKeyword(item.quote())) + .collect(Collectors.toSet()); + } + + private AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses, + AnalysisResultPayload resultPayload + ) { + Map questionById = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + Map> analysesByQuestionId = questionAnalyses.stream() + .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( + questionAnalysis, + questionById.get(questionAnalysis.getQuestion().getId()) + )) + .collect(Collectors.groupingBy( + questionAnalysis -> questionAnalysis.getQuestion().getId(), + Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) + )); + + List questionResponses = questions.stream() + .sorted(Comparator.comparing(Question::getId)) + .map(question -> AnalysisQuestionResponse.of( + question, + analysesByQuestionId.getOrDefault(question.getId(), List.of()) + )) + .toList(); + + return AnalysisResponse.of( + analysis, + mockApply.getStatus(), + mockApplyRepository.calculateSequence(mockApply), + resultPayload.keyStrengths(), + resultPayload.keyWeaknesses(), + resultPayload.missingKeywords(), + questionResponses + ); + } + + private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { + if (questionAnalysis == null || question == null) { + return false; + } + if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { + return false; + } + String answer = question.getAnswer(); + String sentence = questionAnalysis.getSentence(); + int start = questionAnalysis.getStart(); + int end = questionAnalysis.getEnd(); + if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { + return false; + } + if (start < 0 || end <= start || end > answer.length()) { + return false; + } + return answer.substring(start, end).equals(sentence); + } + + private AnalysisResultPayload analysisResultPayload(Analysis analysis) { + List keyStrengths = readHighlights(analysis, analysis.getKeyStrengthsJson(), "keyStrengths"); + return new AnalysisResultPayload( + keyStrengths, + removeOverlappingHighlights( + readHighlights(analysis, analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + keyStrengths + ), + readMissingKeywords(analysis) + ); + } + + private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis, boolean persistIfChanged) { + AnalysisResultPayload payload = analysisResultPayload(analysis); + String sanitizedKeyStrengthsJson = serializeHighlights(payload.keyStrengths(), "keyStrengths"); + String sanitizedKeyWeaknessesJson = serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses"); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()))) { + analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); + } + return payload; + } + + private List buildHighlights(List items) { + return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); + } + + private List buildNonOverlappingHighlights( + List items, + List existingHighlights + ) { + return sanitizeHighlights( + removeOverlappingRawHighlights(items, existingHighlights), + AnalysisLlmResponse.HighlightItem::title, + AnalysisLlmResponse.HighlightItem::quote + ); + } + + private List buildMissingKeywords( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source.get(), + jobPosting == null ? "" : jobPosting.getTask(), + jobPosting == null ? "" : jobPosting.getRequirement() + )) { + continue; + } + if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + private String serializeMissingKeywords(List missingKeywords) { + try { + return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); + return "[]"; + } + } + + private String serializeHighlights(List highlights, String fieldName) { + try { + return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); + return "[]"; + } + } + + private List readHighlights(Analysis analysis, String json, String fieldName) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + + try { + List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); + return sanitizeStoredHighlights(highlights); + } catch (Exception e) { + log.warn( + "Failed to deserialize {}. analysisId={}, fallback to empty array.", + fieldName, + analysis == null ? null : analysis.getId(), + e + ); + return List.of(); + } + } + + private List readMissingKeywords(Analysis analysis) { + if (!StringUtils.hasText(analysis.getMissingKeywordsJson())) { + return List.of(); + } + + try { + List missingKeywords = objectMapper.readValue( + analysis.getMissingKeywordsJson(), + MISSING_KEYWORDS_TYPE + ); + return sanitizeStoredMissingKeywords(missingKeywords); + } catch (Exception e) { + log.warn( + "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", + analysis.getId(), + e + ); + return List.of(); + } + } + + private List sanitizeStoredMissingKeywords(List missingKeywords) { + if (missingKeywords == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (MissingKeywordResponse item : missingKeywords) { + if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, item.source())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private List sanitizeStoredHighlights(List highlights) { + return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); + } + + private List sanitizeHighlights( + List items, + Function titleExtractor, + Function quoteExtractor + ) { + if (items == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenHighlights = new HashSet<>(); + + for (T item : items) { + if (item == null) { + continue; + } + + String rawTitle = titleExtractor.apply(item); + String rawQuote = quoteExtractor.apply(item); + if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { + continue; + } + + String title = rawTitle.trim(); + String quote = rawQuote.trim(); + if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); + if (!seenHighlights.add(dedupeKey)) { + continue; + } + + result.add(new AnalysisHighlightResponse(title, quote)); + if (result.size() >= MAX_HIGHLIGHTS) { + break; + } + } + + return result; + } + + private List removeOverlappingHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private List removeOverlappingRawHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private Set normalizedHighlightQuotes(List highlights) { + if (highlights == null || highlights.isEmpty()) { + return Set.of(); + } + return highlights.stream() + .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) + .map(highlight -> normalizeKeyword(highlight.quote())) + .collect(Collectors.toSet()); + } + + private record AnalysisResultPayload( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } + + private void validateRequiredScores(AnalysisLlmResponse llmResponse) { + if (llmResponse == null + || llmResponse.jobFit() == null + || llmResponse.impact() == null + || llmResponse.completeness() == null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." + ); + } + } + + private int validateScore(String fieldName, Integer score) { + if (score == null || score < MIN_SCORE || score > MAX_SCORE) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." + ); + } + return score; + } + + private int calculateScore(int jobFit, int impact, int completeness) { + return (int) Math.round( + jobFit * JOB_FIT_WEIGHT + + impact * IMPACT_WEIGHT + + completeness * COMPLETENESS_WEIGHT + ); + } + + private int findNextSentenceStart(String answer, String sentence, int fromIndex) { + int start = answer.indexOf(sentence, Math.max(0, fromIndex)); + if (start >= 0) { + return start; + } + return answer.indexOf(sentence); + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); + } + + private QuestionAnalysisStatus parseStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + + String normalizedStatus = status.trim().toUpperCase(); + if ("GOOD".equals(normalizedStatus)) { + return QuestionAnalysisStatus.PROVEN; + } + if ("NEEDS_IMPROVEMENT".equals(normalizedStatus)) { + return QuestionAnalysisStatus.MENTIONED; + } + if ("RISK".equals(normalizedStatus)) { + return QuestionAnalysisStatus.FABRICATED; + } + + try { + return QuestionAnalysisStatus.valueOf(normalizedStatus); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 7f279a68..c2d7fff3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -1,149 +1,96 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; -import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; -import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.payment.service.CreditService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.COMPLETENESS_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.IMPACT_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.JOB_FIT_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; @Service @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) -// 자소서 분석의 핵심 비즈니스 로직과 결과 저장을 담당하는 메인 서비스다. +// 자소서 분석 실행 흐름을 조율하는 오케스트레이션 서비스다. public class AnalysisService { - private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { - }; - private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { - }; + private static final ConcurrentMap ANALYSIS_LOCKS = new ConcurrentHashMap<>(); private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; - private final QuestionAnalysisRepository questionAnalysisRepository; private final JobPostingService jobPostingService; - private final AnalysisAiClient analysisAiClient; - private final CreditService creditService; - private final ObjectMapper objectMapper; - private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final AnalysisGenerator analysisGenerator; + private final AnalysisCreditService analysisCreditService; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; - private final CorpusRetrievalService corpusRetrievalService; - private final JobPostingRagContextAssembler jobPostingRagContextAssembler; + private final AnalysisPreparationService analysisPreparationService; + private final AnalysisResultPersistenceService analysisResultPersistenceService; + private final ObjectProvider selfProvider; - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") public AnalysisResponse analyze(User user, Long mockApplyId) { - validateAnalysisRequest(user, mockApplyId); - AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); - String inputFingerprint = analysisInputFingerprintProvider.create(payload); - lockOwnedMockApply(user, mockApplyId); - AnalysisResponse cachedResponse = reuseExistingAnalysisIfSameInput(user, mockApplyId, inputFingerprint); - if (cachedResponse != null) { - return cachedResponse; - } + ReentrantLock analysisLock = ANALYSIS_LOCKS.computeIfAbsent(mockApplyId, ignored -> new ReentrantLock()); + analysisLock.lock(); + try { + AnalysisService self = selfProvider.getObject(); + AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); + String inputFingerprint = analysisInputFingerprintProvider.create(payload); + AnalysisResponse cachedResponse = self.lockAndReuseExistingAnalysis(user, mockApplyId, inputFingerprint); + if (cachedResponse != null) { + return cachedResponse; + } - String referenceId = analysisCreditReferenceId(mockApplyId, inputFingerprint); - deductAnalysisCredit(user, referenceId); + String referenceId = analysisCreditService.createSyncReferenceId(mockApplyId, inputFingerprint); + boolean creditDeducted = false; - try { - AnalysisLlmResponse llmResponse = executeAnalysis(payload); - AnalysisResponse response = finalizeAnalysis(user, mockApplyId, payload, llmResponse); - return response; - } catch (RuntimeException e) { - refundAnalysisCredit(user, referenceId); - throw e; + try { + AnalysisLlmResponse llmResponse = executeAnalysis(payload); + analysisCreditService.deduct(user, referenceId); + creditDeducted = true; + return self.finalizeAnalysis(user, mockApplyId, payload, llmResponse); + } catch (RuntimeException e) { + if (creditDeducted) { + analysisCreditService.refund(user, referenceId); + } + throw e; + } + } finally { + analysisLock.unlock(); + if (!analysisLock.hasQueuedThreads()) { + ANALYSIS_LOCKS.remove(mockApplyId, analysisLock); + } } } @Transactional(readOnly = true) public void validateAnalysisRequest(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - answeredQuestionsOrThrow(questions); - } - - @Transactional - public void deductAnalysisCredit(User user, String referenceId) { - creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); - } - - @Transactional - public void refundAnalysisCredit(User user, String referenceId) { - creditService.refund(user, 1, "자소서 분석 크레딧 환불", referenceId); + analysisPreparationService.prepare(user, mockApplyId); } @Transactional(readOnly = true) public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); - return prepareAnalysisExecution( - user, - mockApply, - questions, - answeredQuestions, - retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), - jobPostingRagContextAssembler.assemble(mockApply.getJobPosting().getId()) - ); + return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); } @Transactional(readOnly = true) @@ -152,61 +99,17 @@ public AnalysisExecutionPayload prepareAnalysisExecution( Long mockApplyId, List similarJobPostings ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); - return prepareAnalysisExecution( - user, - mockApply, - questions, - answeredQuestions, - new RetrievalContext(List.of(), List.of()), - similarJobPostings - ); - } - - private AnalysisExecutionPayload prepareAnalysisExecution( - User user, - MockApply mockApply, - List questions, - List answeredQuestions, - RetrievalContext retrievalContext, - List similarJobPostings - ) { - // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); - JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider - .findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) - .orElse(null); - - return new AnalysisExecutionPayload( - user.getId(), - mockApply.getId(), - mockApply.getJobPosting(), - List.copyOf(questions), - List.copyOf(answeredQuestions), - evaluationCriteria, - retrievalContext, - similarJobPostings - ); + return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - private List answeredQuestionsOrThrow(List questions) { - List answeredQuestions = questions.stream() - .filter(question -> StringUtils.hasText(question.getAnswer())) - .toList(); - if (answeredQuestions.isEmpty()) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석할 자소서 답변이 1개 이상 필요합니다." - ); - } - return answeredQuestions; + public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + return analysisGenerator.analyze(payload); } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { - return analysisAiClient.analyze(payload); + @Transactional + public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + MockApply mockApply = lockOwnedMockApply(user, mockApplyId); + return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } @Transactional @@ -235,86 +138,25 @@ public AnalysisResponse finalizeAnalysis( ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payload.answerSnapshots()); - validateRequiredScores(llmResponse); - int jobFit = validateScore("jobFit", llmResponse.jobFit()); - int impact = validateScore("impact", llmResponse.impact()); - int completeness = validateScore("completeness", llmResponse.completeness()); - List keyStrengths = buildHighlights(llmResponse.keyStrengths()); - List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); - List missingKeywords = buildMissingKeywords( - mockApply.getJobPosting(), - answerSnapshot.combinedAnswers(), - llmResponse - ); - replaceExistingAnalysis(mockApply); - - Analysis analysis = analysisRepository.save(Analysis.create( + return analysisResultPersistenceService.finalizeAnalysis( mockApply, - calculateScore(jobFit, impact, completeness), - jobFit, - impact, - completeness, - normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords), - serializeHighlights(keyStrengths, "keyStrengths"), - serializeHighlights(keyWeaknesses, "keyWeaknesses"), - inputFingerprint - )); - - List questionAnalyses = buildQuestionAnalyses( - analysis, questions, - answerSnapshot.answerByQuestionId(), - llmResponse + payload.answerSnapshots(), + llmResponse, + inputFingerprint ); - questionAnalysisRepository.saveAll(questionAnalyses); - mockApply.updateStatus(MockApplyStatus.COMPLETED); - - return toResponse(mockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); } @Transactional public AnalysisResponse getAnalysis(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_NOT_FOUND, - "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() - )); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List questionAnalyses = - questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - - return toResponse( - mockApply, - analysis, - questions, - questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis) - ); + return analysisResultPersistenceService.getPersistedAnalysis(getOwnedMockApply(user, mockApplyId)); } @Transactional public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPostingId, int sequence) { JobPosting jobPosting = jobPostingService.getOwnedJobPosting(user, jobPostingId); MockApply mockApply = resolveMockApplyBySequence(jobPosting, sequence); - Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_NOT_FOUND, - "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() - )); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List questionAnalyses = - questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - - return toResponse( - mockApply, - analysis, - questions, - questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis) - ); + return analysisResultPersistenceService.getPersistedAnalysis(mockApply); } @Transactional(readOnly = true) @@ -360,16 +202,6 @@ private MockApply resolveMockApplyBySequence(JobPosting jobPosting, int sequence ); } - private RetrievalContext retrieveAnalysisReferences(JobPosting jobPosting, List answeredQuestions) { - try { - return corpusRetrievalService.retrieveForAnalysis(jobPosting, answeredQuestions); - } catch (Exception exception) { - log.warn("자소서 분석 Curated Corpus retrieval 실패. fallback without references. message={}", exception.getMessage()); - log.debug("analysis Curated Corpus retrieval exception", exception); - return new RetrievalContext(List.of(), List.of()); - } - } - private MockApply lockOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findByIdForUpdate(mockApplyId) .orElseThrow(() -> new GeneralException( @@ -384,514 +216,13 @@ private MockApply lockOwnedMockApply(User user, Long mockApplyId) { return mockApply; } - private void replaceExistingAnalysis(MockApply mockApply) { - Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); - if (existingAnalysis.isEmpty()) { - return; - } - - Analysis analysis = existingAnalysis.get(); - mockApply.clearAnalysis(); - questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); - analysisRepository.delete(analysis); - analysisRepository.flush(); - } - - private VerifiedAnswerSnapshot verifyAnswerSnapshot( - List databaseQuestions, - List payloadSnapshots - ) { - String databaseFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprintFromQuestions(databaseQuestions); - String payloadFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprint(payloadSnapshots); - if (!databaseFingerprint.equals(payloadFingerprint)) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." - ); - } - - List immutableSnapshots = List.copyOf(payloadSnapshots); - Map answerByQuestionId = new LinkedHashMap<>(); - for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { - if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { - continue; - } - if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() - ); - } - } - return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); - } - - private AnalysisResponse reuseExistingAnalysisIfSameInput(User user, Long mockApplyId, String inputFingerprint) { - return analysisRepository.findByMockApplyId(mockApplyId) + private AnalysisResponse reuseExistingAnalysisIfSameInput(MockApply mockApply, String inputFingerprint) { + return analysisRepository.findByMockApplyId(mockApply.getId()) .filter(analysis -> inputFingerprint.equals(analysis.getInputFingerprint())) - .map(analysis -> getAnalysis(user, mockApplyId)) + .map(analysis -> analysisResultPersistenceService.getPersistedAnalysis(mockApply, analysis)) .orElse(null); } - private String analysisCreditReferenceId(Long mockApplyId, String inputFingerprint) { - return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; - } - - private record VerifiedAnswerSnapshot( - List answers, - Map answerByQuestionId - ) { - private String combinedAnswers() { - return answers.stream() - .map(AnalysisExecutionPayload.AnswerSnapshot::answer) - .filter(StringUtils::hasText) - .collect(Collectors.joining("\n")); - } - } - - private List buildQuestionAnalyses( - Analysis analysis, - List questions, - Map answerByQuestionId, - AnalysisLlmResponse llmResponse - ) { - Map questionMap = questions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - List result = new ArrayList<>(); - Map analysisCountByQuestionId = new HashMap<>(); - Map nextSearchIndexByQuestionId = new HashMap<>(); - Set seenSentences = new HashSet<>(); - Set fabricatedQuestionIds = new HashSet<>(); - Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); - - if (llmResponse.questionAnalyses() == null) { - return result; - } - - for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { - if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { - continue; - } - - Question question = questionMap.get(item.questionId()); - if (question == null) { - continue; - } - - String answer = answerByQuestionId.get(item.questionId()); - if (!StringUtils.hasText(answer)) { - continue; - } - QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null || status == QuestionAnalysisStatus.MISSING) { - continue; - } - if (status == QuestionAnalysisStatus.PROVEN - && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { - continue; - } - if (status == QuestionAnalysisStatus.FABRICATED - && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( - item.sentence(), - item.reason() - )) { - continue; - } - int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); - if (currentCount >= MAX_ANALYSES_PER_QUESTION) { - continue; - } - String sentence = item.sentence(); - if (status != QuestionAnalysisStatus.PROVEN - && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { - continue; - } - String dedupeKey = question.getId() + ":" + sentence.trim(); - if (!seenSentences.add(dedupeKey)) { - continue; - } - int start = findNextSentenceStart( - answer, - sentence, - nextSearchIndexByQuestionId.getOrDefault(question.getId(), 0) - ); - if (start < 0) { - continue; - } - if (status == QuestionAnalysisStatus.FABRICATED - && !fabricatedQuestionIds.add(question.getId())) { - continue; - } - nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); - analysisCountByQuestionId.put(question.getId(), currentCount + 1); - - result.add(QuestionAnalysis.create( - question, - analysis, - sentence, - defaultString(item.reason()), - normalizeImprovement(sentence, answer, item.improvement(), status), - status, - start, - start + sentence.length() - )); - } - - return result; - } - - private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { - if (llmResponse == null || llmResponse.keyStrengths() == null) { - return Set.of(); - } - return llmResponse.keyStrengths().stream() - .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) - .collect(Collectors.toSet()); - } - - private AnalysisResponse toResponse( - MockApply mockApply, - Analysis analysis, - List questions, - List questionAnalyses, - AnalysisResultPayload resultPayload - ) { - Map questionById = questions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - Map> analysesByQuestionId = questionAnalyses.stream() - .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( - questionAnalysis, - questionById.get(questionAnalysis.getQuestion().getId()) - )) - .collect(Collectors.groupingBy( - questionAnalysis -> questionAnalysis.getQuestion().getId(), - Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) - )); - - List questionResponses = questions.stream() - .sorted(Comparator.comparing(Question::getId)) - .map(question -> AnalysisQuestionResponse.of( - question, - analysesByQuestionId.getOrDefault(question.getId(), List.of()) - )) - .toList(); - - return AnalysisResponse.of( - analysis, - mockApply.getStatus(), - mockApplyRepository.calculateSequence(mockApply), - resultPayload.keyStrengths(), - resultPayload.keyWeaknesses(), - resultPayload.missingKeywords(), - questionResponses - ); - } - - private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { - if (questionAnalysis == null || question == null) { - return false; - } - if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { - return false; - } - String answer = question.getAnswer(); - String sentence = questionAnalysis.getSentence(); - int start = questionAnalysis.getStart(); - int end = questionAnalysis.getEnd(); - if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { - return false; - } - if (start < 0 || end <= start || end > answer.length()) { - return false; - } - return answer.substring(start, end).equals(sentence); - } - - private AnalysisResultPayload analysisResultPayload(Analysis analysis) { - List keyStrengths = - readHighlights(analysis, analysis == null ? null : analysis.getKeyStrengthsJson(), "keyStrengths"); - return new AnalysisResultPayload( - keyStrengths, - removeOverlappingHighlights( - readHighlights(analysis, analysis == null ? null : analysis.getKeyWeaknessesJson(), "keyWeaknesses"), - keyStrengths - ), - readMissingKeywords(analysis) - ); - } - - private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis) { - AnalysisResultPayload payload = analysisResultPayload(analysis); - if (analysis != null) { - analysis.updateHighlightsJson( - serializeHighlights(payload.keyStrengths(), "keyStrengths"), - serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses") - ); - } - return payload; - } - - private List buildHighlights(List items) { - return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); - } - - private List buildNonOverlappingHighlights( - List items, - List existingHighlights - ) { - return sanitizeHighlights( - removeOverlappingRawHighlights(items, existingHighlights), - AnalysisLlmResponse.HighlightItem::title, - AnalysisLlmResponse.HighlightItem::quote - ); - } - - private List buildMissingKeywords( - JobPosting jobPosting, - String combinedAnswers, - AnalysisLlmResponse llmResponse - ) { - if (llmResponse == null || llmResponse.missingKeywords() == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { - if (item == null || !StringUtils.hasText(item.keyword())) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - Optional source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } - if (!AnalysisSanitizationRules.isValidMissingKeyword( - keyword, - source.get(), - jobPosting == null ? "" : jobPosting.getTask(), - jobPosting == null ? "" : jobPosting.getRequirement() - )) { - continue; - } - if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private String normalizeKeyword(String keyword) { - return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); - } - - private String serializeMissingKeywords(List missingKeywords) { - try { - return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); - return "[]"; - } - } - - private String serializeHighlights(List highlights, String fieldName) { - try { - return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); - return "[]"; - } - } - - private List readHighlights(Analysis analysis, String json, String fieldName) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - - try { - List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); - return sanitizeStoredHighlights(highlights); - } catch (Exception e) { - log.warn( - "Failed to deserialize {}. analysisId={}, fallback to empty array.", - fieldName, - analysis == null ? null : analysis.getId(), - e - ); - return List.of(); - } - } - - private List readMissingKeywords(Analysis analysis) { - if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { - return List.of(); - } - - try { - List missingKeywords = objectMapper.readValue( - analysis.getMissingKeywordsJson(), - MISSING_KEYWORDS_TYPE - ); - return sanitizeStoredMissingKeywords(missingKeywords); - } catch (Exception e) { - log.warn( - "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", - analysis.getId(), - e - ); - return List.of(); - } - } - - private List sanitizeStoredMissingKeywords(List missingKeywords) { - if (missingKeywords == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (MissingKeywordResponse item : missingKeywords) { - if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, item.source())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private List sanitizeStoredHighlights(List highlights) { - return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); - } - - private List sanitizeHighlights( - List items, - Function titleExtractor, - Function quoteExtractor - ) { - if (items == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenHighlights = new HashSet<>(); - - for (T item : items) { - if (item == null) { - continue; - } - - String rawTitle = titleExtractor.apply(item); - String rawQuote = quoteExtractor.apply(item); - if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { - continue; - } - - String title = rawTitle.trim(); - String quote = rawQuote.trim(); - if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); - if (!seenHighlights.add(dedupeKey)) { - continue; - } - - result.add(new AnalysisHighlightResponse(title, quote)); - if (result.size() >= MAX_HIGHLIGHTS) { - break; - } - } - - return result; - } - - private List removeOverlappingHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private List removeOverlappingRawHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private Set normalizedHighlightQuotes(List highlights) { - if (highlights == null || highlights.isEmpty()) { - return Set.of(); - } - return highlights.stream() - .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) - .map(highlight -> normalizeKeyword(highlight.quote())) - .collect(Collectors.toSet()); - } - - private record AnalysisResultPayload( - List keyStrengths, - List keyWeaknesses, - List missingKeywords - ) { - } - private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( @@ -905,91 +236,4 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } - - private void validateRequiredScores(AnalysisLlmResponse llmResponse) { - if (llmResponse == null - || llmResponse.jobFit() == null - || llmResponse.impact() == null - || llmResponse.completeness() == null) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." - ); - } - } - - private int validateScore(String fieldName, Integer score) { - if (score == null || score < MIN_SCORE || score > MAX_SCORE) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." - ); - } - return score; - } - - private int calculateScore(int jobFit, int impact, int completeness) { - return (int) Math.round( - jobFit * JOB_FIT_WEIGHT - + impact * IMPACT_WEIGHT - + completeness * COMPLETENESS_WEIGHT - ); - } - - private int findNextSentenceStart(String answer, String sentence, int fromIndex) { - int start = answer.indexOf(sentence, Math.max(0, fromIndex)); - if (start >= 0) { - return start; - } - return answer.indexOf(sentence); - } - - private String normalizeFeedback(String feedback) { - if (StringUtils.hasText(feedback)) { - return feedback; - } - return "자소서 분석 결과를 확인해주세요."; - } - - private String defaultString(String value) { - return value == null ? "" : value; - } - - private String normalizeImprovement( - String sentence, - String answer, - String improvement, - QuestionAnalysisStatus status - ) { - return AnalysisSanitizationRules.normalizeImprovement( - sentence, - answer, - improvement, - status == QuestionAnalysisStatus.PROVEN - ); - } - - private QuestionAnalysisStatus parseStatus(String status) { - if (!StringUtils.hasText(status)) { - return null; - } - - String normalizedStatus = status.trim().toUpperCase(); - if ("GOOD".equals(normalizedStatus)) { - return QuestionAnalysisStatus.PROVEN; - } - if ("NEEDS_IMPROVEMENT".equals(normalizedStatus)) { - return QuestionAnalysisStatus.MENTIONED; - } - if ("RISK".equals(normalizedStatus)) { - return QuestionAnalysisStatus.FABRICATED; - } - - try { - return QuestionAnalysisStatus.valueOf(normalizedStatus); - } catch (IllegalArgumentException e) { - return null; - } - } - } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java new file mode 100644 index 00000000..2082d4c4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java @@ -0,0 +1,31 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CustomQuestionCandidatePersistenceService { + private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + private final EntityManager entityManager; + + public CustomQuestionCandidatePersistenceService( + CustomQuestionCandidateRepository customQuestionCandidateRepository, + EntityManager entityManager + ) { + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + this.entityManager = entityManager; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CustomQuestionCandidate saveAndFlush(Long mockApplyId, String content, int charLimit) { + MockApply mockApplyReference = entityManager.getReference(MockApply.class, mockApplyId); + return customQuestionCandidateRepository.saveAndFlush( + CustomQuestionCandidate.create(mockApplyReference, content, charLimit) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java new file mode 100644 index 00000000..03efb999 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java @@ -0,0 +1,61 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +// 기본 문항 후보와 후보 판별 규칙을 제공한다. +public class QuestionCandidateCatalogService { + private static final String DEFAULT_CANDIDATE_PREFIX = "default:"; + private static final String CUSTOM_CANDIDATE_PREFIX = "custom:"; + private static final List DEFAULT_CANDIDATES = List.of( + new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), + new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), + new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), + new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), + new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) + ); + private static final Set DEFAULT_CANDIDATE_CONTENTS = DEFAULT_CANDIDATES.stream() + .map(QuestionCandidate::content) + .collect(Collectors.toUnmodifiableSet()); + + public List getDefaultCandidateResponses(Set selectedContents) { + return DEFAULT_CANDIDATES.stream() + .map(candidate -> new QuestionCandidateResponse( + candidate.id(), + candidate.content(), + candidate.charLimit(), + selectedContents.contains(candidate.content()), + false, + toDefaultCandidateKey(candidate.id()) + )) + .toList(); + } + + public boolean isCustomQuestion(String content) { + return !DEFAULT_CANDIDATE_CONTENTS.contains(content); + } + + public void validateCustomCandidate(String content) { + if (!isCustomQuestion(content)) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미 기본 후보에 존재하는 문항입니다."); + } + } + + public String toDefaultCandidateKey(Long candidateId) { + return DEFAULT_CANDIDATE_PREFIX + candidateId; + } + + public String toCustomCandidateKey(Long candidateId) { + return CUSTOM_CANDIDATE_PREFIX + candidateId; + } + + private record QuestionCandidate(Long id, String content, int charLimit) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java similarity index 56% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java index 7ba7502e..de7b5837 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.question; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; @@ -19,11 +19,9 @@ import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.HashSet; @@ -34,56 +32,33 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -// 분석 전 단계에서 문항 선택, 추가, 답변 저장을 담당하는 서비스다. -public class QuestionService { - - private static final int MIN_SELECTION_COUNT = 1; - private static final int MAX_SELECTION_COUNT = 5; - private static final int DEFAULT_CHAR_LIMIT = 1000; - - private static final List DEFAULT_CANDIDATES = List.of( - new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), - new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), - new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), - new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), - new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) - ); - +// 문항 후보 추가, 선택 저장, 답변 저장, 삭제를 담당한다. +public class QuestionCommandService { + private final QuestionDomainSupport questionDomainSupport; + private final QuestionCandidateCatalogService questionCandidateCatalogService; private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final CustomQuestionCandidateRepository customQuestionCandidateRepository; - - public List getQuestionCandidates(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List selectedQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - Set selectedContents = selectedQuestions.stream() - .map(Question::getContent) - .collect(Collectors.toSet()); - - List candidates = new ArrayList<>(DEFAULT_CANDIDATES.stream() - .map(candidate -> new QuestionCandidateResponse( - candidate.id(), - candidate.content(), - candidate.charLimit(), - selectedContents.contains(candidate.content()), - false - )) - .toList()); - - customQuestionCandidateRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() - .map(candidate -> new QuestionCandidateResponse( - candidate.getId(), - candidate.getContent(), - candidate.getLimit(), - selectedContents.contains(candidate.getContent()), - true - )) - .forEach(candidates::add); - - return candidates; + private final CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService; + + public QuestionCommandService( + QuestionDomainSupport questionDomainSupport, + QuestionCandidateCatalogService questionCandidateCatalogService, + MockApplyRepository mockApplyRepository, + QuestionRepository questionRepository, + QuestionAnalysisRepository questionAnalysisRepository, + CustomQuestionCandidateRepository customQuestionCandidateRepository, + CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService + ) { + this.questionDomainSupport = questionDomainSupport; + this.questionCandidateCatalogService = questionCandidateCatalogService; + this.mockApplyRepository = mockApplyRepository; + this.questionRepository = questionRepository; + this.questionAnalysisRepository = questionAnalysisRepository; + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + this.customQuestionCandidatePersistenceService = customQuestionCandidatePersistenceService; } @Transactional @@ -93,9 +68,9 @@ public QuestionCandidateResponse addCustomQuestionCandidate( Long mockApplyId, QuestionCandidateCreateRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); String content = request.content().trim(); - validateCustomCandidate(content); + questionCandidateCatalogService.validateCustomCandidate(content); CustomQuestionCandidate candidate = findOrCreateCustomCandidate(mockApply, content, request.charLimit()); boolean selected = questionRepository.existsByMockApplyIdAndContent(mockApply.getId(), candidate.getContent()); @@ -105,47 +80,11 @@ public QuestionCandidateResponse addCustomQuestionCandidate( candidate.getContent(), candidate.getLimit(), selected, - true + true, + questionCandidateCatalogService.toCustomCandidateKey(candidate.getId()) ); } - private CustomQuestionCandidate findOrCreateCustomCandidate( - MockApply mockApply, - String content, - Integer charLimit - ) { - return customQuestionCandidateRepository - .findByMockApplyIdAndContent(mockApply.getId(), content) - .orElseGet(() -> saveCustomCandidate(mockApply, content, charLimit)); - } - - private CustomQuestionCandidate saveCustomCandidate( - MockApply mockApply, - String content, - Integer charLimit - ) { - try { - return customQuestionCandidateRepository.saveAndFlush(CustomQuestionCandidate.create( - mockApply, - content, - resolveCharLimit(charLimit) - )); - } catch (DataIntegrityViolationException e) { - return customQuestionCandidateRepository - .findByMockApplyIdAndContent(mockApply.getId(), content) - .orElseThrow(() -> e); - } - } - - public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() - .map(this::toQuestionResponse) - .toList(); - - return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions); - } - @Transactional @AuditLogEvent(action = "QUESTION_SELECTION_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionSelectionResponse saveSelectedQuestions( @@ -153,17 +92,17 @@ public QuestionSelectionResponse saveSelectedQuestions( Long mockApplyId, QuestionSelectionSaveRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - validateSelectionCount(request.questions().size()); + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + questionDomainSupport.validateSelectionCount(request.questions().size()); List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); - questionRepository.deleteAll(existingQuestions); + deleteQuestions(existingQuestions); List questions = request.questions().stream() .map(item -> Question.create( mockApply, item.content().trim(), - resolveCharLimit(item.charLimit()), + questionDomainSupport.resolveCharLimit(item.charLimit()), "" )) .toList(); @@ -173,7 +112,7 @@ public QuestionSelectionResponse saveSelectedQuestions( return new QuestionSelectionResponse( mockApply.getId(), mockApply.getStatus(), - savedQuestions.stream().map(this::toQuestionResponse).toList() + savedQuestions.stream().map(questionDomainSupport::toQuestionResponse).toList() ); } @@ -184,8 +123,8 @@ public QuestionAnswerResponse saveAnswers( Long mockApplyId, QuestionAnswerSaveRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - validateSelectionCount(request.questions().size()); + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + questionDomainSupport.validateSelectionCount(request.questions().size()); List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); Map questionMap = existingQuestions.stream() @@ -198,8 +137,8 @@ public QuestionAnswerResponse saveAnswers( syncedQuestions.add(Question.create( mockApply, item.content().trim(), - resolveCharLimit(item.charLimit()), - normalizeAnswer(item.answer()) + questionDomainSupport.resolveCharLimit(item.charLimit()), + questionDomainSupport.normalizeAnswer(item.answer()) )); continue; } @@ -221,8 +160,8 @@ public QuestionAnswerResponse saveAnswers( existingQuestion.updateContentLimitAndAnswer( item.content().trim(), - resolveCharLimit(item.charLimit()), - normalizeAnswer(item.answer()) + questionDomainSupport.resolveCharLimit(item.charLimit()), + questionDomainSupport.normalizeAnswer(item.answer()) ); syncedQuestions.add(existingQuestion); } @@ -230,7 +169,7 @@ public QuestionAnswerResponse saveAnswers( List deletedQuestions = existingQuestions.stream() .filter(question -> !requestedQuestionIds.contains(question.getId())) .toList(); - questionRepository.deleteAll(deletedQuestions); + deleteQuestions(deletedQuestions); List savedQuestions = questionRepository.saveAll(syncedQuestions); mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); @@ -238,16 +177,16 @@ public QuestionAnswerResponse saveAnswers( mockApply.getId(), mockApply.getStatus(), mockApplyRepository.calculateSequence(mockApply), - savedQuestions.stream().map(this::toQuestionResponse).toList() + savedQuestions.stream().map(questionDomainSupport::toQuestionResponse).toList() ); } @Transactional @AuditLogEvent(action = "QUESTION_DELETE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Long questionId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - if (existingQuestions.size() <= MIN_SELECTION_COUNT) { + if (existingQuestions.size() <= 1) { throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); } @@ -259,68 +198,51 @@ public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Lon "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + questionId )); - questionAnalysisRepository.deleteAllByQuestionId(question.getId()); - questionRepository.delete(question); + deleteQuestions(List.of(question)); List remainingQuestions = existingQuestions.stream() .filter(existingQuestion -> !existingQuestion.getId().equals(question.getId())) - .map(this::toQuestionResponse) + .map(questionDomainSupport::toQuestionResponse) .toList(); return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), remainingQuestions); } - private QuestionResponse toQuestionResponse(Question question) { - return QuestionResponse.from(question, isCustomQuestion(question.getContent())); - } - - private MockApply getOwnedMockApply(User user, Long mockApplyId) { - MockApply mockApply = mockApplyRepository.findById(mockApplyId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.MOCK_APPLY_NOT_FOUND, - "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId - )); - - if (!mockApply.getUser().getId().equals(user.getId())) { - throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); - } - - return mockApply; - } - - private void validateSelectionCount(int count) { - if (count < MIN_SELECTION_COUNT) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); - } - if (count > MAX_SELECTION_COUNT) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다."); - } - } - - private int resolveCharLimit(Integer charLimit) { - if (charLimit == null) { - return DEFAULT_CHAR_LIMIT; - } - return charLimit; - } - - private boolean isCustomQuestion(String content) { - return DEFAULT_CANDIDATES.stream() - .noneMatch(candidate -> candidate.content().equals(content)); + private CustomQuestionCandidate findOrCreateCustomCandidate( + MockApply mockApply, + String content, + Integer charLimit + ) { + return customQuestionCandidateRepository + .findByMockApplyIdAndContent(mockApply.getId(), content) + .orElseGet(() -> saveCustomCandidate(mockApply, content, charLimit)); } - private void validateCustomCandidate(String content) { - if (!isCustomQuestion(content)) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미 기본 후보에 존재하는 문항입니다."); + private CustomQuestionCandidate saveCustomCandidate( + MockApply mockApply, + String content, + Integer charLimit + ) { + int resolvedCharLimit = questionDomainSupport.resolveCharLimit(charLimit); + try { + return customQuestionCandidatePersistenceService.saveAndFlush( + mockApply.getId(), + content, + resolvedCharLimit + ); + } catch (DataIntegrityViolationException e) { + return customQuestionCandidateRepository + .findByMockApplyIdAndContent(mockApply.getId(), content) + .orElseThrow(() -> e); } } - private String normalizeAnswer(String answer) { - if (StringUtils.hasText(answer)) { - return answer; + private void deleteQuestions(List questions) { + if (questions == null || questions.isEmpty()) { + return; } - return ""; - } - - private record QuestionCandidate(Long id, String content, int charLimit) { + questions.stream() + .map(Question::getId) + .forEach(questionAnalysisRepository::deleteAllByQuestionId); + questionRepository.deleteAll(questions); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java new file mode 100644 index 00000000..51a57d7c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +// 문항 도메인 공통 규칙과 지원서 접근 검증을 제공한다. +public class QuestionDomainSupport { + private static final int MIN_SELECTION_COUNT = 1; + private static final int MAX_SELECTION_COUNT = 5; + private static final int DEFAULT_CHAR_LIMIT = 1000; + public static final int MAX_CHAR_LIMIT = 5000; + + private final MockApplyRepository mockApplyRepository; + private final QuestionCandidateCatalogService questionCandidateCatalogService; + + public QuestionDomainSupport( + MockApplyRepository mockApplyRepository, + QuestionCandidateCatalogService questionCandidateCatalogService + ) { + this.mockApplyRepository = mockApplyRepository; + this.questionCandidateCatalogService = questionCandidateCatalogService; + } + + public MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + public void validateSelectionCount(int count) { + if (count < MIN_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); + } + if (count > MAX_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다."); + } + } + + public int resolveCharLimit(Integer charLimit) { + if (charLimit == null) { + return DEFAULT_CHAR_LIMIT; + } + if (charLimit > MAX_CHAR_LIMIT) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "글자수 제한은 최대 " + MAX_CHAR_LIMIT + "자까지 설정할 수 있습니다." + ); + } + return charLimit; + } + + public String normalizeAnswer(String answer) { + if (StringUtils.hasText(answer)) { + return answer.trim(); + } + return ""; + } + + public QuestionResponse toQuestionResponse(Question question) { + return QuestionResponse.from(question, questionCandidateCatalogService.isCustomQuestion(question.getContent())); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java new file mode 100644 index 00000000..695fbd64 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java @@ -0,0 +1,73 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +// 문항 후보/선택 목록 조회를 담당한다. +public class QuestionQueryService { + private final QuestionDomainSupport questionDomainSupport; + private final QuestionCandidateCatalogService questionCandidateCatalogService; + private final QuestionRepository questionRepository; + private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + + public QuestionQueryService( + QuestionDomainSupport questionDomainSupport, + QuestionCandidateCatalogService questionCandidateCatalogService, + QuestionRepository questionRepository, + CustomQuestionCandidateRepository customQuestionCandidateRepository + ) { + this.questionDomainSupport = questionDomainSupport; + this.questionCandidateCatalogService = questionCandidateCatalogService; + this.questionRepository = questionRepository; + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + } + + public List getQuestionCandidates(User user, Long mockApplyId) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + List selectedQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + Set selectedContents = selectedQuestions.stream() + .map(Question::getContent) + .collect(Collectors.toSet()); + + List candidates = new ArrayList<>( + questionCandidateCatalogService.getDefaultCandidateResponses(selectedContents) + ); + + customQuestionCandidateRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() + .map(candidate -> new QuestionCandidateResponse( + candidate.getId(), + candidate.getContent(), + candidate.getLimit(), + selectedContents.contains(candidate.getContent()), + true, + questionCandidateCatalogService.toCustomCandidateKey(candidate.getId()) + )) + .forEach(candidates::add); + + return candidates; + } + + public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() + .map(questionDomainSupport::toQuestionResponse) + .toList(); + + return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java index 91893af0..9c296866 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.retrieval; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java index b3722e57..3c7b6169 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; public record MissingKeywordSanitizationDecision( int candidateIndex, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java index 6b5b7bc5..12406ddc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java index 6ce3bd04..8e6e9ecb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import org.springframework.util.StringUtils; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java new file mode 100644 index 00000000..897c69a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncCreditStatus { + NONE, + RESERVED, + CONFIRMED, + RELEASED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java new file mode 100644 index 00000000..cad64735 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncFailureReason { + PUBLISH_FAILED, + RATE_LIMIT, + QUEUE_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java new file mode 100644 index 00000000..941ed43e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncTaskStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java index 2c99de5a..8fa11f56 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.entity; +package com.jobdri.jobdri_api.domain.analysis.type; public enum QuestionAnalysisStatus { PROVEN, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java similarity index 66% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index baaf19a0..5c77371b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -1,20 +1,20 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import lombok.RequiredArgsConstructor; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; +import com.jobdri.jobdri_api.domain.evaluation.analysis.policy.EvaluationAnalysisPolicyConstants; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -27,11 +27,9 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; @Service -@RequiredArgsConstructor @Slf4j public class EvaluationAnalysisBatchService { private static final Long EVALUATION_QUESTION_ID = 1L; @@ -46,9 +44,31 @@ public class EvaluationAnalysisBatchService { "answer" ); - private final AnalysisAiClient analysisAiClient; - private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final EvaluationAnalysisGenerator evaluationAnalysisGenerator; private final ObjectMapper objectMapper; + private final EvaluationSanitizationService evaluationSanitizationService; + + @Autowired + public EvaluationAnalysisBatchService( + EvaluationAnalysisGenerator evaluationAnalysisGenerator, + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService + ) { + this.evaluationAnalysisGenerator = evaluationAnalysisGenerator; + this.objectMapper = objectMapper; + this.evaluationSanitizationService = evaluationSanitizationService; + } + + EvaluationAnalysisBatchService( + EvaluationAnalysisGenerator evaluationAnalysisGenerator, + ObjectMapper objectMapper + ) { + this( + evaluationAnalysisGenerator, + objectMapper, + new EvaluationSanitizationService() + ); + } public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOException { List cases = readCases(inputPath); @@ -106,37 +126,27 @@ private void validateHeaders(List headers) { } private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) { - AnalysisPromptInput promptInput = new AnalysisPromptInput( + EvaluationGeneratedResult generatedResult = evaluationAnalysisGenerator.generate(new EvaluationAnalysisCommand( evaluationCase.caseId(), - "평가용 회사", + evaluationCase.jobCategoryMiddle(), evaluationCase.jobCategorySmall(), evaluationCase.mainTasks(), evaluationCase.qualifications(), evaluationCase.preferences(), - List.of(new AnalysisPromptInput.QuestionAnswer( - EVALUATION_QUESTION_ID, - evaluationCase.question(), - evaluationCase.answer() - )) - ); - - AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( - promptInput, - jobCategoryEvaluationCriteriaProvider - .findByMiddleName(evaluationCase.jobCategoryMiddle()) - .orElse(null) - ); - AnalysisLlmResponse llmResponse = aiCallResult.response(); + evaluationCase.question(), + evaluationCase.answer() + )); + EvaluationLlmSnapshot llmResponse = generatedResult.responseSnapshot(); int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit()); int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact()); int completeness = validateScore("completeness", llmResponse == null ? null : llmResponse.completeness()); - List missingKeywords = buildMissingKeywords(evaluationCase, llmResponse); + List missingKeywords = buildMissingKeywords(evaluationCase, llmResponse); List questionAnalyses = buildQuestionAnalyses(evaluationCase, llmResponse); log.debug( "Evaluation serialized missing keyword flow. caseId={}, candidateMissingKeywordCount={}, finalMissingKeywordCount={}, evaluationSerializedMissingKeywordCount={}", evaluationCase.caseId(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), size(llmResponse == null ? null : llmResponse.missingKeywords()), missingKeywords.size() ); @@ -157,26 +167,26 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa normalizeFeedback(llmResponse.feedback()), writeJson(missingKeywords), writeJson(questionAnalyses), - writeJson(llmResponse), - writeJson(aiCallResult.rawCandidateResponse()), - writeJson(aiCallResult.sanitizedCandidateResponse()), - writeJson(aiCallResult.candidateReviewResponse()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().analysisCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().analysisCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().strengthCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), - acceptedDecisionCount(aiCallResult.candidateReviewResponse()), - rejectedDecisionCount(aiCallResult.candidateReviewResponse()), - rejectionCodeCounts(aiCallResult.candidateReviewResponse()), + generatedResult.rawLlmResponseJson(), + generatedResult.rawCandidateResponseJson(), + generatedResult.sanitizedCandidateResponseJson(), + generatedResult.candidateReviewResponseJson(), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().analysisCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().analysisCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().strengthCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), + acceptedDecisionCount(generatedResult.candidateReviewSnapshot()), + rejectedDecisionCount(generatedResult.candidateReviewSnapshot()), + rejectionCodeCounts(generatedResult.candidateReviewSnapshot()), questionAnalyses.size(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().strengthCandidates()), - llmResponse.keyStrengths() == null ? 0 : llmResponse.keyStrengths().size(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().strengthCandidates()), + llmResponse.keyStrengthQuotes() == null ? 0 : llmResponse.keyStrengthQuotes().size(), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), missingKeywords.size(), - aiCallResult.candidateCallLatencyMs(), - aiCallResult.finalCallLatencyMs(), - aiCallResult.candidateCallLatencyMs(), - aiCallResult.finalCallLatencyMs(), + generatedResult.candidateCallLatencyMs(), + generatedResult.finalCallLatencyMs(), + generatedResult.candidateCallLatencyMs(), + generatedResult.finalCallLatencyMs(), null, null, null, @@ -189,40 +199,36 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa ); } - private List buildMissingKeywords( + private List buildMissingKeywords( EvaluationAnalysisCase evaluationCase, - AnalysisLlmResponse llmResponse + EvaluationLlmSnapshot llmResponse ) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } - List result = new ArrayList<>(); + List result = new ArrayList<>(); Set seenKeywords = new HashSet<>(); - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + for (EvaluationMissingKeyword item : llmResponse.missingKeywords()) { if (item == null || !StringUtils.hasText(item.keyword())) { continue; } String keyword = item.keyword().trim(); - if (keyword.length() > AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH) { + if (keyword.length() > EvaluationAnalysisPolicyConstants.MAX_MISSING_KEYWORD_LENGTH) { continue; } - Optional source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } - if (!AnalysisSanitizationRules.isValidMissingKeyword( + if (!evaluationSanitizationService.isValidMissingKeyword( keyword, - source.get(), + item.source(), evaluationCase.mainTasks(), evaluationCase.qualifications() )) { continue; } - if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + if (evaluationSanitizationService.isMissingKeywordMentionedInAnswers( keyword, evaluationCase.answer() )) { @@ -234,8 +240,8 @@ private List buildMissingKeywords( continue; } - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= AnalysisResultConstants.MAX_MISSING_KEYWORDS) { + result.add(new EvaluationMissingKeyword(keyword, item.source())); + if (result.size() >= EvaluationAnalysisPolicyConstants.MAX_MISSING_KEYWORDS) { break; } } @@ -244,7 +250,7 @@ private List buildMissingKeywords( private List buildQuestionAnalyses( EvaluationAnalysisCase evaluationCase, - AnalysisLlmResponse llmResponse + EvaluationLlmSnapshot llmResponse ) { if (llmResponse == null || llmResponse.questionAnalyses() == null) { return List.of(); @@ -262,7 +268,7 @@ private List buildQuestionAnalyses( Set fabricatedQuestionIds = new HashSet<>(); Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); - for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + for (EvaluationQuestionAnalysis item : llmResponse.questionAnalyses()) { if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { continue; } @@ -275,11 +281,11 @@ private List buildQuestionAnalyses( continue; } if (status == QuestionAnalysisStatus.PROVEN - && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { + && !evaluationSanitizationService.hasValidProvenReason(item.reason())) { continue; } if (status == QuestionAnalysisStatus.FABRICATED - && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( + && !evaluationSanitizationService.hasFabricatedDirectConflictEvidence( item.sentence(), item.reason() )) { @@ -287,7 +293,7 @@ private List buildQuestionAnalyses( } int currentCount = analysisCountByQuestionId.getOrDefault(item.questionId(), 0); - if (currentCount >= AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION) { + if (currentCount >= EvaluationAnalysisPolicyConstants.MAX_ANALYSES_PER_QUESTION) { continue; } @@ -329,20 +335,20 @@ private List buildQuestionAnalyses( return result; } - private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { - if (llmResponse == null || llmResponse.keyStrengths() == null) { + private Set normalizedKeyStrengthQuotes(EvaluationLlmSnapshot llmResponse) { + if (llmResponse == null || llmResponse.keyStrengthQuotes() == null) { return Set.of(); } - return llmResponse.keyStrengths().stream() - .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) + return llmResponse.keyStrengthQuotes().stream() + .filter(StringUtils::hasText) + .map(this::normalizeKeyword) .collect(java.util.stream.Collectors.toSet()); } private int validateScore(String fieldName, Integer score) { if (score == null - || score < AnalysisResultConstants.MIN_SCORE - || score > AnalysisResultConstants.MAX_SCORE) { + || score < EvaluationAnalysisPolicyConstants.MIN_SCORE + || score > EvaluationAnalysisPolicyConstants.MAX_SCORE) { throw new IllegalArgumentException("자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다."); } return score; @@ -350,9 +356,9 @@ private int validateScore(String fieldName, Integer score) { private int calculateScore(int jobFit, int impact, int completeness) { return (int) Math.round( - jobFit * AnalysisResultConstants.JOB_FIT_WEIGHT - + impact * AnalysisResultConstants.IMPACT_WEIGHT - + completeness * AnalysisResultConstants.COMPLETENESS_WEIGHT + jobFit * EvaluationAnalysisPolicyConstants.JOB_FIT_WEIGHT + + impact * EvaluationAnalysisPolicyConstants.IMPACT_WEIGHT + + completeness * EvaluationAnalysisPolicyConstants.COMPLETENESS_WEIGHT ); } @@ -389,7 +395,7 @@ private Integer size(List values) { return values == null ? null : values.size(); } - private Integer acceptedDecisionCount(CandidateReviewResponse reviewResponse) { + private Integer acceptedDecisionCount(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return null; } @@ -398,7 +404,7 @@ private Integer acceptedDecisionCount(CandidateReviewResponse reviewResponse) { .count(); } - private Integer rejectedDecisionCount(CandidateReviewResponse reviewResponse) { + private Integer rejectedDecisionCount(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return null; } @@ -407,16 +413,16 @@ private Integer rejectedDecisionCount(CandidateReviewResponse reviewResponse) { .count(); } - private String rejectionCodeCounts(CandidateReviewResponse reviewResponse) { + private String rejectionCodeCounts(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return ""; } Map counts = reviewResponse.decisions().stream() .filter(decision -> decision != null - && decision.rejectionCode() != null - && decision.rejectionCode() != CandidateReviewResponse.RejectionCode.NONE) + && StringUtils.hasText(decision.rejectionCode()) + && !"NONE".equals(decision.rejectionCode())) .collect(java.util.stream.Collectors.groupingBy( - decision -> decision.rejectionCode().name(), + EvaluationCandidateReviewDecision::rejectionCode, java.util.stream.Collectors.counting() )); return writeJson(counts); @@ -435,7 +441,7 @@ private String normalizeImprovement( String improvement, QuestionAnalysisStatus status ) { - return AnalysisSanitizationRules.normalizeImprovement( + return evaluationSanitizationService.normalizeImprovement( sentence, answer, improvement, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java index bd32f5c6..0ed2cd8c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationAnalysisCase( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java index 30dcdd0d..60acb682 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationAnalysisResult( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java index 08e531d2..51e36a20 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java index c70343bc..08bbc77a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedReader; import java.io.BufferedWriter; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java index d4b9d8c9..ee74bff1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java similarity index 78% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java index 15e0af54..382cf546 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationQuestionAnalysisResult( Long questionId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java index 79467dfb..ad5ec1d2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java similarity index 96% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java index 1ee5d093..965fce8f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java index ecfd665c..0fdaa4e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java index 68ec70a5..9791542f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java index aedce188..b7e646c0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java similarity index 67% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java index 5cfcd7b4..a2e9a917 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java @@ -1,13 +1,18 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationDecision; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -23,7 +28,6 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j class MissingKeywordSanitizerReplayService { private static final List REQUIRED_HEADERS = List.of( @@ -76,6 +80,22 @@ class MissingKeywordSanitizerReplayService { ); private final ObjectMapper objectMapper; + private final EvaluationSanitizationService evaluationSanitizationService; + private final EvaluationCandidateSnapshotParser candidateSnapshotParser; + + @Autowired + MissingKeywordSanitizerReplayService( + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService + ) { + this.objectMapper = objectMapper; + this.evaluationSanitizationService = evaluationSanitizationService; + this.candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); + } + + MissingKeywordSanitizerReplayService(ObjectMapper objectMapper) { + this(objectMapper, new EvaluationSanitizationService()); + } ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOException { List headers = EvaluationCsvSupport.readHeaders(input); @@ -100,19 +120,18 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept if (!caseIds.add(caseId)) { throw new IllegalArgumentException("Missing keyword replay input CSV has duplicate caseId: " + caseId); } - AnalysisCandidateResponse rawResponse = readCandidateResponse( + EvaluationCandidateSnapshot rawResponse = readCandidateResponse( value(row, "rawCandidateResponseJson"), "rawCandidateResponseJson", caseId ); - AnalysisCandidateResponse existingSanitized = readCandidateResponse( + EvaluationCandidateSnapshot existingSanitized = readCandidateResponse( value(row, "sanitizedCandidateResponseJson"), "sanitizedCandidateResponseJson", caseId ); - List rawCandidates = - safeMissingKeywordCandidates(rawResponse); - MissingKeywordSanitizationResult replayResult = MissingKeywordSanitizer.sanitize( + List rawCandidates = safeMissingKeywordCandidates(rawResponse); + EvaluationMissingKeywordSanitizationResult replayResult = evaluationSanitizationService.sanitizeMissingKeywordCandidates( value(row, "mainTasks"), value(row, "qualifications"), value(row, "answer"), @@ -130,7 +149,7 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept acceptedTotal += acceptedCount; rejectedTotal += rejectedCount; - for (MissingKeywordSanitizationDecision decision : replayResult.decisions()) { + for (EvaluationMissingKeywordSanitizationDecision decision : replayResult.decisions()) { reasonCounts.merge(decision.rejectionReason().name(), 1L, Long::sum); replayRows.add(toReplayRow(caseId, rawCandidateCount, acceptedCount, rejectedCount, decision)); if (rawCandidateCount > 0) { @@ -178,23 +197,14 @@ private void validateHeaders(List headers) { } } - private AnalysisCandidateResponse readCandidateResponse(String json, String fieldName, String caseId) { - if (!StringUtils.hasText(json)) { - return new AnalysisCandidateResponse(List.of(), List.of(), List.of()); - } - try { - AnalysisCandidateResponse response = objectMapper.readValue(json, AnalysisCandidateResponse.class); - if (response == null) { - return new AnalysisCandidateResponse(List.of(), List.of(), List.of()); - } - return response; - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); - } + private EvaluationCandidateSnapshot readCandidateResponse(String json, String fieldName, String caseId) { + EvaluationCandidateSnapshot snapshot = candidateSnapshotParser.parse(json, fieldName, caseId); + validateMissingKeywordCandidates(json, fieldName, caseId); + return snapshot; } - private List safeMissingKeywordCandidates( - AnalysisCandidateResponse response + private List safeMissingKeywordCandidates( + EvaluationCandidateSnapshot response ) { if (response == null || response.missingKeywordCandidates() == null) { return List.of(); @@ -204,24 +214,91 @@ private List safeMissingKeywo private void validateExistingSanitized( String caseId, - List replayAccepted, - List existingAccepted + List replayAccepted, + List existingAccepted ) { - if (!Objects.equals(replayAccepted, existingAccepted)) { + if (!acceptedCandidatesMatch(replayAccepted, existingAccepted)) { throw new IllegalStateException( "Missing keyword replay accepted candidates mismatch. caseId=" + caseId ); } } + private void validateMissingKeywordCandidates(String json, String fieldName, String caseId) { + if (!StringUtils.hasText(json)) { + return; + } + try { + JsonNode missingKeywordCandidates = objectMapper.readTree(json).path("missingKeywordCandidates"); + if (!missingKeywordCandidates.isArray()) { + return; + } + for (int i = 0; i < missingKeywordCandidates.size(); i++) { + JsonNode item = missingKeywordCandidates.get(i); + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + if (!StringUtils.hasText(keyword)) { + throw new IllegalArgumentException( + fieldName + " has blank missingKeywordCandidates[" + i + "].keyword. caseId=" + caseId + ); + } + if (EvaluationMissingKeywordSource.from(source).isEmpty()) { + throw new IllegalArgumentException( + fieldName + " has invalid missingKeywordCandidates[" + i + "].source. caseId=" + caseId + ); + } + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); + } + } + + private boolean acceptedCandidatesMatch( + List replayAccepted, + List existingAccepted + ) { + if (replayAccepted == null || existingAccepted == null) { + return Objects.equals(replayAccepted, existingAccepted); + } + if (replayAccepted.size() != existingAccepted.size()) { + return false; + } + for (int i = 0; i < replayAccepted.size(); i++) { + if (!acceptedCandidateMatches(replayAccepted.get(i), existingAccepted.get(i))) { + return false; + } + } + return true; + } + + private boolean acceptedCandidateMatches( + EvaluationMissingKeywordCandidate replayCandidate, + EvaluationMissingKeywordCandidate existingCandidate + ) { + if (replayCandidate == null || existingCandidate == null) { + return Objects.equals(replayCandidate, existingCandidate); + } + return Objects.equals(replayCandidate.keyword(), existingCandidate.keyword()) + && replayCandidate.source() == existingCandidate.source() + && relatedRequirementMatches(replayCandidate.relatedRequirement(), existingCandidate.relatedRequirement()); + } + + private boolean relatedRequirementMatches(String replayRelatedRequirement, String existingRelatedRequirement) { + String normalizedReplay = value(replayRelatedRequirement); + String normalizedExisting = value(existingRelatedRequirement); + return Objects.equals(normalizedReplay, normalizedExisting) + || !StringUtils.hasText(normalizedReplay) + || !StringUtils.hasText(normalizedExisting); + } + private Map toReplayRow( String caseId, int rawCandidateCount, int acceptedCandidateCount, int rejectedCandidateCount, - MissingKeywordSanitizationDecision decision + EvaluationMissingKeywordSanitizationDecision decision ) { - AnalysisCandidateResponse.MissingKeywordCandidate candidate = decision.originalCandidate(); + EvaluationMissingKeywordCandidate candidate = decision.originalCandidate(); Map row = new LinkedHashMap<>(); row.put("caseId", caseId); row.put("candidateIndex", String.valueOf(decision.candidateIndex())); @@ -245,9 +322,9 @@ private Map toReplayRow( private Map toReviewRow( String caseId, Map inputRow, - MissingKeywordSanitizationDecision decision + EvaluationMissingKeywordSanitizationDecision decision ) { - AnalysisCandidateResponse.MissingKeywordCandidate candidate = decision.originalCandidate(); + EvaluationMissingKeywordCandidate candidate = decision.originalCandidate(); Map row = new LinkedHashMap<>(); row.put("caseId", caseId); row.put("candidateIndex", String.valueOf(decision.candidateIndex())); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java index 4bca6209..e34589b9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.openai.client.OpenAIClient; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java similarity index 93% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index 85af0d91..60205300 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -1,15 +1,16 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import lombok.RequiredArgsConstructor; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -28,7 +29,6 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j class NlgEvaluationBatchService { private static final int MIN_SCORE = 1; @@ -64,6 +64,24 @@ class NlgEvaluationBatchService { private final NlgEvaluationAiClient nlgEvaluationAiClient; private final ObjectMapper objectMapper; + private final EvaluationLlmSnapshotParser evaluationLlmSnapshotParser; + private final EvaluationSanitizationService evaluationSanitizationService; + + @Autowired + NlgEvaluationBatchService( + NlgEvaluationAiClient nlgEvaluationAiClient, + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService + ) { + this.nlgEvaluationAiClient = nlgEvaluationAiClient; + this.objectMapper = objectMapper; + this.evaluationLlmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + this.evaluationSanitizationService = evaluationSanitizationService; + } + + NlgEvaluationBatchService(NlgEvaluationAiClient nlgEvaluationAiClient, ObjectMapper objectMapper) { + this(nlgEvaluationAiClient, objectMapper, new EvaluationSanitizationService()); + } NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { validateDifferentFiles(inputPath, outputPath); @@ -140,7 +158,9 @@ private NlgEvaluationAiClient.NlgJudgeInput buildJudgeInput( requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "question", caseId), requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "answer", caseId), questionAnalysesJson, - readKeyStrengthsJson(resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson")), + writeJson(evaluationLlmSnapshotParser.parseRawLlmResponse( + resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson") + ).keyStrengthQuotes()), missingKeywordsJson, value(row, "rawCandidateResponseJson"), value(row, "sanitizedCandidateResponseJson"), @@ -389,8 +409,8 @@ private List validMissingKey if (evaluations == null || evaluations.isEmpty()) { return List.of(); } - List actualMissingKeywords = - readActualMissingKeywords(input.missingKeywordsJson(), input.caseId()); + List actualMissingKeywords = + evaluationLlmSnapshotParser.parseMissingKeywords(input.missingKeywordsJson(), input.caseId()); List valid = new ArrayList<>(); for (NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation : evaluations) { Optional invalidReason = @@ -413,7 +433,7 @@ private List validMissingKey private Optional missingKeywordMissInvalidReason( NlgEvaluationAiClient.NlgJudgeInput input, - List actualMissingKeywords, + List actualMissingKeywords, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation ) { if (evaluation == null) { @@ -428,21 +448,21 @@ private Optional missingKeywordMissInvalidReaso if (!StringUtils.hasText(evaluation.reason())) { return Optional.of(MissingKeywordMissInvalidReason.BLANK_REASON); } - Optional source = parseJudgeMissingKeywordSource(evaluation.source()); - if (source.isEmpty() || source.get() == MissingKeywordSource.PREFERENCE) { + Optional source = parseJudgeMissingKeywordSource(evaluation.source()); + if (source.isEmpty() || source.get() == EvaluationMissingKeywordSource.PREFERENCE) { return Optional.of(MissingKeywordMissInvalidReason.INVALID_SOURCE); } - if (AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.keyword()) - || AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { + if (evaluationSanitizationService.isStructuredQualificationKeyword(evaluation.keyword()) + || evaluationSanitizationService.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { return Optional.of(MissingKeywordMissInvalidReason.STRUCTURED_QUALIFICATION); } - String sourceText = source.get() == MissingKeywordSource.MAIN_TASK + String sourceText = source.get() == EvaluationMissingKeywordSource.MAIN_TASK ? input.mainTasks() : input.qualifications(); if (!containsNormalized(sourceText, evaluation.relatedRequirement())) { return Optional.of(MissingKeywordMissInvalidReason.RELATED_REQUIREMENT_NOT_IN_JD); } - if (!AnalysisSanitizationRules.isValidMissingKeyword( + if (!evaluationSanitizationService.isValidMissingKeyword( evaluation.keyword(), source.get(), input.mainTasks(), @@ -456,30 +476,18 @@ private Optional missingKeywordMissInvalidReaso return Optional.empty(); } - private Optional parseJudgeMissingKeywordSource(String source) { - if (!StringUtils.hasText(source)) { - return Optional.empty(); - } - String normalized = source.trim(); - if ("MAIN_TASK".equalsIgnoreCase(normalized) || "MAIN_TASKS".equalsIgnoreCase(normalized)) { - return Optional.of(MissingKeywordSource.MAIN_TASK); - } - if ("QUALIFICATION".equalsIgnoreCase(normalized) || "QUALIFICATIONS".equalsIgnoreCase(normalized)) { - return Optional.of(MissingKeywordSource.QUALIFICATION); - } - return MissingKeywordSource.from(normalized); + private Optional parseJudgeMissingKeywordSource(String source) { + return EvaluationMissingKeywordSource.from(source); } private boolean sameMissingKeyword( - AnalysisLlmResponse.MissingKeywordItem actual, + EvaluationMissingKeyword actual, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation, - MissingKeywordSource source + EvaluationMissingKeywordSource source ) { return actual != null && normalize(actual.keyword()).equals(normalize(evaluation.keyword())) - && parseJudgeMissingKeywordSource(actual.source()) - .map(actualSource -> actualSource == source) - .orElse(false); + && actual.source() == source; } private List sanitizeErrorCodes( @@ -601,22 +609,6 @@ private int readJsonArraySize(String json, String fieldName, String caseId) { } } - private List readActualMissingKeywords(String json, String caseId) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - try { - List values = objectMapper.readValue( - json, - new TypeReference<>() { - } - ); - return values == null ? List.of() : values; - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); - } - } - private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJson) { if (!StringUtils.hasText(sanitizedCandidateResponseJson)) { return 0; @@ -634,18 +626,6 @@ private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJs } } - private String readKeyStrengthsJson(String rawLlmResponseJson) { - if (!StringUtils.hasText(rawLlmResponseJson)) { - return "[]"; - } - try { - AnalysisLlmResponse response = objectMapper.readValue(rawLlmResponseJson, AnalysisLlmResponse.class); - return writeJson(response == null || response.keyStrengths() == null ? List.of() : response.keyStrengths()); - } catch (JsonProcessingException e) { - return "[]"; - } - } - private SourceCaseRows loadSourceCaseRowsIfNeeded(Path inputPath, List inputHeaders) throws IOException { boolean hasAllContextHeaders = List.of("mainTasks", "qualifications", "question", "answer").stream() .allMatch(field -> directHeader(inputHeaders, field).isPresent()); @@ -888,7 +868,7 @@ private boolean containsNormalized(String sourceText, String fragment) { } private String normalize(String value) { - return AnalysisSanitizationRules.normalizeText(value); + return evaluationSanitizationService.normalizeText(value); } private String truncateShortRationale(String rationale) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java index 824102d2..17b37a8b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedWriter; import java.io.IOException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java index a0560756..35e91e50 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record NlgEvaluationComparisonResult( String sourceResultFile, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java index 1fe595b0..cd8ba8d7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedWriter; import java.io.IOException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java similarity index 85% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java index 27719c36..427d0d5d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; public enum NlgEvaluationErrorCode { FALSE_POSITIVE_ANALYSIS, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java index 8d7cd57b..e98bd9f6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java similarity index 96% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java index 1ef7df2c..23655dda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record NlgEvaluationResult( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java index 80ddea40..41a68007 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java new file mode 100644 index 00000000..ba937714 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java @@ -0,0 +1,49 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationCandidateReviewSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationCandidateReviewSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationCandidateReviewSnapshot parse(String json) { + if (!StringUtils.hasText(json)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(json); + return new EvaluationCandidateReviewSnapshot(readDecisions(root.path("decisions"))); + } catch (JsonProcessingException e) { + return emptySnapshot(); + } + } + + private EvaluationCandidateReviewSnapshot emptySnapshot() { + return new EvaluationCandidateReviewSnapshot(List.of()); + } + + private List readDecisions(JsonNode decisionsNode) { + if (!decisionsNode.isArray()) { + return List.of(); + } + List decisions = new ArrayList<>(); + for (JsonNode item : decisionsNode) { + decisions.add(new EvaluationCandidateReviewDecision( + item.path("accepted").isBoolean() ? item.path("accepted").booleanValue() : null, + item.path("rejectionCode").asText(null) + )); + } + return decisions; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java new file mode 100644 index 00000000..5d084d6d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java @@ -0,0 +1,71 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationCandidateSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationCandidateSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationCandidateSnapshot parse(String json, String fieldName, String caseId) { + if (!StringUtils.hasText(json)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(json); + return new EvaluationCandidateSnapshot( + readOpaqueItems(root.path("strengthCandidates")), + readOpaqueItems(root.path("analysisCandidates")), + readMissingKeywordCandidates(root.path("missingKeywordCandidates")) + ); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); + } + } + + private EvaluationCandidateSnapshot emptySnapshot() { + return new EvaluationCandidateSnapshot(List.of(), List.of(), List.of()); + } + + private List readOpaqueItems(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List items = new ArrayList<>(); + node.forEach(item -> items.add(item)); + return items; + } + + private List readMissingKeywordCandidates(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List candidates = new ArrayList<>(); + for (JsonNode item : node) { + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + String relatedRequirement = item.path("relatedRequirement").asText(null); + if (!StringUtils.hasText(keyword)) { + continue; + } + EvaluationMissingKeywordSource.from(source) + .ifPresent(value -> candidates.add(new EvaluationMissingKeywordCandidate( + keyword, + value, + relatedRequirement + ))); + } + return candidates; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java new file mode 100644 index 00000000..76694c36 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java @@ -0,0 +1,108 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationLlmSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationLlmSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationLlmSnapshot parseRawLlmResponse(String rawLlmResponseJson) { + if (!StringUtils.hasText(rawLlmResponseJson)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(rawLlmResponseJson); + return new EvaluationLlmSnapshot( + root.path("jobFit").isNumber() ? root.path("jobFit").intValue() : null, + root.path("impact").isNumber() ? root.path("impact").intValue() : null, + root.path("completeness").isNumber() ? root.path("completeness").intValue() : null, + root.path("feedback").asText(""), + readKeyStrengthQuotes(root.path("keyStrengths")), + readMissingKeywords(root.path("missingKeywords")), + readQuestionAnalyses(root.path("questionAnalyses")) + ); + } catch (JsonProcessingException e) { + return emptySnapshot(); + } + } + + public List parseMissingKeywords(String json, String caseId) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(json); + if (!root.isArray()) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId); + } + return readMissingKeywords(root); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); + } + } + + private EvaluationLlmSnapshot emptySnapshot() { + return new EvaluationLlmSnapshot(null, null, null, "", List.of(), List.of(), List.of()); + } + + private List readKeyStrengthQuotes(JsonNode keyStrengthsNode) { + if (!keyStrengthsNode.isArray()) { + return List.of(); + } + List quotes = new ArrayList<>(); + for (JsonNode item : keyStrengthsNode) { + String quote = item.path("quote").asText(null); + if (StringUtils.hasText(quote)) { + quotes.add(quote); + } + } + return quotes; + } + + private List readMissingKeywords(JsonNode missingKeywordsNode) { + if (!missingKeywordsNode.isArray()) { + return List.of(); + } + List missingKeywords = new ArrayList<>(); + for (JsonNode item : missingKeywordsNode) { + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + if (!StringUtils.hasText(keyword)) { + continue; + } + EvaluationMissingKeywordSource.from(source) + .ifPresent(value -> missingKeywords.add(new EvaluationMissingKeyword(keyword, value))); + } + return missingKeywords; + } + + private List readQuestionAnalyses(JsonNode questionAnalysesNode) { + if (!questionAnalysesNode.isArray()) { + return List.of(); + } + List questionAnalyses = new ArrayList<>(); + for (JsonNode item : questionAnalysesNode) { + questionAnalyses.add(new EvaluationQuestionAnalysis( + item.path("questionId").isNumber() ? item.path("questionId").longValue() : null, + item.path("sentence").asText(null), + item.path("status").asText(null), + item.path("reason").asText(null), + item.path("improvement").asText(null) + )); + } + return questionAnalyses; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java new file mode 100644 index 00000000..847f40d3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationAnalysisCommand( + String caseId, + String jobCategoryMiddle, + String jobCategorySmall, + String mainTasks, + String qualifications, + String preferences, + String question, + String answer +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java new file mode 100644 index 00000000..ba6b6572 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationCandidateReviewDecision( + Boolean accepted, + String rejectionCode +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java new file mode 100644 index 00000000..359e8851 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationCandidateReviewSnapshot( + List decisions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java new file mode 100644 index 00000000..24c140c9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.List; + +public record EvaluationCandidateSnapshot( + List strengthCandidates, + List analysisCandidates, + List missingKeywordCandidates +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java new file mode 100644 index 00000000..4e761130 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationGeneratedResult( + EvaluationLlmSnapshot responseSnapshot, + String rawLlmResponseJson, + String rawCandidateResponseJson, + String sanitizedCandidateResponseJson, + EvaluationCandidateSnapshot sanitizedCandidateSnapshot, + String candidateReviewResponseJson, + EvaluationCandidateReviewSnapshot candidateReviewSnapshot, + long candidateCallLatencyMs, + long finalCallLatencyMs +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java new file mode 100644 index 00000000..e082b2ae --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationLlmSnapshot( + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List keyStrengthQuotes, + List missingKeywords, + List questionAnalyses +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java new file mode 100644 index 00000000..3fdc7d1c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeyword( + String keyword, + EvaluationMissingKeywordSource source +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java new file mode 100644 index 00000000..ff274889 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeywordCandidate( + String keyword, + EvaluationMissingKeywordSource source, + String relatedRequirement +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java new file mode 100644 index 00000000..e60c485b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public enum EvaluationMissingKeywordRejectionReason { + ACCEPTED, + NULL_CANDIDATE, + BLANK_KEYWORD, + MAX_ACCEPTED_LIMIT, + INVALID_FORMAT, + UNSUPPORTED_KEYWORD, + CERTIFICATE_OR_QUANTITATIVE_NOISE, + TOO_GENERIC, + NOT_RELATED_TO_JD, + DUPLICATE_KEYWORD, + NORMALIZATION_COLLISION +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java new file mode 100644 index 00000000..1034d6a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeywordSanitizationDecision( + int candidateIndex, + EvaluationMissingKeywordCandidate originalCandidate, + String normalizedKeyword, + boolean accepted, + EvaluationMissingKeywordRejectionReason rejectionReason, + boolean answerExactMatch, + boolean answerNormalizedMatch, + boolean jdRequirementMatched, + Integer duplicateOfCandidateIndex +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java new file mode 100644 index 00000000..9a659aa9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationMissingKeywordSanitizationResult( + List acceptedCandidates, + List decisions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java new file mode 100644 index 00000000..2d34514c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.Arrays; +import java.util.Optional; + +public enum EvaluationMissingKeywordSource { + MAIN_TASK("mainTask", "MAIN_TASK", "MAIN_TASKS"), + QUALIFICATION("qualification", "QUALIFICATION", "QUALIFICATIONS"), + PREFERENCE("preference", "PREFERENCE", "PREFERENCES"); + + private final String value; + private final String[] aliases; + + EvaluationMissingKeywordSource(String value, String... aliases) { + this.value = value; + this.aliases = aliases; + } + + public String value() { + return value; + } + + public static Optional from(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.trim(); + return Arrays.stream(values()) + .filter(source -> source.value.equalsIgnoreCase(normalized) + || Arrays.stream(source.aliases).anyMatch(alias -> alias.equalsIgnoreCase(normalized))) + .findFirst(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java new file mode 100644 index 00000000..acfa661d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationQuestionAnalysis( + Long questionId, + String sentence, + String status, + String reason, + String improvement +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java new file mode 100644 index 00000000..b61041d8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.policy; + +public final class EvaluationAnalysisPolicyConstants { + + public static final int MIN_SCORE = 0; + public static final int MAX_SCORE = 100; + public static final int MAX_ANALYSES_PER_QUESTION = 3; + public static final int MAX_MISSING_KEYWORDS = 3; + public static final int MAX_MISSING_KEYWORD_LENGTH = 60; + public static final double JOB_FIT_WEIGHT = 0.50; + public static final double IMPACT_WEIGHT = 0.30; + public static final double COMPLETENESS_WEIGHT = 0.20; + + private EvaluationAnalysisPolicyConstants() { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java new file mode 100644 index 00000000..482a6f32 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.port; + +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; + +public interface EvaluationAnalysisGenerator { + EvaluationGeneratedResult generate(EvaluationAnalysisCommand command); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java new file mode 100644 index 00000000..23078d5b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java @@ -0,0 +1,507 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization; + +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordRejectionReason; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class EvaluationSanitizationService { + private static final int MAX_ACCEPTED_COUNT = 3; + private static final double MIN_KEYWORD_TOKEN_MATCH_RATIO = 0.5; + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + private static final Pattern TOKEN_PATTERN = Pattern.compile("[a-zA-Z0-9+#.]+|[가-힣]+"); + private static final Pattern KOREAN_ONLY_PATTERN = Pattern.compile("[가-힣]+"); + private static final Pattern CAREER_YEAR_PATTERN = Pattern.compile("경력\\s*\\d+\\s*년|\\d+\\s*년\\s*(이상|이하|미만|초과)"); + private static final Set STRUCTURED_QUALIFICATION_TERMS = normalizedSet( + "자격증", "면허", "면허증", "공인성적", "어학성적", "토익", "toeic", "토플", "toefl", + "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령", + "사회복지사", "청소년지도사", "청소년상담사", "직업상담사", "임상심리사", + "대졸", "초대졸", "전문대졸", "고졸" + ); + private static final Set META_IMPROVEMENT_TERMS = normalizedSet( + "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", + "더 설득력 있게 작성했습니다", "경험을 구체적으로 작성했습니다", "구체적으로 설명했습니다", + "구체적으로 서술했습니다", "명확히 서술했습니다", "구체적으로 설명합니다", + "구체적으로 서술합니다", "명확히 설명합니다", "명확히 서술합니다", + "추가해 보", "추가하면 좋", "설명해 보", "구체적으로 작성", "구체적으로 설명", + "강조하겠", "추가하겠", "명확히 작성", "작성할 수 있", "설명할 수 있", + "보완하겠", "드러내겠", "제시하겠", "추가할 수 있", "수정할 수 있", + "수정하는 방향", "강조하는 방향" + ); + private static final String[] CONTRADICTORY_PROVEN_REASON_TERMS = { + "근거가 부족", "성과가 부족", "수치가 부족", "구체성이 부족", "보완이 필요", + "드러나지 않음", "확인하기 어려움" + }; + private static final Set FABRICATED_DIRECT_CONFLICT_TERMS = normalizedSet( + "직접 충돌", "명시적 사실과 충돌", "사실과 충돌", "조건과 충돌", "요건과 충돌", + "서로 충돌", "상충", "하지 않았다고", "수행하지 않았다고", "경험이 없다고", + "없다고 밝혔", "실제로 하지 않았", "모순", "불일치", "일치하지 않", "앞뒤가 맞지", + "다르게 서술" + ); + private static final String TEAM_PROJECT_TERM = normalizeStatic("팀 프로젝트"); + private static final String TEAM_PROGRESS_TERM = normalizeStatic("팀으로 진행"); + private static final String INDIVIDUAL_PROJECT_TERM = normalizeStatic("개인 프로젝트"); + private static final String SOLO_EXECUTION_TERM = normalizeStatic("혼자 수행"); + private static final String SOLO_PROGRESS_TERM = normalizeStatic("혼자 진행"); + private static final Set STOP_WORDS = Set.of( + "경험", "역량", "업무", "관련", "가능", "보유", "필수", "우대", "자격", "요건", + "사항", "직무", "수행", "활용", "사용", "기반", "중심", "대한", "통한", "등", + "및", "또는", "위한", "있는", "없는" + ); + private static final List BANNED_IMPROVEMENT_PHRASES = compactList( + "추가하세요", + "보완하세요", + "수정해주세요", + "수정하세요", + "작성해주세요", + "작성하세요", + "필요합니다", + "해야 합니다", + "해주세요", + "명확히 해야", + "명확히 하세요" + ); + private static final List IMPERATIVE_ENDINGS = compactList( + "하세요", + "하십시오", + "해주십시오", + "해 주십시오" + ); + private static final List KOREAN_SUFFIXES = List.of( + "했습니다", "았습니다", "었습니다", "으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", + "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한" + ); + + public boolean isValidMissingKeyword( + String keyword, + EvaluationMissingKeywordSource source, + String mainTasks, + String qualifications + ) { + if (!StringUtils.hasText(keyword) || source == null) { + return false; + } + if (source == EvaluationMissingKeywordSource.PREFERENCE) { + return false; + } + if (isStructuredQualificationKeyword(keyword)) { + return false; + } + if (source == EvaluationMissingKeywordSource.MAIN_TASK) { + return isGroundedInSource(keyword, mainTasks); + } + if (source == EvaluationMissingKeywordSource.QUALIFICATION) { + return isGroundedInSource(keyword, qualifications); + } + return false; + } + + public boolean isMissingKeywordMentionedInAnswers(String keyword, String answer) { + if (!StringUtils.hasText(keyword) || !StringUtils.hasText(answer)) { + return false; + } + if (containsNormalized(answer, keyword)) { + return true; + } + + Set keywordTokens = coreTokens(keyword); + Set answerTokens = coreTokens(answer); + if (keywordTokens.isEmpty() || answerTokens.isEmpty()) { + return false; + } + + long matchCount = keywordTokens.stream() + .filter(answerTokens::contains) + .count(); + if (keywordTokens.size() == 1) { + return matchCount == 1; + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; + } + + public boolean hasValidProvenReason(String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : CONTRADICTORY_PROVEN_REASON_TERMS) { + if (normalized.contains(normalize(term))) { + return false; + } + } + return true; + } + + public boolean hasFabricatedDirectConflictEvidence(String sentence, String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + if (hasFabricatedDirectConflictReason(reason)) { + return true; + } + return StringUtils.hasText(sentence) + && hasFabricatedDirectConflictReason(sentence + " " + reason); + } + + public String normalizeImprovement(String sentence, String answer, String improvement, boolean proven) { + if (proven || !StringUtils.hasText(improvement) || isNullLikeImprovement(improvement)) { + return ""; + } + + String normalized = improvement.trim(); + if (isInstructionLike(normalized) + || equalsNormalized(sentence, normalized) + || isCopiedFromAnotherAnswerSentence(sentence, answer, normalized) + || isMetaImprovement(normalized) + || changesSentenceTense(sentence, normalized)) { + return ""; + } + return normalized; + } + + public boolean isStructuredQualificationKeyword(String value) { + if (value == null) { + return false; + } + String normalized = normalize(value); + if (CAREER_YEAR_PATTERN.matcher(value).find()) { + return true; + } + for (String term : STRUCTURED_QUALIFICATION_TERMS) { + if (normalized.contains(term)) { + return true; + } + } + return false; + } + + public String normalizeText(String value) { + return normalize(value); + } + + public EvaluationMissingKeywordSanitizationResult sanitizeMissingKeywordCandidates( + String mainTasks, + String qualifications, + String answer, + List candidates + ) { + if (candidates == null) { + return new EvaluationMissingKeywordSanitizationResult(List.of(), List.of()); + } + + List acceptedCandidates = new ArrayList<>(); + List decisions = new ArrayList<>(); + Map seen = new HashMap<>(); + + for (int i = 0; i < candidates.size(); i++) { + EvaluationMissingKeywordCandidate candidate = candidates.get(i); + EvaluationMissingKeywordSanitizationDecision decision = decide( + i, + candidate, + mainTasks, + qualifications, + answer, + seen, + acceptedCandidates.size() + ); + decisions.add(decision); + if (decision.accepted()) { + acceptedCandidates.add(candidate); + seen.put(decision.normalizedKeyword(), new IndexedKeyword(i, candidate.keyword())); + } + } + + return new EvaluationMissingKeywordSanitizationResult(List.copyOf(acceptedCandidates), List.copyOf(decisions)); + } + + private EvaluationMissingKeywordSanitizationDecision decide( + int index, + EvaluationMissingKeywordCandidate candidate, + String mainTasks, + String qualifications, + String answer, + Map seen, + int acceptedCount + ) { + if (candidate == null) { + return rejected(index, null, "", answer, false, null, EvaluationMissingKeywordRejectionReason.NULL_CANDIDATE); + } + String keyword = candidate.keyword(); + String normalizedKeyword = normalize(keyword); + if (!StringUtils.hasText(keyword)) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.BLANK_KEYWORD); + } + if (acceptedCount >= MAX_ACCEPTED_COUNT) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.MAX_ACCEPTED_LIMIT); + } + if (candidate.source() == null) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.INVALID_FORMAT); + } + if (candidate.source() == EvaluationMissingKeywordSource.PREFERENCE) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.UNSUPPORTED_KEYWORD); + } + if (isStructuredQualificationKeyword(keyword)) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); + } + if (coreTokens(keyword).isEmpty()) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.TOO_GENERIC); + } + + boolean grounded = isValidMissingKeyword(keyword, candidate.source(), mainTasks, qualifications); + if (!grounded) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.NOT_RELATED_TO_JD); + } + + IndexedKeyword duplicate = seen.get(normalizedKeyword); + if (duplicate != null) { + EvaluationMissingKeywordRejectionReason reason = duplicate.keyword().trim().equals(keyword.trim()) + ? EvaluationMissingKeywordRejectionReason.DUPLICATE_KEYWORD + : EvaluationMissingKeywordRejectionReason.NORMALIZATION_COLLISION; + return rejected(index, candidate, normalizedKeyword, answer, grounded, duplicate.index(), reason); + } + + return new EvaluationMissingKeywordSanitizationDecision( + index, + candidate, + normalizedKeyword, + true, + EvaluationMissingKeywordRejectionReason.ACCEPTED, + containsExact(answer, keyword), + containsNormalized(answer, keyword), + true, + null + ); + } + + private EvaluationMissingKeywordSanitizationDecision rejected( + int index, + EvaluationMissingKeywordCandidate candidate, + String normalizedKeyword, + String answer, + boolean jdRequirementMatched, + Integer duplicateOfCandidateIndex, + EvaluationMissingKeywordRejectionReason reason + ) { + String keyword = candidate == null ? "" : candidate.keyword(); + return new EvaluationMissingKeywordSanitizationDecision( + index, + candidate, + normalizedKeyword, + false, + reason, + containsExact(answer, keyword), + containsNormalized(answer, keyword), + jdRequirementMatched, + duplicateOfCandidateIndex + ); + } + + private boolean hasFabricatedDirectConflictReason(String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : FABRICATED_DIRECT_CONFLICT_TERMS) { + if (normalized.contains(term)) { + return true; + } + } + boolean teamClaim = normalized.contains(TEAM_PROJECT_TERM) + || normalized.contains(TEAM_PROGRESS_TERM); + boolean individualClaim = normalized.contains(INDIVIDUAL_PROJECT_TERM) + || normalized.contains(SOLO_EXECUTION_TERM) + || normalized.contains(SOLO_PROGRESS_TERM); + return teamClaim && individualClaim; + } + + private boolean isInstructionLike(String improvement) { + if (improvement == null) { + return false; + } + String compact = compactWhitespace(improvement); + return BANNED_IMPROVEMENT_PHRASES.stream().anyMatch(compact::contains) + || IMPERATIVE_ENDINGS.stream() + .anyMatch(ending -> compact.endsWith(ending) || compact.endsWith(ending + ".")); + } + + private boolean isGroundedInSource(String keyword, String sourceText) { + Set keywordTokens = coreTokens(keyword); + if (keywordTokens.isEmpty() || !StringUtils.hasText(sourceText)) { + return false; + } + + Set sourceTokens = coreTokens(sourceText); + long matchCount = keywordTokens.stream() + .filter(sourceTokens::contains) + .count(); + if (matchCount < 1) { + return false; + } + if (keywordTokens.size() == 1) { + String token = keywordTokens.iterator().next(); + return token.length() >= 3 && sourceTokens.contains(token); + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; + } + + private Set coreTokens(String value) { + Set tokens = new LinkedHashSet<>(); + if (!StringUtils.hasText(value)) { + return tokens; + } + + Matcher matcher = TOKEN_PATTERN.matcher(value.toLowerCase(Locale.ROOT)); + while (matcher.find()) { + String token = stripKoreanSuffix(matcher.group()); + if (isCoreToken(token)) { + tokens.add(token); + } + } + return tokens; + } + + private boolean isCoreToken(String token) { + return StringUtils.hasText(token) + && token.length() >= 2 + && !STOP_WORDS.contains(token); + } + + private String stripKoreanSuffix(String token) { + if (token == null || !KOREAN_ONLY_PATTERN.matcher(token).matches()) { + return token == null ? "" : token; + } + String result = token; + boolean changed; + do { + changed = false; + for (String suffix : KOREAN_SUFFIXES) { + if (result.length() > suffix.length() + 1 && result.endsWith(suffix)) { + result = result.substring(0, result.length() - suffix.length()); + changed = true; + break; + } + } + } while (changed); + return result; + } + + private boolean containsExact(String sourceText, String value) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(value) + && sourceText.contains(value); + } + + private boolean containsNormalized(String sourceText, String keyword) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(keyword) + && normalize(sourceText).contains(normalize(keyword)); + } + + private boolean equalsNormalized(String left, String right) { + return StringUtils.hasText(left) + && StringUtils.hasText(right) + && normalize(left).equals(normalize(right)); + } + + private boolean isCopiedFromAnotherAnswerSentence(String sentence, String answer, String improvement) { + if (!StringUtils.hasText(answer) || !StringUtils.hasText(improvement)) { + return false; + } + return containsNormalized(answer, improvement) && !equalsNormalized(sentence, improvement); + } + + private boolean isMetaImprovement(String improvement) { + String normalized = normalize(improvement); + for (String term : META_IMPROVEMENT_TERMS) { + if (normalized.contains(term)) { + return true; + } + } + return false; + } + + private boolean isNullLikeImprovement(String improvement) { + String normalized = normalize(improvement); + return "null".equals(normalized) + || "n/a".equals(normalized) + || "na".equals(normalized) + || "없음".equals(normalized); + } + + private boolean changesSentenceTense(String sentence, String improvement) { + return isPastSentence(sentence) && isFutureSentence(improvement) + || isFutureSentence(sentence) && isPastSentence(improvement); + } + + private boolean isPastSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("했습니다") + || normalized.contains("였습니다") + || normalized.contains("수행했습니다") + || normalized.contains("개선했습니다") + || normalized.contains("달성했습니다") + || normalized.contains("근무했습니다") + || normalized.contains("담당했습니다"); + } + + private boolean isFutureSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("하겠습니다") + || normalized.contains("되겠습니다") + || normalized.contains("기여하겠습니다") + || normalized.contains("노력하겠습니다") + || normalized.contains("성장하겠습니다") + || normalized.contains("싶습니다"); + } + + private String normalize(String value) { + return normalizeStatic(value); + } + + private static Set normalizedSet(String... values) { + return Arrays.stream(values) + .map(EvaluationSanitizationService::normalizeStatic) + .collect(LinkedHashSet::new, Set::add, Set::addAll); + } + + private static List compactList(String... values) { + return Arrays.stream(values) + .map(EvaluationSanitizationService::compactWhitespace) + .toList(); + } + + private static String compactWhitespace(String value) { + return value == null ? "" : WHITESPACE_PATTERN.matcher(value).replaceAll(""); + } + + private static String normalizeStatic(String value) { + return compactWhitespace(value).toLowerCase(Locale.ROOT); + } + + private record IndexedKeyword( + int index, + String keyword + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java new file mode 100644 index 00000000..0bcf9edf --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java @@ -0,0 +1,116 @@ +package com.jobdri.jobdri_api.domain.evaluation.infrastructure.analysis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; +import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateReviewSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +@Component +public class AnalysisAiEvaluationAnalysisGenerator implements EvaluationAnalysisGenerator { + private static final Long EVALUATION_QUESTION_ID = 1L; + + private final AnalysisAiClient analysisAiClient; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final ObjectMapper objectMapper; + private final EvaluationLlmSnapshotParser llmSnapshotParser; + private final EvaluationCandidateSnapshotParser candidateSnapshotParser; + private final EvaluationCandidateReviewSnapshotParser reviewSnapshotParser; + + public AnalysisAiEvaluationAnalysisGenerator( + AnalysisAiClient analysisAiClient, + JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + ObjectMapper objectMapper + ) { + this.analysisAiClient = analysisAiClient; + this.jobCategoryEvaluationCriteriaProvider = jobCategoryEvaluationCriteriaProvider; + this.objectMapper = objectMapper; + this.llmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + this.candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); + this.reviewSnapshotParser = new EvaluationCandidateReviewSnapshotParser(objectMapper); + } + + @Value("${evaluation.analysis.case-timeout.single-pass-seconds:70}") + private long singlePassCaseTimeoutSeconds; + + @Value("${evaluation.analysis.case-timeout.two-pass-seconds:130}") + private long twoPassCaseTimeoutSeconds; + + @Value("${evaluation.analysis.case-timeout.hybrid-exact-seconds:190}") + private long hybridExactCaseTimeoutSeconds; + + @Override + public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { + AnalysisPromptInput promptInput = new AnalysisPromptInput( + command.caseId(), + "평가용 회사", + command.jobCategorySmall(), + command.mainTasks(), + command.qualifications(), + command.preferences(), + List.of(new AnalysisPromptInput.QuestionAnswer( + EVALUATION_QUESTION_ID, + command.question(), + command.answer() + )) + ); + Instant deadline = Instant.now().plus(resolveCaseBudget()); + + AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( + promptInput, + jobCategoryEvaluationCriteriaProvider + .findByMiddleName(command.jobCategoryMiddle()) + .orElse(null), + deadline + ); + String rawLlmResponseJson = writeJson(aiCallResult.response()); + String rawCandidateResponseJson = writeJson(aiCallResult.rawCandidateResponse()); + String sanitizedCandidateResponseJson = writeJson(aiCallResult.sanitizedCandidateResponse()); + String candidateReviewResponseJson = writeJson(aiCallResult.candidateReviewResponse()); + return new EvaluationGeneratedResult( + llmSnapshotParser.parseRawLlmResponse(rawLlmResponseJson), + rawLlmResponseJson, + rawCandidateResponseJson, + sanitizedCandidateResponseJson, + candidateSnapshotParser.parse( + sanitizedCandidateResponseJson, + "sanitizedCandidateResponseJson", + command.caseId() + ), + candidateReviewResponseJson, + reviewSnapshotParser.parse(candidateReviewResponseJson), + aiCallResult.candidateCallLatencyMs(), + aiCallResult.finalCallLatencyMs() + ); + } + + private Duration resolveCaseBudget() { + long seconds = switch (analysisAiClient.resolveAnalysisMode()) { + case SINGLE_PASS -> singlePassCaseTimeoutSeconds; + case TWO_PASS -> twoPassCaseTimeoutSeconds; + case HYBRID_EXACT -> hybridExactCaseTimeoutSeconds; + }; + return Duration.ofSeconds(Math.max(1L, seconds)); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize evaluation analysis result.", e); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java deleted file mode 100644 index 6fb856cf..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.controller; - -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.jobposting.service.MockQuestionCacheService; -import com.jobdri.jobdri_api.domain.user.service.UserService; -import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.UserDetailsImpl; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/job-postings/mock") -@Tag(name = "Mock JobPosting", description = "모의 채용 공고 생성 및 추천 질문 API") -public class MockJobPostingController { - - private final MockJobPostingGenerationService mockJobPostingGenerationService; - private final MockQuestionCacheService mockQuestionCacheService; - private final UserService userService; - - @Operation( - summary = "모의 공고 생성", - description = "선택한 직무 중분류/소분류를 기반으로 기존 공고를 참고하여 가상의 모의 공고를 생성합니다." - ) - @PostMapping("/generate") - public ApiResponse generateMockJobPosting( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody JobPostingMockGenerateRequest request - ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "모의 공고 생성에 성공했습니다.", - mockJobPostingGenerationService.generate(request) - ); - } - - @Operation( - summary = "모의 공고 추천 질문 조회", - description = "선택한 회사/직무 기준으로 모의 공고 추천 질문을 조회합니다. 질문은 직무 기준 캐시를 재사용합니다." - ) - @GetMapping("/questions") - public ApiResponse getMockRecommendedQuestions( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @RequestParam Long companyId, - @RequestParam Long middleClassificationId, - @RequestParam Long detailClassificationId - ) { - validateAuthenticatedUser(userDetails); - JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest( - companyId, - middleClassificationId, - detailClassificationId - ); - return ApiResponse.onSuccess( - "모의 공고 추천 질문 조회에 성공했습니다.", - new JobPostingMockQuestionResponse(mockQuestionCacheService.getRecommendedQuestions(request)) - ); - } - - @Operation( - summary = "모의 공고 추천 질문 조회", - description = "선택한 회사/직무 기준으로 모의 공고 추천 질문을 조회합니다. 하위 호환을 위해 POST 요청도 지원합니다." - ) - @PostMapping("/questions") - public ApiResponse getMockRecommendedQuestionsByPost( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody JobPostingMockGenerateRequest request - ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "모의 공고 추천 질문 조회에 성공했습니다.", - new JobPostingMockQuestionResponse(mockQuestionCacheService.getRecommendedQuestions(request)) - ); - } - - private com.jobdri.jobdri_api.domain.user.entity.User validateAuthenticatedUser(UserDetailsImpl userDetails) { - return userService.validateUser(userDetails == null ? null : userDetails.getUser()); - } -} 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 3dc9a3c2..e672bff6 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,13 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record JobPostingWorkerFailureRequest( - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, @NotBlank @Size(max = 100) String workerId, 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 index 99e3ec15..ba24fdc0 100644 --- 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 @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record JobPostingWorkerRetryRequest( - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, @NotBlank @Size(max = 100) String workerId, 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 e1817253..1a84a1c9 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 @@ -32,7 +32,7 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - private TaskStatus status; + private AnalysisAsyncTaskStatus status; @Column(nullable = false, length = 255) private String message; @@ -42,7 +42,7 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(name = "failure_reason", length = 40) - private FailureReason failureReason; + private AnalysisAsyncFailureReason failureReason; @Column(name = "worker_id", length = 100) private String workerId; @@ -91,7 +91,7 @@ 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.status = AnalysisAsyncTaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); @@ -106,7 +106,7 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte if (isTerminal()) { return; } - this.status = TaskStatus.RUNNING; + this.status = AnalysisAsyncTaskStatus.RUNNING; this.message = "채용 공고 비동기 처리를 진행 중입니다."; this.currentStep = "EXTRACTING_CONTENT"; this.progressPercent = Math.max(resolveProgressPercent(), 20); @@ -125,7 +125,7 @@ public void markSuccess(String resultPayload) { if (isTerminal()) { return; } - this.status = TaskStatus.SUCCEEDED; + this.status = AnalysisAsyncTaskStatus.SUCCEEDED; this.message = "채용 공고 비동기 처리에 성공했습니다."; this.error = null; this.failureReason = null; @@ -136,7 +136,7 @@ public void markSuccess(String resultPayload) { this.estimatedRemainingSeconds = 0; } - public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } @@ -144,7 +144,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, markFailed(failureReason, errorMessage, retryCount); return; } - this.status = TaskStatus.PENDING; + this.status = AnalysisAsyncTaskStatus.PENDING; this.message = "채용 공고 비동기 재시도를 대기 중입니다."; this.currentStep = "VALIDATING_INPUT"; this.progressPercent = 0; @@ -154,11 +154,11 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, this.completedAt = null; } - public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } - this.status = TaskStatus.FAILED; + this.status = AnalysisAsyncTaskStatus.FAILED; this.message = "채용 공고 비동기 처리에 실패했습니다."; this.failureReason = failureReason; this.error = errorMessage; @@ -169,17 +169,17 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret } public void requestCancel() { - if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + if (status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED) { return; } this.cancelRequested = true; - if (status == TaskStatus.CANCELLED) { + if (status == AnalysisAsyncTaskStatus.CANCELLED) { if (cancelledAt == null) { this.cancelledAt = LocalDateTime.now(); } return; } - this.status = TaskStatus.CANCELLED; + this.status = AnalysisAsyncTaskStatus.CANCELLED; this.message = "채용 공고 비동기 작업이 취소되었습니다."; this.error = null; this.failureReason = null; @@ -199,14 +199,14 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } private int resolveProgressPercent() { return progressPercent == null ? 0 : progressPercent; } - public enum FailureReason { + public enum AnalysisAsyncFailureReason { RATE_LIMIT, QUEUE_TIMEOUT, WORKER_TIMEOUT, @@ -215,7 +215,7 @@ public enum FailureReason { INTERNAL_ERROR } - public enum TaskStatus { + public enum AnalysisAsyncTaskStatus { PENDING, RUNNING, SUCCEEDED, 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 5deefb5a..22f9bbaa 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 @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; 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.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -11,5 +11,5 @@ public interface JobPostingAsyncTaskRepository extends JpaRepository { Optional findByTaskIdAndUserId(String taskId, Long userId); - List findByStatusIn(Collection statuses); + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java deleted file mode 100644 index 5e7f7b52..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.service; - -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -public class JobPostingAiAsyncService { - - private final JobPostingAiService jobPostingAiService; - private final MockQuestionCacheService mockQuestionCacheService; - - @Async("llmAsyncExecutor") - public CompletableFuture generateMockJobPosting( - JobPostingMockGenerateRequest request, - Company company - ) { - return CompletableFuture.completedFuture( - jobPostingAiService.generateMockJobPosting(request, company) - ); - } - - @Async("llmAsyncExecutor") - public CompletableFuture> getRecommendedQuestions(JobPostingMockGenerateRequest request) { - return CompletableFuture.completedFuture( - mockQuestionCacheService.getRecommendedQuestions(request) - ); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java index ec18f267..6b6bc38f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,7 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(JobPostingAsyncStatusResponse statusResponse) { - TaskStatus status = TaskStatus.valueOf(statusResponse.getStatus()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + AnalysisAsyncTaskStatus status = AnalysisAsyncTaskStatus.valueOf(statusResponse.getStatus()); + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } } 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 ab9c46c6..62fc53c2 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,8 +5,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingProgressStepResponse; 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.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; @@ -86,16 +86,16 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestResponse result) { JobPostingAsyncTask task = getTaskState(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { return deserializeResult(task.getResultPayload()); } - if (task.getStatus() == TaskStatus.FAILED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); } - if (task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId @@ -109,7 +109,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } @Transactional - public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; @@ -120,7 +120,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin } @Transactional - public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; @@ -134,11 +134,11 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM @Transactional public JobPostingAsyncCancelResponse cancelTask(User user, String taskId) { JobPostingAsyncTask task = getOwnedTaskState(user, taskId); - TaskStatus previousStatus = task.getStatus(); + AnalysisAsyncTaskStatus previousStatus = task.getStatus(); LocalDateTime previousCancelledAt = task.getCancelledAt(); task.requestCancel(); - boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; - boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + boolean cancelled = task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != AnalysisAsyncTaskStatus.CANCELLED && cancelled; if (newlyCancelled) { recordProcessingMetric(task, "cancelled"); } @@ -182,7 +182,7 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { @Transactional public int sweepTimedOutTasks() { int expiredCount = 0; - for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING))) { if (expireTimedOutTaskIfNeeded(task)) { expiredCount++; } @@ -244,9 +244,9 @@ private JobPostingAsyncTask getTaskState(String taskId) { } private boolean isTerminal(JobPostingAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; } private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { @@ -255,11 +255,11 @@ private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { } LocalDateTime now = LocalDateTime.now(); - if (task.getStatus() == TaskStatus.PENDING + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, jobPostingQueueProperties.getQueueTimeoutSeconds())) { markFailed( task.getTaskId(), - FailureReason.QUEUE_TIMEOUT, + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, "채용 공고 작업이 대기열에서 시간 내 처리되지 않았습니다.", task.getRetryCount() ); @@ -267,11 +267,11 @@ && isExpired(task.getSubmittedAt(), now, jobPostingQueueProperties.getQueueTimeo } LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); - if (task.getStatus() == TaskStatus.RUNNING + if (task.getStatus() == AnalysisAsyncTaskStatus.RUNNING && isExpired(lastActivityAt, now, jobPostingQueueProperties.getProcessingTimeoutSeconds())) { markFailed( task.getTaskId(), - FailureReason.WORKER_TIMEOUT, + AnalysisAsyncFailureReason.WORKER_TIMEOUT, "채용 공고 작업이 처리 제한 시간을 초과했습니다.", task.getRetryCount() ); @@ -312,7 +312,7 @@ private List buildSteps(JobPostingAsyncTask task ); } - private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + private AsyncTaskProgressStatus toProgressStatus(AnalysisAsyncTaskStatus status) { return AsyncTaskProgressStatus.valueOf(status.name()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java index 4c7f9004..5bc1b274 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java @@ -4,8 +4,6 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; -import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; -import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.domain.user.entity.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,8 +13,6 @@ public class JobPostingExtensionIngestService { private final JobPostingIngestService jobPostingIngestService; - private final JobPostingService jobPostingService; - private final MockApplyService mockApplyService; public JobPostingExtensionIngestResponse ingest(User user, JobPostingExtensionIngestRequest request) { JobPostingIngestResponse ingest = jobPostingIngestService.ingestAndCreate( @@ -24,22 +20,11 @@ public JobPostingExtensionIngestResponse ingest(User user, JobPostingExtensionIn new JobPostingIngestRequest(request.rawText(), null) ); - MockApplyCreateResponse mockApply = null; - if (ingest.isSavedToDatabase() && ingest.getSaved() != null) { - Long jobPostingId = ingest.getSaved().getJobPostingId(); - try { - mockApply = mockApplyService.createMockApplyFromJobPosting(user, jobPostingId); - } catch (RuntimeException e) { - jobPostingService.deleteJobPosting(user, jobPostingId); - throw e; - } - } - return JobPostingExtensionIngestResponse.of( request.sourceUrl(), request.sourceSite(), ingest, - mockApply + null ); } } 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 4ed58f04..c93d0baf 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 @@ -9,8 +9,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFinalizeRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerResultStoreRequest; 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.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; @@ -71,7 +71,7 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp @Transactional public void markRetry( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -91,7 +91,7 @@ public void markRetry( @Transactional public void failTask( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -139,21 +139,21 @@ public JobPostingIngestResponse finalizeAndComplete( "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { workerTaskResultService.markDeliveredIfPresent(TaskType.JOB_POSTING_FINALIZE, taskId); return jobPostingAsyncTaskService.getTask(taskId).getResult(); } - if (task.getStatus() == TaskStatus.FAILED || task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { workerTaskResultService.markDeliveryFailedIfPresent( TaskType.JOB_POSTING_FINALIZE, taskId, - task.getStatus() == TaskStatus.CANCELLED + task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED ? "취소된 채용 공고 비동기 작업입니다." : "이미 실패 처리된 채용 공고 비동기 작업입니다." ); throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - task.getStatus() == TaskStatus.CANCELLED + task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED ? "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId : "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); @@ -236,13 +236,13 @@ private JobPostingAsyncTask getTask(String taskId) { } private boolean isTerminal(JobPostingAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; } private void rejectIfCancelled(JobPostingAsyncTask task, String message) { - if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED || task.isCancelRequested()) { throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java deleted file mode 100644 index 0bd85acb..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.service; - -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -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.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class MockJobPostingGenerationService { - - private final CompanyRepository companyRepository; - private final JobPostingAiAsyncService jobPostingAiAsyncService; - - public JobPostingMockGenerateResponse generate(JobPostingMockGenerateRequest request) { - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.COMPANY_NOT_FOUND, - "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() - )); - - CompletableFuture generatedPostingFuture = - jobPostingAiAsyncService.generateMockJobPosting(request, company); - CompletableFuture> recommendedQuestionsFuture = - jobPostingAiAsyncService.getRecommendedQuestions(request); - - CompletableFuture.allOf(generatedPostingFuture, recommendedQuestionsFuture).join(); - JobPostingMockGenerateResponse generatedPosting = generatedPostingFuture.join(); - - return new JobPostingMockGenerateResponse( - generatedPosting.companyName(), - generatedPosting.jobTitle(), - generatedPosting.task(), - generatedPosting.requirement(), - generatedPosting.preferred(), - generatedPosting.summary(), - recommendedQuestionsFuture.join() - ); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index 3d0f9f5d..0128851e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -1,8 +1,6 @@ package com.jobdri.jobdri_api.domain.mockapply.controller; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateActualRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockFromJobPostingRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyUpdateNameRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; @@ -237,111 +235,6 @@ public ApiResponse createActualApply( ); } - @Operation( - summary = "저장된 공고 기반 MOCK 타입 모의 서류 지원 생성", - description = "AI 초안 생성 후 사용자가 수정하여 저장한 채용 공고 ID를 기준으로 로그인 사용자의 MOCK 타입 모의 서류 지원을 생성합니다." - ) - @ApiResponses(value = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "모의 서류 지원 생성 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 정보 누락", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "403", - description = "다른 사용자의 공고 접근", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4031\",\"message\":\"해당 공고에 접근할 수 없습니다.\",\"result\":null,\"error\":\"해당 공고에 접근할 수 없습니다.\"}") - ) - ) - }) - @PostMapping("/mock/from-job-posting") - public ApiResponse createMockApplyFromJobPosting( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody MockApplyCreateMockFromJobPostingRequest request - ) { - return ApiResponse.onSuccess( - "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApplyFromJobPosting( - userDetails.getUser(), - request.jobPostingId(), - request.sequence() - ) - ); - } - - @Operation( - summary = "가상 공고 기반 모의 서류 지원 생성", - description = "선택한 소분류를 기준으로 가상 채용 공고와 MOCK 타입 모의 서류 지원을 함께 생성합니다." - ) - @ApiResponses(value = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "모의 서류 지원 생성 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "요청값 검증 실패", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"REQ_4002\",\"message\":\"파라미터 형식이 잘못되었습니다.\",\"result\":null,\"error\":[\"[companyId] 회사 ID는 필수입니다. (입력값: null)\"]}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 정보 누락", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "회사 또는 직무 분류 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = { - @ExampleObject(name = "company_not_found", value = "{\"isSuccess\":false,\"code\":\"COMPANY_4041\",\"message\":\"해당 회사를 찾을 수 없습니다. companyId=999\",\"result\":null,\"error\":\"해당 회사를 찾을 수 없습니다. companyId=999\"}"), - @ExampleObject(name = "classification_not_found", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\",\"result\":null,\"error\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\"}"), - @ExampleObject(name = "middle_detail_mismatch", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\",\"result\":null,\"error\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\"}") - } - ) - ) - }) - @PostMapping("/mock") - public ApiResponse createMockApply( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody MockApplyCreateMockRequest request - ) { - return ApiResponse.onSuccess( - "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApply(userDetails.getUser(), request) - ); - } - @Operation( summary = "모의 서류 지원 재도전", description = "기존 모의 서류 지원의 공고와 선택 문항을 복사해 새 회차의 모의 서류 지원을 생성합니다. 답변은 비워진 상태로 자소서 입력 단계부터 다시 진행합니다." diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java deleted file mode 100644 index af1d53fa..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.jobdri.jobdri_api.domain.mockapply.dto.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record MockApplyCreateMockFromJobPostingRequest( - @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId, - - @Positive(message = "지원 순번은 1 이상이어야 합니다.") - Integer sequence -) { - public MockApplyCreateMockFromJobPostingRequest(Long jobPostingId) { - this(jobPostingId, null); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java deleted file mode 100644 index b4a567e2..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.jobdri.jobdri_api.domain.mockapply.dto.request; - -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record MockApplyCreateMockRequest( - @NotNull(message = "회사 ID는 필수입니다.") - Long companyId, - - @NotNull(message = "중분류 ID는 필수입니다.") - Long middleClassificationId, - - @NotNull(message = "소분류 ID는 필수입니다.") - Long detailClassificationId, - - @Positive(message = "지원 순번은 1 이상이어야 합니다.") - Integer sequence -) { - public MockApplyCreateMockRequest(Long companyId, Long middleClassificationId, Long detailClassificationId) { - this(companyId, middleClassificationId, detailClassificationId, null); - } - - public JobPostingMockGenerateRequest toJobPostingMockGenerateRequest() { - return new JobPostingMockGenerateRequest(companyId, middleClassificationId, detailClassificationId); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index b7b15370..ee090335 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -2,24 +2,15 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockFromJobPostingRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeItemResponse; @@ -71,10 +62,7 @@ public class MockApplyService { private final MockApplyRepository mockApplyRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisRepository analysisRepository; - private final JobPostingRepository jobPostingRepository; - private final CompanyRepository companyRepository; private final QuestionRepository questionRepository; - private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; @@ -135,67 +123,6 @@ public MockApplyRetryResponse retryMockApply(User user, Long mockApplyId) { return MockApplyRetryResponse.of(sourceMockApply.getId(), retryMockApply); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { - return createMockApplyFromJobPosting(user, jobPostingId, null); - } - - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId, Integer sequence) { - User validatedUser = userService.validateUser(user); - JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - - MockApply mockApply = saveMockApplyWithSequence( - validatedUser, - jobPosting, - ApplyType.MOCK, - sequence - ); - return MockApplyCreateResponse.from(mockApply); - } - - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { - User validatedUser = userService.validateUser(user); - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.COMPANY_NOT_FOUND, - "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() - )); - - JobPostingMockGenerateResponse generated = - mockJobPostingGenerationService.generate(request.toJobPostingMockGenerateRequest()); - - JobPostingCreateRequest createRequest = new JobPostingCreateRequest( - JobPostingProfileColor.DEFAULT, - generated.jobTitle(), - company.getName(), - company.getSize(), - generated.jobTitle(), - request.detailClassificationId(), - generated.task(), - generated.requirement(), - generated.preferred() - ); - Long savedJobPostingId = jobPostingService.createJobPosting(validatedUser, createRequest).getJobPostingId(); - JobPosting savedJobPosting = jobPostingRepository.findById(savedJobPostingId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.JOB_POSTING_NOT_FOUND, - "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId - )); - - MockApply mockApply = saveMockApplyWithSequence( - validatedUser, - savedJobPosting, - ApplyType.MOCK, - request.sequence() - ); - return MockApplyCreateResponse.from(mockApply); - } - public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { User validatedUser = userService.validateUser(user); MockApply mockApply = getOwnedMockApply(validatedUser, mockApplyId); @@ -335,7 +262,7 @@ private Map findActiveAnalysisTaskIds(Long userId, List return analysisAsyncTaskRepository.findByUserIdAndMockApplyIdInAndStatusIn( userId, mockApplyIds, - List.of(TaskStatus.PENDING, TaskStatus.RUNNING) + List.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING) ) .stream() .sorted(Comparator diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java index 0573b67b..c8ba1cfd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java @@ -7,7 +7,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PortOnePaymentCompleteRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.service.CouponService; import com.jobdri.jobdri_api.domain.payment.service.PaymentService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; @@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -160,4 +161,18 @@ public ApiResponse> getTransactions( paymentService.getTransactions(userDetails.getUser(), type) ); } + + @Operation(summary = "내 크레딧 거래 내역 페이징 조회", description = "크레딧 거래 내역을 페이지 단위로 조회합니다. type query parameter로 필터링할 수 있습니다.") + @GetMapping("/credits/me/transactions/page") + public ApiResponse> getTransactionsPage( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(required = false) CreditTransactionType type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + "크레딧 거래 내역 페이징 조회에 성공했습니다.", + paymentService.getTransactionsPage(userDetails.getUser(), type, page, size) + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java similarity index 71% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java index fa493adb..29a87b4d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java similarity index 75% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java index 5569258b..61102005 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java similarity index 74% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java index 4de357ee..13bb5d15 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java similarity index 76% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java index 49355eb1..b66ec347 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java index f8cb7ea0..9d998dda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java index e49508a3..f05f6a2c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; public record PortOnePrepareData( String storeId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java similarity index 86% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java index 8eed1c88..3ae4515c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java similarity index 72% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java index b5132025..00723fb5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java similarity index 80% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java index ae15c03a..5f4f0692 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java similarity index 84% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java index 91bb8031..74c64f31 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java index f799588c..34451996 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; public record TossPayCreateRequest( String orderNo, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java index f8628675..f4c64ca3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java similarity index 82% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java index 683ea842..de735c9e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java index 51ce6059..4ec96d50 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java similarity index 62% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java index 96ccadf6..353e7d84 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; public record TossPayStatusRequest( String apiKey, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java similarity index 86% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java index 522b07f7..6ff8cd54 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java index 88898c8e..b437a911 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; public record CreditPlanResponse( String planCode, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java index c1f91269..1eb337fa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import java.time.LocalDateTime; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java index 465ff1ec..935448a9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentConfirmResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java index c37ad0b9..99d19986 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentOrderStatusResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java index 57cba441..2a2db39b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; public record PaymentPrepareResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java index 2eaba9a7..09df0d86 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentRefundResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java index 84ac802c..c18e23b6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.entity; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.entity.CreatedAtEntity; import jakarta.persistence.*; 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 a0286a0a..57d9425f 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 @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.entity; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.entity.BaseEntity; import jakarta.persistence.*; @@ -228,6 +230,49 @@ public void refundByPortOne(String externalStatus, String refundReason) { this.lastStatusCheckedAt = LocalDateTime.now(); } + public void completeByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> completeByTossPay(providerStatus); + case PORTONE -> completeByPortOne(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 완료"); + } + } + + public void failByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> failByTossPay(providerStatus); + case PORTONE -> failByPortOne(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 실패"); + } + } + + public void markUnknownByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> { + markTossPayUnknown(); + updateTossStatus(providerStatus); + } + case PORTONE -> markPortOneUnknown(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 상태 미확정"); + } + } + + public void updateProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> updateTossStatus(providerStatus); + case PORTONE -> updatePortOneStatus(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 상태 갱신"); + } + } + + public void refundByProviderStatus(String providerStatus, String refundReason) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> refundByTossPay(providerStatus, refundReason); + case PORTONE -> refundByPortOne(providerStatus, refundReason); + default -> throw unsupportedProvider("결제 환불"); + } + } + private void validateRefundableStatus() { if (this.status != PaymentStatus.COMPLETED) { throw new IllegalStateException("완료된 결제만 환불 상태로 변경할 수 있습니다."); @@ -258,4 +303,8 @@ public PaymentProviderType getProviderOrDefault() { public boolean isProvider(PaymentProviderType provider) { return getProviderOrDefault() == provider; } + + private IllegalStateException unsupportedProvider(String action) { + return new IllegalStateException("지원하지 않는 결제 제공자입니다. action=" + action + ", provider=" + getProviderOrDefault()); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java new file mode 100644 index 00000000..d8426046 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java @@ -0,0 +1,27 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; + +/** + * Normalized payment gateway contract used to isolate provider-specific DTOs + * and API flows from application services. + */ +public interface PaymentGateway { + + PaymentProviderType type(); + + GatewayPrepareResult prepare(GatewayPrepareCommand command); + + GatewayPaymentSnapshot confirm(GatewayConfirmCommand command); + + GatewayPaymentSnapshot fetch(GatewayPaymentQuery query); + + GatewayRefundResult refund(GatewayRefundCommand command); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java new file mode 100644 index 00000000..13fdcc7a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java @@ -0,0 +1,118 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; +import com.jobdri.jobdri_api.domain.payment.service.PortOneClient; +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.Component; + +@Component +@RequiredArgsConstructor +public class PortOnePaymentGateway implements PaymentGateway { + + public static final String CANCELED_EXTERNAL_STATUS = "CANCELLED"; + + private final PortOneClient portOneClient; + + @Override + public PaymentProviderType type() { + return PaymentProviderType.PORTONE; + } + + @Override + public GatewayPrepareResult prepare(GatewayPrepareCommand command) { + var response = portOneClient.prepareData(); + return new GatewayPrepareResult( + type(), + command.orderId(), + null, + null, + response.redirectUrl(), + response.storeId(), + response.channelKey(), + "KRW" + ); + } + + @Override + public GatewayPaymentSnapshot fetch(GatewayPaymentQuery query) { + var response = portOneClient.getPayment(query.externalPaymentId()); + return new GatewayPaymentSnapshot( + type(), + mapStatus(response.status()), + response.id(), + null, + null, + response.id(), + response.transactionId(), + response.status(), + response.storeId(), + response.currency(), + response.amount() == null ? null : response.amount().total() + ); + } + + @Override + public GatewayPaymentSnapshot confirm(GatewayConfirmCommand command) { + throw unsupported(); + } + + @Override + public GatewayRefundResult refund(GatewayRefundCommand command) { + if (isBlank(command.externalPaymentId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "포트원 paymentId가 없는 결제는 환불할 수 없습니다."); + } + PortOneCancelResponse response = portOneClient.cancelPayment( + command.externalPaymentId(), + command.amount(), + command.reason() + ); + validateRefundResponse(response, command.amount()); + return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, CANCELED_EXTERNAL_STATUS); + } + + public static GatewayPaymentStatus mapStatus(String externalStatus) { + if (externalStatus == null) { + return GatewayPaymentStatus.UNKNOWN; + } + return switch (externalStatus.toUpperCase()) { + case "READY", "PENDING", "VIRTUAL_ACCOUNT_ISSUED", "PAY_PENDING", "CANCEL_PENDING" -> + GatewayPaymentStatus.PENDING; + case "PAID" -> GatewayPaymentStatus.COMPLETED; + case "PARTIAL_CANCELLED" -> GatewayPaymentStatus.UNKNOWN; + case "FAILED" -> GatewayPaymentStatus.FAILED; + case CANCELED_EXTERNAL_STATUS -> GatewayPaymentStatus.CANCELED; + default -> GatewayPaymentStatus.UNKNOWN; + }; + } + + private UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("PortOnePaymentGateway currently supports prepare, fetch, and refund only."); + } + + private void validateRefundResponse(PortOneCancelResponse response, int amount) { + if (response == null + || response.cancellation() == null + || isBlank(response.cancellation().id()) + || !"SUCCEEDED".equals(response.cancellation().status()) + || response.cancellation().amount() == null + || response.cancellation().amount() != amount) { + throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "포트원 결제 취소 응답 검증에 실패했습니다."); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java new file mode 100644 index 00000000..e8916e96 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java @@ -0,0 +1,106 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; +import com.jobdri.jobdri_api.domain.payment.service.TossPayClient; +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.Component; + +@Component +@RequiredArgsConstructor +public class TossPayPaymentGateway implements PaymentGateway { + + private static final String CURRENCY = "KRW"; + + private final TossPayClient tossPayClient; + + @Override + public PaymentProviderType type() { + return PaymentProviderType.TOSS_PAY_DIRECT; + } + + @Override + public GatewayPrepareResult prepare(GatewayPrepareCommand command) { + var response = tossPayClient.createPayment( + command.orderId(), + command.amount(), + command.orderName() + ); + return new GatewayPrepareResult( + type(), + null, + response.payToken(), + response.checkoutPage(), + null, + null, + null, + CURRENCY + ); + } + + @Override + public GatewayPaymentSnapshot confirm(GatewayConfirmCommand command) { + throw unsupported(); + } + + @Override + public GatewayPaymentSnapshot fetch(GatewayPaymentQuery query) { + throw unsupported(); + } + + @Override + public GatewayRefundResult refund(GatewayRefundCommand command) { + if (isBlank(command.payToken())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "토스페이 payToken이 없는 결제는 환불할 수 없습니다."); + } + String refundNo = refundNo(command.paymentId()); + TossPayRefundResponse response = tossPayClient.refundPayment( + command.payToken(), + command.orderId(), + refundNo, + command.amount(), + command.reason() + ); + validateRefundResponse(response, command, refundNo); + return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, response.payStatus()); + } + + private UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("TossPayPaymentGateway currently supports prepare and refund only."); + } + + private void validateRefundResponse( + TossPayRefundResponse response, + GatewayRefundCommand command, + String refundNo + ) { + if (response == null + || !response.successful() + || !refundNo.equals(response.refundNo()) + || !command.payToken().equals(response.payToken()) + || !TossPayStatus.REFUND_SUCCESS.name().equals(response.payStatus()) + || response.refundedAmount() == null + || response.refundedAmount() != command.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "토스페이 환불 응답 검증에 실패했습니다."); + } + } + + private String refundNo(Long paymentId) { + return "jobdri-refund-" + paymentId; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java new file mode 100644 index 00000000..961ba765 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.command; + +public record GatewayConfirmCommand( + String orderId, + String paymentKey, + int amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java new file mode 100644 index 00000000..2f14beb4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.command; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; + +public record GatewayPrepareCommand( + PaymentProviderType provider, + String orderId, + String orderName, + int amount, + int creditAmount, + String customerEmail +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java new file mode 100644 index 00000000..70a93b10 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.command; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; + +public record GatewayRefundCommand( + Long paymentId, + PaymentProviderType provider, + String orderId, + String externalPaymentId, + String payToken, + int amount, + String reason +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java new file mode 100644 index 00000000..2f5117c5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.query; + +public record GatewayPaymentQuery( + String orderId, + String externalPaymentId, + String payToken +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java new file mode 100644 index 00000000..8bba7d05 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.result; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; + +public record GatewayPaymentSnapshot( + PaymentProviderType provider, + GatewayPaymentStatus status, + String orderId, + String paymentKey, + String payToken, + String externalPaymentId, + String externalTransactionId, + String externalStatus, + String storeId, + String currency, + Integer amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java new file mode 100644 index 00000000..1d3c8742 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.result; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; + +public record GatewayPrepareResult( + PaymentProviderType provider, + String externalPaymentId, + String payToken, + String checkoutPage, + String redirectUrl, + String storeId, + String channelKey, + String currency +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java new file mode 100644 index 00000000..197afa7a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.result; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; + +public record GatewayRefundResult( + PaymentProviderType provider, + GatewayRefundStatus status, + String externalStatus +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java new file mode 100644 index 00000000..4849b970 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.type; + +public enum GatewayPaymentStatus { + PENDING, + PROCESSING, + APPROVED, + COMPLETED, + REFUNDED, + CANCELED, + FAILED, + UNKNOWN +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java new file mode 100644 index 00000000..5f9501d5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.type; + +public enum GatewayRefundStatus { + SUCCEEDED, + FAILED, + UNKNOWN +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java index b2c57827..f6251c68 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.payment.repository; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,5 +12,7 @@ public interface CreditTransactionRepository extends JpaRepository { List findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId); List findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type); + Page findAllByUserId(Long userId, Pageable pageable); + Page findAllByUserIdAndType(Long userId, CreditTransactionType type, Pageable pageable); Optional findByUserIdAndTypeAndReferenceId(Long userId, CreditTransactionType type, String referenceId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java index c11fc685..d03f8fea 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.repository; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java index 9ec8c8d2..7b89c4ff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.service; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; 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 6f2b896f..17a2b3ea 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 @@ -2,26 +2,29 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneWebhookPayload; +import com.jobdri.jobdri_api.domain.payment.gateway.PaymentGateway; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneWebhookPayload; import com.jobdri.jobdri_api.domain.payment.dto.request.PortOnePaymentCompleteRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.entity.UserRole; @@ -30,8 +33,13 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; +import com.jobdri.jobdri_api.global.pagination.PaginationPolicy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -48,6 +56,7 @@ @Transactional(readOnly = true) public class PaymentService { + private static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE; private static final String EXPECTED_PAYMENT_METHOD = "간편결제"; private static final String EXPECTED_EASY_PAY_PROVIDER = "토스페이"; private static final String ORDER_ID_PREFIX = "jobdri-"; @@ -56,6 +65,7 @@ public class PaymentService { private final UserService userService; private final CreditTransactionRepository creditTransactionRepository; private final PaymentTransactionService paymentTransactionService; + private final List paymentGateways; private final TossPaymentClient tossPaymentClient; private final TossPayClient tossPayClient; private final PortOneClient portOneClient; @@ -88,15 +98,18 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) validatedUser.getId(), plan, orderId, - PaymentProviderType.TOSS_PAY_DIRECT + provider ); - TossPayCreateResponse tossPayResponse; + GatewayPrepareResult prepareResult; try { - tossPayResponse = tossPayClient.createPayment( + prepareResult = resolveGateway(provider).prepare(new GatewayPrepareCommand( + provider, payment.getOrderId(), + payment.getContent(), payment.getPrice(), - payment.getContent() - ); + payment.getCreditAmount(), + validatedUser.getEmail() + )); } catch (RuntimeException e) { if (e instanceof GeneralException generalException && generalException.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { @@ -111,15 +124,15 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) payment = paymentTransactionService.completeTossPayCreation( validatedUser.getId(), payment.getOrderId(), - tossPayResponse.payToken(), - tossPayResponse.checkoutPage() + prepareResult.payToken(), + prepareResult.checkoutPage() ); } catch (RuntimeException e) { paymentTransactionService.markTossPayCreationUnknown( validatedUser.getId(), payment.getOrderId(), - tossPayResponse.payToken(), - tossPayResponse.checkoutPage() + prepareResult.payToken(), + prepareResult.checkoutPage() ); throw e; } @@ -134,14 +147,31 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) return PaymentPrepareResponse.of(payment, validatedUser.getEmail()); } + private PaymentGateway resolveGateway(PaymentProviderType provider) { + return paymentGateways.stream() + .filter(gateway -> gateway.type() == provider) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "지원하는 결제 게이트웨이를 찾을 수 없습니다. provider=" + provider + )); + } + private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan plan) { - PortOnePrepareData prepareData = portOneClient.prepareData(); String orderId = generateOrderId(); Payment payment = paymentTransactionService.createPortOnePendingPayment( validatedUser.getId(), plan, orderId ); + GatewayPrepareResult prepareResult = resolveGateway(PaymentProviderType.PORTONE).prepare(new GatewayPrepareCommand( + PaymentProviderType.PORTONE, + payment.getOrderId(), + payment.getContent(), + payment.getPrice(), + payment.getCreditAmount(), + validatedUser.getEmail() + )); try (var ignored = LoggingContext.with( "payment.portone.prepare.completed", null, @@ -152,9 +182,9 @@ private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan pla return PaymentPrepareResponse.portOne( payment, validatedUser.getEmail(), - prepareData.storeId(), - prepareData.channelKey(), - prepareData.redirectUrl() + prepareResult.storeId(), + prepareResult.channelKey(), + prepareResult.redirectUrl() ); } @@ -247,7 +277,15 @@ public PaymentRefundResponse refund(User user, Long paymentId, PaymentRefundRequ ); } try { - RefundExternalResult result = refundExternalPayment(start, reason); + GatewayRefundResult result = resolveGateway(start.provider()).refund(new GatewayRefundCommand( + start.paymentId(), + start.provider(), + start.orderId(), + start.externalPaymentId(), + start.payToken(), + start.amount(), + reason + )); return paymentTransactionService.completeRefund( start.paymentId(), start.provider(), @@ -263,10 +301,12 @@ public PaymentRefundResponse refund(User user, Long paymentId, PaymentRefundRequ @Transactional(propagation = Propagation.NOT_SUPPORTED) public PaymentConfirmResponse completePortOne(User user, PortOnePaymentCompleteRequest request) { User validatedUser = userService.validateUser(user); - PortOnePaymentResponse response = portOneClient.getPayment(request.paymentId()); + GatewayPaymentSnapshot paymentSnapshot = resolveGateway(PaymentProviderType.PORTONE).fetch( + new GatewayPaymentQuery(null, request.paymentId(), null) + ); return paymentTransactionService.applyPortOnePayment( validatedUser.getId(), - response, + paymentSnapshot, portOneClient.storeId() ); } @@ -362,6 +402,29 @@ public List getTransactions(User user, CreditTransact .toList(); } + public Page getTransactionsPage( + User user, + CreditTransactionType type, + int page, + int size + ) { + User validatedUser = userService.validateUser(user); + Pageable pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), MAX_PAGE_SIZE), + Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ) + ); + if (type == null) { + return creditTransactionRepository.findAllByUserId(validatedUser.getId(), pageable) + .map(CreditTransactionResponse::from); + } + return creditTransactionRepository.findAllByUserIdAndType(validatedUser.getId(), type, pageable) + .map(CreditTransactionResponse::from); + } + private void validateTossResponse(Long userId, PaymentConfirmRequest request, TossPaymentConfirmResponse response) { if (response == null || !request.orderId().equals(response.orderId()) @@ -443,76 +506,6 @@ private void validateTossPayStatusResponse(TossPayStatusResponse response, Strin } } - private RefundExternalResult refundExternalPayment( - PaymentTransactionService.PaymentRefundStart start, - String reason - ) { - return switch (start.provider()) { - case PORTONE -> refundPortOnePayment(start, reason); - case TOSS_PAY_DIRECT -> refundTossPayPayment(start, reason); - default -> throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "지원하지 않는 결제수단입니다."); - }; - } - - private RefundExternalResult refundPortOnePayment(PaymentTransactionService.PaymentRefundStart start, String reason) { - if (isBlank(start.externalPaymentId())) { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "포트원 paymentId가 없는 결제는 환불할 수 없습니다."); - } - PortOneCancelResponse response = portOneClient.cancelPayment(start.externalPaymentId(), start.amount(), reason); - validatePortOneRefundResponse(response, start); - return new RefundExternalResult("CANCELLED"); - } - - private RefundExternalResult refundTossPayPayment(PaymentTransactionService.PaymentRefundStart start, String reason) { - if (isBlank(start.payToken())) { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "토스페이 payToken이 없는 결제는 환불할 수 없습니다."); - } - String refundNo = tossPayRefundNo(start.paymentId()); - TossPayRefundResponse response = tossPayClient.refundPayment( - start.payToken(), - start.orderId(), - refundNo, - start.amount(), - reason - ); - validateTossPayRefundResponse(response, start, refundNo); - return new RefundExternalResult(response.payStatus()); - } - - private void validatePortOneRefundResponse( - PortOneCancelResponse response, - PaymentTransactionService.PaymentRefundStart start - ) { - if (response == null - || response.cancellation() == null - || isBlank(response.cancellation().id()) - || !"SUCCEEDED".equals(response.cancellation().status()) - || response.cancellation().amount() == null - || response.cancellation().amount() != start.amount()) { - throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "포트원 결제 취소 응답 검증에 실패했습니다."); - } - } - - private void validateTossPayRefundResponse( - TossPayRefundResponse response, - PaymentTransactionService.PaymentRefundStart start, - String refundNo - ) { - if (response == null - || !response.successful() - || !refundNo.equals(response.refundNo()) - || !start.payToken().equals(response.payToken()) - || !TossPayStatus.REFUND_SUCCESS.name().equals(response.payStatus()) - || response.refundedAmount() == null - || response.refundedAmount() != start.amount()) { - throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "토스페이 환불 응답 검증에 실패했습니다."); - } - } - - private String tossPayRefundNo(Long paymentId) { - return "jobdri-refund-" + paymentId; - } - private String normalizeRefundReason(String reason) { if (isBlank(reason)) { return DEFAULT_REFUND_REASON; @@ -526,13 +519,11 @@ public boolean shouldAcknowledgeTossPayCallbackFailure(Exception exception) { } BaseErrorCode code = generalException.getCode(); return code == GeneralErrorCode.INVALID_PARAMETER + || code == GeneralErrorCode.PAYMENT_ALREADY_PROCESSED || code == GeneralErrorCode.PAYMENT_CONFIRM_FAILED; } private boolean isBlank(String value) { return value == null || value.isBlank(); } - - private record RefundExternalResult(String externalStatus) { - } } 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 b85da781..43972ed8 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 @@ -1,16 +1,18 @@ package com.jobdri.jobdri_api.domain.payment.service; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.PortOnePaymentGateway; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentOrderStatusResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentRefundResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.PortOnePaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -182,13 +184,7 @@ public PaymentRefundResponse completeRefund( if (!payment.isProvider(provider)) { throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "환불 요청 결제수단이 일치하지 않습니다."); } - if (provider == PaymentProviderType.PORTONE) { - payment.refundByPortOne(externalStatus, reason); - } else if (provider == PaymentProviderType.TOSS_PAY_DIRECT) { - payment.refundByTossPay(externalStatus, reason); - } else { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "지원하지 않는 결제수단입니다."); - } + payment.refundByProviderStatus(externalStatus, reason); int creditBalance = creditService.use( user, payment.getCreditAmount(), @@ -216,40 +212,24 @@ public PaymentConfirmResponse applyTossPayStatus(String orderId, String payToken "결제 정보를 찾을 수 없습니다. orderId=" + orderId )); validateTossPayStatus(payment, payToken, amount); + GatewayPaymentStatus normalizedStatus = normalizeTossPayStatus(tossPayStatus); - if (payment.getStatus() == PaymentStatus.REFUNDED) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - if (payment.getStatus() == PaymentStatus.COMPLETED) { - if (tossPayStatus == TossPayStatus.PAY_CANCEL) { - payment.updateTossStatus(tossPayStatus.name()); - } - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (payment.getStatus() == PaymentStatus.FAILED || payment.getStatus() == PaymentStatus.PROCESSING) { + if (payment.getStatus() == PaymentStatus.FAILED) { throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "처리할 수 없는 결제 상태입니다."); } - - if (tossPayStatus == TossPayStatus.PAY_COMPLETE) { - User user = userService.getUser(payment.getUser().getId()); - payment.completeByTossPay(tossPayStatus.name()); - int creditBalance = creditService.charge( - user, - payment.getCreditAmount(), - payment.getContent(), - payment.getOrderId() - ); - return PaymentConfirmResponse.of(payment, creditBalance); + if (payment.getStatus() == PaymentStatus.COMPLETED + && normalizedStatus != GatewayPaymentStatus.CANCELED + && normalizedStatus != GatewayPaymentStatus.REFUNDED) { + return currentConfirmResponse(payment, userService.getUser(payment.getUser().getId())); } - if (tossPayStatus == TossPayStatus.PAY_CANCEL) { - payment.failByTossPay(tossPayStatus.name()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - payment.updateTossStatus(tossPayStatus.name()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + return applyGatewayPaymentTransition( + payment, + normalizedStatus, + tossPayStatus.name(), + null, + chargeReferenceId(payment) + ); } @Transactional @@ -257,6 +237,11 @@ public PaymentConfirmResponse applyPortOnePayment(Long userId, PortOnePaymentRes return applyPortOnePayment(userId, response, expectedStoreId, true); } + @Transactional + public PaymentConfirmResponse applyPortOnePayment(Long userId, GatewayPaymentSnapshot snapshot, String expectedStoreId) { + return applyPortOnePayment(userId, snapshot, expectedStoreId, true); + } + @Transactional public PaymentConfirmResponse applyPortOneWebhookPayment(PortOnePaymentResponse response, String expectedStoreId) { return applyPortOnePayment(null, response, expectedStoreId, false); @@ -269,49 +254,32 @@ private PaymentConfirmResponse applyPortOnePayment( boolean requireOwner ) { validatePortOneResponseMinimum(response); - Payment payment = paymentRepository.findByOrderIdForUpdate(response.id()) + return applyPortOnePayment(userId, toGatewaySnapshot(response), expectedStoreId, requireOwner); + } + + private PaymentConfirmResponse applyPortOnePayment( + Long userId, + GatewayPaymentSnapshot snapshot, + String expectedStoreId, + boolean requireOwner + ) { + validatePortOneSnapshotMinimum(snapshot); + Payment payment = paymentRepository.findByOrderIdForUpdate(snapshot.orderId()) .orElseThrow(() -> new GeneralException( GeneralErrorCode.PAYMENT_NOT_FOUND, - "결제 정보를 찾을 수 없습니다. paymentId=" + response.id() + "결제 정보를 찾을 수 없습니다. paymentId=" + snapshot.orderId() )); if (requireOwner && !payment.belongsTo(userId)) { throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); } - validatePortOnePayment(payment, response, expectedStoreId); - - PortOnePaymentStatus portOneStatus = PortOnePaymentStatus.from(response.status()); - if (payment.getStatus() == PaymentStatus.REFUNDED) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - if (payment.getStatus() == PaymentStatus.COMPLETED) { - payment.updatePortOneStatus(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.PAID) { - User user = userService.getUser(payment.getUser().getId()); - payment.completeByPortOne(response.status(), response.transactionId()); - int creditBalance = creditService.charge( - user, - payment.getCreditAmount(), - payment.getContent(), - "PAYMENT:PORTONE:" + payment.getOrderId() - ); - return PaymentConfirmResponse.of(payment, creditBalance); - } - - if (portOneStatus == PortOnePaymentStatus.FAILED || portOneStatus == PortOnePaymentStatus.CANCELLED) { - payment.failByPortOne(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.UNKNOWN || portOneStatus == PortOnePaymentStatus.PARTIAL_CANCELLED) { - payment.markPortOneUnknown(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - payment.updatePortOneStatus(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + validatePortOnePayment(payment, snapshot, expectedStoreId); + return applyGatewayPaymentTransition( + payment, + snapshot.status(), + snapshot.externalStatus(), + snapshot.externalTransactionId(), + chargeReferenceId(payment) + ); } @Transactional @@ -392,6 +360,119 @@ private void validatePortOneResponseMinimum(PortOnePaymentResponse response) { } } + private void validatePortOneSnapshotMinimum(GatewayPaymentSnapshot snapshot) { + if (snapshot == null || snapshot.orderId() == null || snapshot.orderId().isBlank()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 조회 응답 검증에 실패했습니다."); + } + } + + private PaymentConfirmResponse applyGatewayPaymentTransition( + Payment payment, + GatewayPaymentStatus normalizedStatus, + String providerStatus, + String providerTransactionId, + String chargeReferenceId + ) { + User user = userService.getUser(payment.getUser().getId()); + + if (payment.getStatus() == PaymentStatus.REFUNDED) { + return currentConfirmResponse(payment, user); + } + if (payment.getStatus() == PaymentStatus.COMPLETED + && normalizedStatus != GatewayPaymentStatus.REFUNDED) { + payment.updateProviderStatus(providerStatus, providerTransactionId); + return currentConfirmResponse(payment, user); + } + + return switch (normalizedStatus) { + case COMPLETED -> PaymentConfirmResponse.of( + payment, + completeAndCharge(user, payment, providerStatus, providerTransactionId, chargeReferenceId) + ); + case REFUNDED -> PaymentConfirmResponse.of( + payment, + refundAndRecover(user, payment, providerStatus) + ); + case FAILED, CANCELED -> { + payment.failByProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment, user); + } + case UNKNOWN -> { + payment.markUnknownByProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment, user); + } + default -> { + payment.updateProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment, user); + } + }; + } + + private int completeAndCharge( + User user, + Payment payment, + String providerStatus, + String providerTransactionId, + String chargeReferenceId + ) { + payment.completeByProviderStatus(providerStatus, providerTransactionId); + return creditService.charge( + user, + payment.getCreditAmount(), + payment.getContent(), + chargeReferenceId + ); + } + + private int refundAndRecover(User user, Payment payment, String providerStatus) { + payment.refundByProviderStatus(providerStatus, null); + return creditService.use( + user, + payment.getCreditAmount(), + "결제 환불 크레딧 회수", + refundCreditReferenceId(payment) + ); + } + + private PaymentConfirmResponse currentConfirmResponse(Payment payment, User user) { + return PaymentConfirmResponse.of(payment, user.getCredit()); + } + + private String chargeReferenceId(Payment payment) { + if (payment.isProvider(PaymentProviderType.PORTONE)) { + return "PAYMENT:PORTONE:" + payment.getOrderId(); + } + return payment.getOrderId(); + } + + private GatewayPaymentSnapshot toGatewaySnapshot(PortOnePaymentResponse response) { + return new GatewayPaymentSnapshot( + PaymentProviderType.PORTONE, + PortOnePaymentGateway.mapStatus(response.status()), + response.id(), + null, + null, + response.id(), + response.transactionId(), + response.status(), + response.storeId(), + response.currency(), + response.amount() == null ? null : response.amount().total() + ); + } + + private GatewayPaymentStatus normalizeTossPayStatus(TossPayStatus tossPayStatus) { + if (tossPayStatus == null) { + return GatewayPaymentStatus.UNKNOWN; + } + return switch (tossPayStatus) { + case PAY_COMPLETE -> GatewayPaymentStatus.COMPLETED; + case PAY_CANCEL -> GatewayPaymentStatus.CANCELED; + case PAY_APPROVED -> GatewayPaymentStatus.APPROVED; + case REFUND_SUCCESS -> GatewayPaymentStatus.REFUNDED; + }; + } + private void validatePortOnePayment(Payment payment, PortOnePaymentResponse response, String expectedStoreId) { if (!payment.isProvider(PaymentProviderType.PORTONE)) { throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 건이 아닙니다."); @@ -413,6 +494,27 @@ private void validatePortOnePayment(Payment payment, PortOnePaymentResponse resp } } + private void validatePortOnePayment(Payment payment, GatewayPaymentSnapshot snapshot, String expectedStoreId) { + if (!payment.isProvider(PaymentProviderType.PORTONE)) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 건이 아닙니다."); + } + if (payment.getExternalPaymentId() == null || !payment.getExternalPaymentId().equals(snapshot.externalPaymentId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 paymentId가 일치하지 않습니다."); + } + if (expectedStoreId != null && !expectedStoreId.isBlank() && !expectedStoreId.equals(snapshot.storeId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 storeId가 일치하지 않습니다."); + } + if (!"KRW".equals(snapshot.currency())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "지원하지 않는 포트원 결제 통화입니다."); + } + if (snapshot.amount() == null) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 금액이 누락되었습니다."); + } + if (payment.getPrice() != snapshot.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다."); + } + } + private String refundCreditReferenceId(Payment payment) { return "PAYMENT_REFUND:" + payment.getProviderOrDefault() + ":" + payment.getOrderId(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java index 3dd1c8cf..91d84b74 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelRequest; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePrepareData; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java index 3923ab4a..c7e391b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java @@ -1,11 +1,11 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; 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 39713480..fdcdbbf5 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 @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmRequest; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java index 6256b569..1959039f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java similarity index 61% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java index 64575358..2d61aa1f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum CreditTransactionType { CHARGE, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java index 291ce590..b540a3db 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java similarity index 73% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java index 5e75072c..754d9563 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import lombok.Getter; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java similarity index 90% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java index c12e9835..e89ebdd4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum PortOnePaymentStatus { READY, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java similarity index 65% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java index 2db90ae2..c0fae5e8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum TossPayStatus { PAY_COMPLETE, diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java new file mode 100644 index 00000000..c4c82175 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock systemClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql b/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql new file mode 100644 index 00000000..6456a708 --- /dev/null +++ b/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created_at_id_desc + ON credit_transactions (user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_type_created_at_id_desc + ON credit_transactions (user_id, type, created_at DESC, id DESC); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index ce528de9..5da74226 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -58,6 +58,10 @@ CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status ON analysis_async_tasks (user_id, mock_apply_id, status); +CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_async_tasks_credit_reference_id_unique + ON analysis_async_tasks (credit_reference_id) + WHERE credit_reference_id IS NOT NULL; + ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; @@ -109,6 +113,9 @@ ALTER TABLE IF EXISTS analysis_async_tasks ALTER TABLE IF EXISTS analysis_async_tasks ADD COLUMN IF NOT EXISTS input_fingerprint_snapshot VARCHAR(64); +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS credit_reference_version INTEGER NOT NULL DEFAULT 0; + ALTER TABLE IF EXISTS job_posting_async_tasks ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; @@ -161,3 +168,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_pay_token_unique CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_external_payment_id_unique ON payments (external_payment_id) WHERE external_payment_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created_at_id_desc + ON credit_transactions (user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_type_created_at_id_desc + ON credit_transactions (user_id, type, created_at DESC, id DESC); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java index 5c44861c..ab30ecf2 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java index bd45bc44..2484d37f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java new file mode 100644 index 00000000..e4905c2d --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java @@ -0,0 +1,109 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.ai; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisResponseParser; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.StructuredResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OpenAiAnalysisAdapterTest { + + private final OpenAIClient openAIClient = mock(OpenAIClient.class); + private final LlmConcurrencyLimiter llmConcurrencyLimiter = mock(LlmConcurrencyLimiter.class); + private final AsyncMetricsRecorder asyncMetricsRecorder = mock(AsyncMetricsRecorder.class); + private final AnalysisResponseParser analysisResponseParser = mock(AnalysisResponseParser.class); + + private OpenAiAnalysisAdapter openAiAnalysisAdapter; + + @BeforeEach + void setUp() { + openAiAnalysisAdapter = new OpenAiAnalysisAdapter( + openAIClient, + llmConcurrencyLimiter, + asyncMetricsRecorder, + analysisResponseParser + ); + ReflectionTestUtils.setField(openAiAnalysisAdapter, "analysisModel", "gpt-4o-mini"); + } + + @Test + @DisplayName("정상 응답이면 limiter 실행 후 success 메트릭을 기록한다") + void createStructuredResponseRecordsSuccessMetric() throws Exception { + StructuredResponse response = mock(StructuredResponse.class); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + when(analysisResponseParser.extractStructuredContent(response)).thenReturn("ok"); + + String result = openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class); + + assertThat(result).isEqualTo("ok"); + verify(llmConcurrencyLimiter).execute(eq("analysis"), any()); + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("success"), any(Long.class)); + verify(asyncMetricsRecorder, never()).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } + + @Test + @DisplayName("limiter 실행 중 RuntimeException이 나면 그대로 전파하고 error 메트릭을 기록한다") + void createStructuredResponsePropagatesLimiterFailure() { + RuntimeException exception = new RuntimeException("boom"); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenThrow(exception); + + assertThatThrownBy(() -> openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class)) + .isSameAs(exception); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } + + @Test + @DisplayName("응답 추출 실패면 success 없이 error 메트릭만 기록한다") + void createStructuredResponseRecordsOnlyErrorWhenParsingFails() throws Exception { + StructuredResponse response = mock(StructuredResponse.class); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + when(analysisResponseParser.extractStructuredContent(response)) + .thenThrow(new IllegalStateException("parse failed")); + + assertThatThrownBy(() -> openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("parse failed"); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + verify(asyncMetricsRecorder, never()).recordLlmRequest(eq("analysis"), eq("success"), any(Long.class)); + } + + @Test + @DisplayName("output text가 없으면 parser 예외를 전파하고 error 메트릭을 기록한다") + void createStructuredResponsePropagatesParserFailureWhenOutputMissing() throws Exception { + AnalysisResponseParser realParser = new AnalysisResponseParser(); + OpenAiAnalysisAdapter adapter = new OpenAiAnalysisAdapter( + openAIClient, + llmConcurrencyLimiter, + asyncMetricsRecorder, + realParser + ); + ReflectionTestUtils.setField(adapter, "analysisModel", "gpt-4o-mini"); + + StructuredResponse response = mock(StructuredResponse.class); + when(response.output()).thenReturn(List.of()); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + + assertThatThrownBy(() -> adapter.createStructuredResponse("analysis", "prompt", String.class)) + .isInstanceOf(GeneralException.class); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java new file mode 100644 index 00000000..9712b2eb --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java @@ -0,0 +1,134 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +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.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncTaskSweepCoordinatorTest { + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Mock + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + + @Mock + private TransactionTemplate transactionTemplate; + + private AnalysisAsyncTaskSweepCoordinator analysisAsyncTaskSweepCoordinator; + private AnalysisQueueProperties analysisQueueProperties; + private Clock clock; + + @BeforeEach + void setUp() { + analysisQueueProperties = new AnalysisQueueProperties(); + clock = Clock.fixed(Instant.parse("2026-08-12T00:00:00Z"), ZoneId.of("Asia/Seoul")); + analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( + analysisAsyncTaskRepository, + analysisAsyncTaskService, + analysisAsyncCreditCoordinator, + transactionTemplate, + analysisQueueProperties, + clock + ); + lenient().doAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(org.mockito.Mockito.mock(TransactionStatus.class)); + }).when(transactionTemplate).execute(any()); + } + + @Test + @DisplayName("sweep는 timeout 대상 task id만 batch 조회해 처리한다") + void sweepTimedOutTasksLoadsOnlyTimedOutTaskIdsInBatches() { + LocalDateTime now = LocalDateTime.now(clock); + AnalysisAsyncTask pendingTask = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.setField(pendingTask, "submittedAt", now.minusMinutes(10)); + AnalysisAsyncTask runningTask = AnalysisAsyncTask.pending(2L, 20L, 3); + runningTask.markRunning("worker-1", 0, null); + ReflectionTestUtils.setField(runningTask, "lastAttemptAt", now.minusMinutes(20)); + + LocalDateTime pendingDeadline = now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds()); + LocalDateTime runningDeadline = now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds()); + + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(eq(pendingDeadline), any(Pageable.class))) + .thenReturn(List.of(pendingTask.getTaskId()), List.of()); + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(eq(runningDeadline), any(Pageable.class))) + .thenReturn(List.of(runningTask.getTaskId()), List.of()); + when(analysisAsyncTaskRepository.findByIdForUpdate(pendingTask.getTaskId())).thenReturn(Optional.of(pendingTask)); + when(analysisAsyncTaskRepository.findByIdForUpdate(runningTask.getTaskId())).thenReturn(Optional.of(runningTask)); + + int expiredCount = analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); + + assertThat(expiredCount).isEqualTo(2); + verify(analysisAsyncTaskService).markFailed( + pendingTask.getTaskId(), + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, + "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다.", + pendingTask.getRetryCount() + ); + verify(analysisAsyncTaskService).markFailed( + runningTask.getTaskId(), + AnalysisAsyncFailureReason.INTERNAL_ERROR, + "자소서 분석 작업이 처리 제한 시간을 초과했습니다.", + runningTask.getRetryCount() + ); + } + + @Test + @DisplayName("batch가 비어 있으면 추가 처리 없이 종료한다") + void sweepTimedOutTasksStopsWhenNoTimedOutTaskIdsExist() { + LocalDateTime now = LocalDateTime.now(clock); + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds( + eq(now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds())), + any(Pageable.class) + )) + .thenReturn(List.of()); + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds( + eq(now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds())), + any(Pageable.class) + )) + .thenReturn(List.of()); + + int expiredCount = analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); + + assertThat(expiredCount).isZero(); + verify(analysisAsyncTaskRepository, never()).findByIdForUpdate(anyString()); + verify(analysisAsyncTaskService, never()).markFailed(anyString(), any(), anyString(), org.mockito.ArgumentMatchers.anyInt()); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 5b278acc..0488bca6 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -1,13 +1,16 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse.RecheckDecision; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse.RecheckDecision; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse.RejectionCode; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotCase; @@ -19,8 +22,6 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; -import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -41,15 +42,20 @@ class AnalysisAiClientTest { private final FewShotSearchService fewShotSearchService = mock(FewShotSearchService.class); private final FewShotProperties fewShotProperties = new FewShotProperties(); - private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( - mock(OpenAIClient.class), - mock(CorpusRetrievalService.class), - mock(LlmConcurrencyLimiter.class), + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AnalysisResponseParser analysisResponseParser = new AnalysisResponseParser(); + private final AnalysisPromptBuilder analysisPromptBuilder = new AnalysisPromptBuilder( new FewShotPromptProvider(), fewShotSearchService, fewShotProperties, - mock(AsyncMetricsRecorder.class), - new ObjectMapper() + objectMapper + ); + private final OpenAiAnalysisAdapter openAiAnalysisAdapter = mock(OpenAiAnalysisAdapter.class); + private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( + mock(CorpusRetrievalService.class), + analysisPromptBuilder, + analysisResponseParser, + openAiAnalysisAdapter ); @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java index 31e58bbc..e7f6e20b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java new file mode 100644 index 00000000..13f9d7da --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java @@ -0,0 +1,115 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncCreditCoordinatorTest { + + @Mock + private AnalysisCreditService analysisCreditService; + + @Mock + private UserService userService; + + @InjectMocks + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + + @Test + @DisplayName("예약된 크레딧이 있으면 환불 후 RELEASED 상태로 전이한다") + void releaseReservedCreditIfNeededRefundsReservedCredit() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-credit-release@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + + assertThat(released).isTrue(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RELEASED); + verify(userService, org.mockito.Mockito.times(2)).getUser(1L); + verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + } + + @Test + @DisplayName("예약된 크레딧이 없으면 환불을 수행하지 않는다") + void releaseReservedCreditIfNeededSkipsWhenNoReservedCredit() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + + assertThat(released).isFalse(); + verify(userService, never()).getUser(any()); + verify(analysisCreditService, never()).refund(any(), anyString()); + } + + @Test + @DisplayName("크레딧 재예약은 version을 올린 새 reference로 한 번만 수행한다") + void reserveCreditIfNeededUsesIncrementedReferenceVersion() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-credit-reserve@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 2)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + + boolean firstReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + boolean secondReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + boolean thirdReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + + assertThat(firstReserved).isTrue(); + assertThat(secondReserved).isFalse(); + assertThat(released).isTrue(); + assertThat(thirdReserved).isTrue(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RESERVED); + assertThat(task.getCreditReferenceId()).isEqualTo("analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + } + + @Test + @DisplayName("예약된 크레딧만 confirm 되고 이후 중복 confirm은 무시된다") + void confirmReservedCreditIfNeededIsIdempotent() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-credit-confirm@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + + boolean firstConfirmed = analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); + boolean secondConfirmed = analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); + + assertThat(firstConfirmed).isTrue(); + assertThat(secondConfirmed).isFalse(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.CONFIRMED); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index 122c0e59..7137ac67 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -1,20 +1,31 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; 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.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; 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.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; +import java.sql.SQLException; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -22,8 +33,10 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -56,13 +69,12 @@ void submitReturnsExistingTaskWhenCreateConflicts() { when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.of(existingTask)); when(analysisAsyncTaskService.createPendingTask(1L, 10L)) - .thenThrow(new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply")); + .thenThrow(activeTaskUniqueConflict()); AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); - verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), eq(3)); } @@ -72,8 +84,7 @@ void submitPropagatesExceptionWhenTaskDisappearsAfterConflict() { User user = User.signup("테스트 사용자", "analysis-async-missing@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - DataIntegrityViolationException exception = - new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply"); + DataIntegrityViolationException exception = activeTaskUniqueConflict(); when(userService.validateUser(user)).thenReturn(user); when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); @@ -104,7 +115,129 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { assertThat(response.cached()).isFalse(); assertThat(response.resultAvailable()).isFalse(); verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); - verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); + } + + @Test + @DisplayName("이전 PUBLISH_FAILED task가 있으면 새 task를 만들지 않고 같은 task를 재접수한다") + void submitReopensRecoverablePublishFailureTask() { + User user = User.signup("테스트 사용자", "analysis-async-recoverable@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + failedTask.reopenForRepublish(); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(failedTask, true)); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(failedTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.cached()).isFalse(); + assertThat(response.resultAvailable()).isFalse(); + verify(analysisAsyncTaskService, never()).createPendingTask(1L, 10L); + verify(analysisService, never()).hasReusableAnalysis(user, 10L); + verify(analysisAsyncTaskService).reopenPublishFailureTask(failedTask.getTaskId()); + verify(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); + } + + @Test + @DisplayName("다른 요청이 이미 PUBLISH_FAILED task를 재접수했으면 재발행하지 않고 진행 중 응답을 반환한다") + void submitReturnsInProgressWhenPublishFailureTaskAlreadyReopened() { + User user = User.signup("테스트 사용자", "analysis-async-reopened@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + AnalysisAsyncTask reopenedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.setField(reopenedTask, "taskId", failedTask.getTaskId()); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(reopenedTask, false)); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(failedTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.cached()).isFalse(); + assertThat(response.resultAvailable()).isFalse(); + verify(analysisAsyncProcessor, never()).process(failedTask.getTaskId(), 1L, 10L, 3); + } + + @Test + @DisplayName("메시지 발행 실패 시 task를 삭제하지 않고 PUBLISH_FAILED로 실패 처리한다") + void submitMarksTaskFailedWhenPublishFails() { + User user = User.signup("테스트 사용자", "analysis-async-publish-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask createdTask = AnalysisAsyncTask.pending(1L, 10L, 3); + RuntimeException publishException = new RuntimeException("publish failed"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenReturn(createdTask); + doThrow(publishException).when(analysisAsyncProcessor).process(createdTask.getTaskId(), 1L, 10L, 3); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(publishException); + + verify(analysisAsyncTaskService).markFailed( + createdTask.getTaskId(), + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + 0 + ); + verify(analysisAsyncTaskService, never()).deleteTask(createdTask.getTaskId()); + } + + @Test + @DisplayName("재접수한 PUBLISH_FAILED task의 발행이 다시 실패하면 같은 task를 다시 실패 처리한다") + void submitMarksReopenedTaskFailedWhenPublishFailsAgain() { + User user = User.signup("테스트 사용자", "analysis-async-republish-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + RuntimeException publishException = new RuntimeException("publish failed again"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + failedTask.reopenForRepublish(); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(failedTask, true)); + doThrow(publishException).when(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(publishException); + + verify(analysisAsyncTaskService).markFailed( + failedTask.getTaskId(), + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + failedTask.getRetryCount() + ); + } + + @Test + @DisplayName("제약 조건명이 없으면 메시지 기반으로 중복 충돌로 분류하지 않는다") + void submitDoesNotTreatMessageOnlyConflictAsDuplicate() { + User user = User.signup("테스트 사용자", "analysis-async-message-only@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + DataIntegrityViolationException exception = + new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenThrow(exception); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(exception); } @Test @@ -146,16 +279,130 @@ void submitPrefersActiveTaskOverCachedResult() { verify(analysisService, never()).hasReusableAnalysis(user, 10L); } + @Test + @DisplayName("성공한 task 상태 조회는 분석 결과를 함께 반환한다") + void getTaskReturnsResultWhenSucceeded() { + User user = User.signup("테스트 사용자", "analysis-async-status-success@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(10L) + .status("SUCCEEDED") + .message("분석이 완료되었습니다.") + .build(); + AnalysisResponse result = new AnalysisResponse( + 10L, + 100L, + MockApplyStatus.COMPLETED, + 1, + 80, + 80, + 80, + 80, + "완료된 분석입니다.", + java.util.List.of(), + java.util.List.of(), + java.util.List.of(), + java.util.List.of() + ); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + when(analysisService.getAnalysis(user, 10L)).thenReturn(result); + + AnalysisAsyncStatusResponse response = analysisAsyncFacadeService.getTask(user, 10L, "task-1"); + + assertThat(response.taskId()).isEqualTo("task-1"); + assertThat(response.status()).isEqualTo("SUCCEEDED"); + assertThat(response.result()).isEqualTo(result); + verify(analysisService, times(1)).getAnalysis(user, 10L); + } + + @ParameterizedTest + @ValueSource(strings = {"RUNNING", "PENDING", "FAILED", "CANCELLED"}) + @DisplayName("비완료 task 상태 조회는 분석 결과를 조회하지 않는다") + void getTaskReturnsStatusWithoutResultWhenNotSucceeded(String taskStatus) { + User user = User.signup("테스트 사용자", "analysis-async-status-" + taskStatus.toLowerCase() + "@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(10L) + .status(taskStatus) + .message("분석 상태입니다.") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + + AnalysisAsyncStatusResponse response = analysisAsyncFacadeService.getTask(user, 10L, "task-1"); + + assertThat(response).isEqualTo(status); + verify(analysisService, never()).getAnalysis(user, 10L); + } + + @Test + @DisplayName("task 상태 조회 시 요청 mockApplyId가 다르면 예외를 던진다") + void getTaskThrowsWhenMockApplyIdDoesNotMatch() { + User user = User.signup("테스트 사용자", "analysis-async-status-forbidden@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(99L) + .status("RUNNING") + .message("분석 중입니다.") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + + assertThatThrownBy(() -> analysisAsyncFacadeService.getTask(user, 10L, "task-1")) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + verifyNoInteractions(analysisService); + } + + @Test + @DisplayName("task 취소는 검증된 사용자 기준으로 task service에 위임한다") + void cancelDelegatesToTaskService() { + User user = User.signup("테스트 사용자", "analysis-async-cancel@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncCancelResponse cancelResponse = new AnalysisAsyncCancelResponse( + "task-1", + "CANCELLED", + "분석 작업이 취소되었습니다." + ); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.cancelTask(1L, 10L, "task-1")).thenReturn(cancelResponse); + + AnalysisAsyncCancelResponse response = analysisAsyncFacadeService.cancel(user, 10L, "task-1"); + + assertThat(response).isEqualTo(cancelResponse); + verify(analysisAsyncTaskService, times(1)).cancelTask(1L, 10L, "task-1"); + } + @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); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-1", 1); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-2", 2); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-3", 3); - assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTask.TaskStatus.FAILED); + assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTaskStatus.FAILED); assertThat(task.getRetryCount()).isEqualTo(3); } + + private DataIntegrityViolationException activeTaskUniqueConflict() { + return new DataIntegrityViolationException( + "constraint violation", + new ConstraintViolationException( + "duplicate", + new SQLException("duplicate"), + "uk_analysis_async_tasks_active_user_mock_apply" + ) + ); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java new file mode 100644 index 00000000..c9a13352 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java @@ -0,0 +1,119 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +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 java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncTaskServiceTest { + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisAsyncSseService analysisAsyncSseService; + + @Mock + private NotificationService notificationService; + + @Mock + private AsyncMetricsRecorder asyncMetricsRecorder; + + @Mock + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + + @Test + @DisplayName("동시에 재접수해도 PUBLISH_FAILED task는 한 번만 reopen 된다") + void reopenPublishFailureTaskIsAtomicAcrossConcurrentRequests() throws Exception { + AnalysisQueueProperties queueProperties = new AnalysisQueueProperties(); + AnalysisAsyncTaskService analysisAsyncTaskService = new AnalysisAsyncTaskService( + analysisAsyncTaskRepository, + analysisAsyncSseService, + notificationService, + asyncMetricsRecorder, + queueProperties, + analysisAsyncCreditCoordinator, + new AsyncProgressCalculator() + ); + AnalysisAsyncTask failedTask = spy(AnalysisAsyncTask.pending(1L, 10L, 3)); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + + CountDownLatch firstReopened = new CountDownLatch(1); + AtomicBoolean firstLookup = new AtomicBoolean(true); + doAnswer(invocation -> { + invocation.callRealMethod(); + firstReopened.countDown(); + return null; + }).when(failedTask).reopenForRepublish(); + when(analysisAsyncTaskRepository.findByIdForUpdate(failedTask.getTaskId())).thenAnswer(invocation -> { + if (firstLookup.getAndSet(false)) { + return Optional.of(failedTask); + } + assertThat(firstReopened.await(5, TimeUnit.SECONDS)).isTrue(); + return Optional.of(failedTask); + }); + + List results = runConcurrently(2, () -> + analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId()) + ); + + assertThat(results).hasSize(2); + assertThat(results).extracting(AnalysisAsyncTaskService.ReopenPublishFailureResult::reopened) + .containsExactlyInAnyOrder(true, false); + assertThat(failedTask.getStatus().name()).isEqualTo("PENDING"); + verify(analysisAsyncSseService, times(1)).publish(org.mockito.ArgumentMatchers.any()); + } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(executorService.submit(() -> { + ready.countDown(); + assertThat(start.await(5, TimeUnit.SECONDS)).isTrue(); + return task.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List results = new ArrayList<>(threadCount); + for (Future future : futures) { + results.add(future.get(5, TimeUnit.SECONDS)); + } + return results; + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index 06351ae8..7d752248 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -1,16 +1,18 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.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.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; 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.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.company.entity.Company; @@ -22,6 +24,7 @@ import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; 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; @@ -32,9 +35,20 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -44,9 +58,12 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; @ExtendWith(MockitoExtension.class) class AnalysisWorkerBridgeServiceTest { @@ -60,6 +77,11 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisService analysisService; + @Mock + private AnalysisCreditService analysisCreditService; + + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + @Mock private UserService userService; @@ -69,12 +91,35 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + @Mock + private TransactionTemplate transactionTemplate; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @InjectMocks private AnalysisWorkerBridgeService analysisWorkerBridgeService; + @BeforeEach + void setUp() { + analysisAsyncCreditCoordinator = new AnalysisAsyncCreditCoordinator(analysisCreditService, userService); + analysisWorkerBridgeService = new AnalysisWorkerBridgeService( + analysisAsyncTaskService, + analysisAsyncTaskRepository, + analysisService, + analysisAsyncCreditCoordinator, + userService, + workerTaskResultService, + analysisInputFingerprintProvider, + objectMapper, + transactionTemplate + ); + lenient().when(transactionTemplate.execute(any(TransactionCallback.class))) + .thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(org.mockito.Mockito.mock(TransactionStatus.class)); + }); + } + @Test @DisplayName("취소된 task의 complete 요청은 결과 저장과 성공 처리를 하지 않는다") void completeTaskRejectsCancelledTaskWithoutSideEffects() { @@ -88,7 +133,7 @@ void completeTaskRejectsCancelledTaskWithoutSideEffects() { 10L ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); assertThatThrownBy(() -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request)) .isInstanceOf(GeneralException.class); @@ -123,13 +168,12 @@ void getContextRejectsCancelledTaskWithoutCreditReservation() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); task.requestCancel(); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); assertThatThrownBy(() -> analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L)) .isInstanceOf(GeneralException.class); - verify(analysisService, never()).deductAnalysisCredit(any(), anyString()); - verify(analysisAsyncTaskService, never()).markCreditReserved(anyString(), anyString()); + verify(analysisCreditService, never()).deduct(any(), anyString()); } @Test @@ -183,17 +227,24 @@ void getContextReservesCreditBeforePreparingExecution() { List.of(similarContext) ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); - var context = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + when(analysisInputFingerprintProvider.create(payload)).thenReturn("initial-fingerprint"); - verify(analysisService).deductAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); - verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); - verify(analysisService).prepareAnalysisExecution(user, 10L); - assertThat(context.corpusReferences()).hasSize(1); - assertThat(context.corpusReferences().getFirst().corpusId()).isEqualTo(11L); - assertThat(context.similarJobPostings()).containsExactly(similarContext); + var firstContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + var secondContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + + verify(analysisCreditService).createAsyncReferenceId(task.getTaskId(), 1); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RESERVED); + assertThat(firstContext).isEqualTo(secondContext); + assertThat(firstContext.corpusReferences()).hasSize(1); + assertThat(firstContext.corpusReferences().getFirst().corpusId()).isEqualTo(11L); + assertThat(firstContext.similarJobPostings()).containsExactly(similarContext); } @Test @@ -223,17 +274,38 @@ void getContextDoesNotReserveCreditTwice() { List.of() ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(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()).deductAnalysisCredit(eq(user), anyString()); - verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); + verify(analysisCreditService, never()).deduct(eq(user), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } + @Test + @DisplayName("실패 처리를 재시도해도 예약된 크레딧 환불은 한 번만 수행한다") + void failTaskReleasesReservedCreditOnlyOnce() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); + User user = User.signup("테스트 사용자", "analysis-worker-refund@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + doAnswer(invocation -> { + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); + return null; + }).when(analysisAsyncTaskService).markFailed(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); + + analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + + verify(analysisCreditService, times(1)) + .refund(user, "analysisTaskId=" + task.getTaskId()); + } + @Test @DisplayName("완료 시 최초 worker context snapshot과 fingerprint를 재사용한다") void completeTaskReusesInitialExecutionSnapshot() { @@ -298,7 +370,7 @@ void completeTaskReusesInitialExecutionSnapshot() { 1L, 10L, llmResponse, "worker-1", 10L ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); AnalysisExecutionPayload changedRetrievalPayload = new AnalysisExecutionPayload( 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of(laterContext) @@ -339,12 +411,12 @@ void terminalTaskDoesNotReopen() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); task.markSuccess(); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); analysisWorkerBridgeService.markRunning(task.getTaskId(), "worker-1", 1, java.time.Instant.now()); analysisWorkerBridgeService.markRetry( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry", 1, "worker-1", @@ -352,19 +424,19 @@ void terminalTaskDoesNotReopen() { ); 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)); + verify(analysisAsyncTaskService, never()).markRetryScheduled(eq(task.getTaskId()), eq(AnalysisAsyncFailureReason.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)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( 2L, 11L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class), + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class), "worker-1", 10L ); @@ -382,7 +454,7 @@ void storeGeneratedResultPersistsPayload() { AnalysisWorkerResultStoreRequest request = new AnalysisWorkerResultStoreRequest( 1L, 10L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class) + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class) ); analysisWorkerBridgeService.storeGeneratedResult(task.getTaskId(), request); @@ -400,7 +472,7 @@ void storeGeneratedResultAllowsTerminalTask() { AnalysisWorkerResultStoreRequest request = new AnalysisWorkerResultStoreRequest( 1L, 10L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class) + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class) ); analysisWorkerBridgeService.storeGeneratedResult(task.getTaskId(), request); @@ -415,9 +487,9 @@ void completeTaskMarksDeliveredForSucceededTask() { task.markSuccess(); User user = User.signup("테스트 사용자", "analysis-complete@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - var llmResponse = mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class); + var llmResponse = mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.getAnalysis(user, 10L)).thenReturn(mock(com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse.class)); @@ -439,4 +511,140 @@ void completeTaskMarksDeliveredForSucceededTask() { ); inOrder.verify(workerTaskResultService).markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, task.getTaskId()); } + + @Test + @DisplayName("PUBLISH_FAILED 이후 늦게 도착한 complete 요청은 정상 완료로 복구한다") + void completeTaskRecoversAfterPublishFailure() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + User user = User.signup("테스트 사용자", "analysis-recover@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + ReflectionTestUtils.setField(task, "executionContextSnapshot", """ + {"userId":1,"mockApplyId":10,"companyName":"","jobTitle":"","task":"","requirements":"","preferredQualifications":"","bigClassificationName":"","middleClassificationName":"","detailClassificationName":"","questions":[],"corpusReferences":[],"similarJobPostings":[]} + """); + ReflectionTestUtils.setField(task, "inputFingerprintSnapshot", "publish-fingerprint"); + + AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); + AnalysisResponse response = mock(AnalysisResponse.class); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, + 10L, + llmResponse, + "worker-1", + 15L + ); + + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); + when(analysisService.finalizeAnalysis(user, 10L, completionPayload, llmResponse, "publish-fingerprint")) + .thenReturn(response); + + AnalysisResponse completed = analysisWorkerBridgeService.completeTask(task.getTaskId(), request); + + assertThat(completed).isEqualTo(response); + verify(analysisAsyncTaskService).markSuccess(task.getTaskId(), response); + verify(workerTaskResultService).markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, task.getTaskId()); + } + + @Test + @DisplayName("동시에 complete가 들어와도 분석 완료 처리는 한 번만 수행한다") + void completeTaskProcessesSuccessOnlyOnceAcrossConcurrentRequests() throws Exception { + AnalysisAsyncTask task = spy(AnalysisAsyncTask.pending(1L, 10L, 3)); + task.markCreditReserved("credit-ref"); + ReflectionTestUtils.setField(task, "executionContextSnapshot", """ + {"userId":1,"mockApplyId":10,"companyName":"","jobTitle":"","task":"","requirements":"","preferredQualifications":"","bigClassificationName":"","middleClassificationName":"","detailClassificationName":"","questions":[],"corpusReferences":[],"similarJobPostings":[]} + """); + ReflectionTestUtils.setField(task, "inputFingerprintSnapshot", "complete-fingerprint"); + + User user = User.signup("테스트 사용자", "analysis-concurrent-complete@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); + AnalysisResponse finalizedResponse = mock(AnalysisResponse.class); + AnalysisResponse cachedResponse = mock(AnalysisResponse.class); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, + 10L, + llmResponse, + "worker-1", + 15L + ); + + CountDownLatch successMarked = new CountDownLatch(1); + AtomicBoolean firstLookup = new AtomicBoolean(true); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenAnswer(invocation -> { + if (firstLookup.getAndSet(false)) { + return Optional.of(task); + } + assertThat(successMarked.await(5, TimeUnit.SECONDS)).isTrue(); + return Optional.of(task); + }); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); + when(analysisService.finalizeAnalysis(user, 10L, completionPayload, llmResponse, "complete-fingerprint")) + .thenAnswer(invocation -> { + task.markSuccess(); + successMarked.countDown(); + return finalizedResponse; + }); + when(analysisService.getAnalysis(user, 10L)).thenReturn(cachedResponse); + + List results = runConcurrently( + 2, + () -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request) + ); + + assertThat(results).containsExactlyInAnyOrder(finalizedResponse, cachedResponse); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.CONFIRMED); + verify(analysisService, times(1)) + .finalizeAnalysis(user, 10L, completionPayload, llmResponse, "complete-fingerprint"); + verify(analysisService, times(1)).getAnalysis(user, 10L); + verify(analysisAsyncTaskService, times(1)).markSuccess(task.getTaskId(), finalizedResponse); + verify(task, times(1)).markCreditConfirmed(); + } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(executorService.submit(() -> { + ready.countDown(); + assertThat(start.await(5, TimeUnit.SECONDS)).isTrue(); + return task.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List results = new ArrayList<>(threadCount); + for (Future future : futures) { + results.add(future.get(5, TimeUnit.SECONDS)); + } + return results; + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + } + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java index 60806622..d36a882a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java @@ -1,7 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index 7739f4c9..ed112019 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -1,12 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -29,7 +30,7 @@ import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java new file mode 100644 index 00000000..bc20b612 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java @@ -0,0 +1,26 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestionCandidateCatalogServiceTest { + + private final QuestionCandidateCatalogService questionCandidateCatalogService = + new QuestionCandidateCatalogService(); + + @Test + @DisplayName("기본 후보와 custom 후보는 서로 다른 prefix의 candidateKey를 사용한다") + void candidateKeysUseDistinctPrefixes() { + var defaultCandidates = questionCandidateCatalogService.getDefaultCandidateResponses(Set.of()); + + assertThat(defaultCandidates).isNotEmpty(); + assertThat(defaultCandidates.getFirst().candidateKey()).isEqualTo("default:1"); + assertThat(questionCandidateCatalogService.toCustomCandidateKey(1L)).isEqualTo("custom:1"); + assertThat(defaultCandidates.getFirst().candidateKey()) + .isNotEqualTo(questionCandidateCatalogService.toCustomCandidateKey(1L)); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java new file mode 100644 index 00000000..c8fe2117 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java @@ -0,0 +1,102 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +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.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +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 QuestionCommandServiceTest { + + @Mock + private QuestionDomainSupport questionDomainSupport; + + @Mock + private QuestionCandidateCatalogService questionCandidateCatalogService; + + @Mock + private MockApplyRepository mockApplyRepository; + + @Mock + private QuestionRepository questionRepository; + + @Mock + private QuestionAnalysisRepository questionAnalysisRepository; + + @Mock + private CustomQuestionCandidateRepository customQuestionCandidateRepository; + + @Mock + private CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService; + + @Test + @DisplayName("custom candidate 저장 충돌 시 기존 후보를 재조회해 정상 응답한다") + void addCustomQuestionCandidateRecoversAfterUniqueConflict() { + QuestionCommandService questionCommandService = new QuestionCommandService( + questionDomainSupport, + questionCandidateCatalogService, + mockApplyRepository, + questionRepository, + questionAnalysisRepository, + customQuestionCandidateRepository, + customQuestionCandidatePersistenceService + ); + User user = User.signup("테스트 사용자", "question-command@example.com", "encoded-password"); + MockApply mockApply = mock(MockApply.class); + when(mockApply.getId()).thenReturn(10L); + + CustomQuestionCandidate existingCandidate = CustomQuestionCandidate.create( + mockApply, + "새로운 지원 동기를 작성해주세요.", + 900 + ); + ReflectionTestUtils.setField(existingCandidate, "id", 7L); + + when(questionDomainSupport.getOwnedMockApply(user, 10L)).thenReturn(mockApply); + doNothing().when(questionCandidateCatalogService).validateCustomCandidate("새로운 지원 동기를 작성해주세요."); + when(questionCandidateCatalogService.toCustomCandidateKey(7L)).thenReturn("custom:7"); + when(questionDomainSupport.resolveCharLimit(900)).thenReturn(900); + when(customQuestionCandidateRepository.findByMockApplyIdAndContent(10L, "새로운 지원 동기를 작성해주세요.")) + .thenReturn(Optional.empty(), Optional.of(existingCandidate)); + when(customQuestionCandidatePersistenceService.saveAndFlush(10L, "새로운 지원 동기를 작성해주세요.", 900)) + .thenThrow(new DataIntegrityViolationException("duplicate candidate")); + when(questionRepository.existsByMockApplyIdAndContent(10L, "새로운 지원 동기를 작성해주세요.")) + .thenReturn(false); + + QuestionCandidateResponse response = questionCommandService.addCustomQuestionCandidate( + user, + 10L, + new QuestionCandidateCreateRequest(" 새로운 지원 동기를 작성해주세요. ", 900) + ); + + assertThat(response.questionId()).isEqualTo(7L); + assertThat(response.content()).isEqualTo("새로운 지원 동기를 작성해주세요."); + assertThat(response.charLimit()).isEqualTo(900); + assertThat(response.selected()).isFalse(); + assertThat(response.custom()).isTrue(); + assertThat(response.candidateKey()).isEqualTo("custom:7"); + verify(customQuestionCandidatePersistenceService).saveAndFlush(10L, "새로운 지원 동기를 작성해주세요.", 900); + verify(customQuestionCandidateRepository, never()).saveAndFlush(existingCandidate); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java deleted file mode 100644 index ebf3bdc9..00000000 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java +++ /dev/null @@ -1,629 +0,0 @@ -package com.jobdri.jobdri_api.domain.analysis.service.question; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; -import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; -import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.classification.entity.Classification; -import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; -import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; -import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; -import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.entity.CompanySize; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; -import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.repository.UserRepository; -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.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.tuple; - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -class QuestionServiceTest { - - @Autowired - private QuestionService questionService; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private QuestionAnalysisRepository questionAnalysisRepository; - - @Autowired - private AnalysisRepository analysisRepository; - - @Autowired - private MockApplyRepository mockApplyRepository; - - @Autowired - private JobPostingRepository jobPostingRepository; - - @Autowired - private CompanyRepository companyRepository; - - @Autowired - private ClassificationRepository classificationRepository; - - @Autowired - private DetailClassificationRepository detailClassificationRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private ObjectMapper objectMapper; - - @Test - @DisplayName("선택 문항을 저장하고 지원 상태를 답변 작성 단계로 변경한다") - void saveSelectedQuestions() { - User user = saveUser("question-save@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("직접 추가한 문항입니다.", null, true) - )); - - QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), request); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(response.questions()).hasSize(2); - assertThat(response.questions().get(0).content()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(response.questions().get(0).charLimit()).isEqualTo(700); - assertThat(response.questions().get(0).custom()).isFalse(); - assertThat(response.questions().get(1).content()).isEqualTo("직접 추가한 문항입니다."); - assertThat(response.questions().get(1).charLimit()).isEqualTo(1000); - assertThat(response.questions().get(1).custom()).isTrue(); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).hasSize(2); - } - - @Test - @DisplayName("문항 저장 시 기존 선택 문항을 새 선택 목록으로 교체한다") - void saveSelectedQuestionsReplacesExistingQuestions() { - User user = saveUser("question-replace@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 1", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 2", 500, false) - ))); - - QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("새 문항", 800, false) - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(response.questions().get(0).custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())) - .extracting(Question::getContent) - .containsExactly("새 문항"); - } - - @Test - @DisplayName("선택 문항 조회 시 직접 추가 여부를 함께 반환한다") - void getSelectedQuestionsReturnsCustomFlag() { - User user = saveUser("question-selected-custom-flag@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("직접 추가한 문항입니다.", 1000, true) - ))); - - QuestionSelectionResponse response = questionService.getSelectedQuestions(user, mockApply.getId()); - - assertThat(response.questions()).hasSize(2); - assertThat(response.questions()) - .extracting("content", "custom") - .containsExactlyInAnyOrder( - tuple("지원 동기와 입사 후 목표를 작성해주세요.", false), - tuple("직접 추가한 문항입니다.", true) - ); - } - - @Test - @DisplayName("직접 추가 문항은 선택 문항으로 저장하지 않고 후보 목록에 추가한다") - void addCustomQuestionCandidate() { - User user = saveUser("question-custom-candidate@example.com"); - MockApply mockApply = saveMockApply(user); - - QuestionCandidateResponse response = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("직접 추가한 후보 문항입니다.", 500) - ); - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(response.content()).isEqualTo("직접 추가한 후보 문항입니다."); - assertThat(response.charLimit()).isEqualTo(500); - assertThat(response.selected()).isFalse(); - assertThat(response.custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(candidates).hasSize(6); - assertThat(candidates.get(5).questionId()).isEqualTo(response.questionId()); - assertThat(candidates.get(5).content()).isEqualTo("직접 추가한 후보 문항입니다."); - assertThat(candidates.get(5).selected()).isFalse(); - assertThat(candidates.get(5).custom()).isTrue(); - } - - @Test - @DisplayName("기본 후보와 같은 내용은 직접 추가 문항 후보로 등록할 수 없다") - void addCustomQuestionCandidateThrowsWhenContentExistsInDefaultCandidate() { - User user = saveUser("question-custom-default-duplicate@example.com"); - MockApply mockApply = saveMockApply(user); - - assertThatThrownBy(() -> questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("지원 동기와 입사 후 목표를 작성해주세요.", 700) - )) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(questionService.getQuestionCandidates(user, mockApply.getId())).hasSize(5); - } - - @Test - @DisplayName("같은 직접 추가 문항 후보를 여러 번 요청하면 기존 후보를 반환한다") - void addCustomQuestionCandidateReturnsExistingCandidateWhenDuplicated() { - User user = saveUser("question-custom-duplicate@example.com"); - MockApply mockApply = saveMockApply(user); - - QuestionCandidateResponse first = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 500) - ); - QuestionCandidateResponse second = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 800) - ); - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(second.questionId()).isEqualTo(first.questionId()); - assertThat(second.content()).isEqualTo(first.content()); - assertThat(second.charLimit()).isEqualTo(first.charLimit()); - assertThat(second.selected()).isFalse(); - assertThat(second.custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(candidates).hasSize(6); - assertThat(candidates.stream() - .filter(QuestionCandidateResponse::custom) - .map(QuestionCandidateResponse::content) - .toList()) - .containsExactly("중복 직접 추가 문항입니다."); - } - - @Test - @DisplayName("이미 선택된 직접 추가 후보를 다시 요청하면 선택 상태로 반환한다") - void addCustomQuestionCandidateReturnsSelectedStateWhenAlreadySelected() { - User user = saveUser("question-custom-selected@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionCandidateResponse first = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 500) - ); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("이미 선택된 직접 추가 문항입니다.", 500, true) - ))); - - QuestionCandidateResponse second = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 800) - ); - - assertThat(second.questionId()).isEqualTo(first.questionId()); - assertThat(second.content()).isEqualTo(first.content()); - assertThat(second.charLimit()).isEqualTo(first.charLimit()); - assertThat(second.selected()).isTrue(); - assertThat(second.custom()).isTrue(); - } - - @Test - @DisplayName("문항 후보 목록은 이미 저장된 기본 문항을 선택 상태로 반환한다") - void getQuestionCandidatesMarksSelectedQuestion() { - User user = saveUser("question-candidates@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false) - ))); - - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(candidates).hasSize(5); - assertThat(candidates) - .extracting(QuestionCandidateResponse::questionId) - .containsExactly(1L, 2L, 3L, 4L, 5L); - assertThat(candidates) - .extracting(QuestionCandidateResponse::custom) - .containsOnly(false); - assertThat(candidates.get(0).selected()).isTrue(); - assertThat(candidates.get(1).selected()).isFalse(); - } - - @Test - @DisplayName("선택 문항은 1개 이상이어야 한다") - void saveSelectedQuestionsThrowsWhenEmpty() { - User user = saveUser("question-empty@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of()); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - } - - @Test - @DisplayName("선택 문항은 최대 5개까지 저장할 수 있다") - void saveSelectedQuestionsThrowsWhenTooMany() { - User user = saveUser("question-too-many@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 1", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 2", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 3", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 4", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 5", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 6", 500, false) - )); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - } - - @Test - @DisplayName("다른 사용자의 지원서에는 문항을 저장할 수 없다") - void saveSelectedQuestionsThrowsWhenUserDoesNotOwnMockApply() { - User owner = saveUser("question-owner@example.com"); - User other = saveUser("question-other@example.com"); - MockApply mockApply = saveMockApply(owner); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기", 700, false) - )); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(other, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.FORBIDDEN); - } - - @Test - @DisplayName("저장된 문항의 내용과 답변을 함께 작성하거나 수정한다") - void saveAnswers() { - User user = saveUser("answer-save@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기를 작성해주세요.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem( - questionId, - "지원 동기와 입사 후 목표를 작성해주세요.", - 1000, - "저는 백엔드 개발 경험을 바탕으로 지원했습니다." - ) - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.sequence()).isEqualTo(1); - assertThat(response.questions().get(0).content()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(response.questions().get(0).answer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); - Question savedQuestion = questionRepository.findById(questionId).orElseThrow(); - assertThat(savedQuestion.getContent()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(savedQuestion.getLimit()).isEqualTo(1000); - assertThat(savedQuestion.getAnswer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); - } - - @Test - @DisplayName("답변 저장은 선택 저장에서 허용한 짧은 문항 내용도 동일하게 허용한다") - void saveAnswersAllowsShortQuestionContentAcceptedBySelectionSave() { - User user = saveUser("answer-short-question@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("새 문항", 700, true) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "새 문항", 700, "새 문항 답변입니다.") - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(response.questions().get(0).answer()).isEqualTo("새 문항 답변입니다."); - } - - @Test - @DisplayName("답변 저장 요청은 answers 필드명도 questions와 동일하게 역직렬화한다") - void questionAnswerSaveRequestAcceptsAnswersAlias() throws Exception { - QuestionAnswerSaveRequest request = objectMapper.readValue( - """ - { - "answers": [ - { - "questionId": 1, - "content": "새 문항", - "charLimit": 700, - "answer": "새 문항 답변입니다." - } - ] - } - """, - QuestionAnswerSaveRequest.class - ); - - assertThat(request.questions()).hasSize(1); - assertThat(request.questions().get(0).questionId()).isEqualTo(1L); - assertThat(request.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(request.questions().get(0).answer()).isEqualTo("새 문항 답변입니다."); - } - - @Test - @DisplayName("답변 저장 시 문항 추가 수정 삭제와 글자수 제한을 화면 상태대로 동기화한다") - void saveAnswersSyncsQuestionList() { - User user = saveUser("answer-sync@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 1", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 2", 800, false) - ))); - Long retainedQuestionId = selected.questions().get(0).questionId(); - Long deletedQuestionId = selected.questions().get(1).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem( - retainedQuestionId, - "수정된 기존 문항", - 1000, - "수정된 기존 문항 답변입니다." - ), - new QuestionAnswerSaveRequest.QuestionAnswerItem( - null, - "새로 추가한 문항", - 700, - "새로 추가한 문항 답변입니다." - ) - ))); - - assertThat(response.questions()).hasSize(2); - assertThat(response.questions()) - .extracting("content", "charLimit", "answer") - .containsExactly( - tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), - tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") - ); - assertThat(questionRepository.findById(deletedQuestionId)).isEmpty(); - assertThat(questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId())) - .extracting(Question::getContent, Question::getLimit, Question::getAnswer) - .containsExactly( - tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), - tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") - ); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - } - - @Test - @DisplayName("답변 저장 응답은 같은 공고 기준 현재 지원 순번을 반환한다") - void saveAnswersReturnsSequence() { - User user = saveUser("answer-sequence@example.com"); - JobPosting jobPosting = saveJobPosting(); - mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - MockApply secondMockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, secondMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("재지원 답변 문항입니다.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, secondMockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "재지원 답변 문항입니다.", 700, "두 번째 지원 답변입니다.") - ))); - - assertThat(response.mockApplyId()).isEqualTo(secondMockApply.getId()); - assertThat(response.sequence()).isEqualTo(2); - } - - @Test - @DisplayName("답변 저장 응답은 저장된 지원 순번을 우선 반환한다") - void saveAnswersReturnsStoredSequence() { - User user = saveUser("answer-stored-sequence@example.com"); - JobPosting jobPosting = saveJobPosting(); - MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("재지원 저장 순번 문항입니다.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "재지원 저장 순번 문항입니다.", 700, "네 번째 지원 답변입니다.") - ))); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.sequence()).isEqualTo(4); - } - - @Test - @DisplayName("해당 지원서에 속하지 않은 문항은 답변 저장에 사용할 수 없다") - void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { - User user = saveUser("answer-invalid-question@example.com"); - MockApply mockApply = saveMockApply(user); - MockApply otherMockApply = saveMockApply(user); - QuestionSelectionResponse otherSelected = questionService.saveSelectedQuestions(user, otherMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("다른 지원서 문항", 700, false) - ))); - Long otherQuestionId = otherSelected.questions().get(0).questionId(); - - assertThatThrownBy(() -> questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(otherQuestionId, "다른 지원서 문항", 700, "답변") - )))) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.QUESTION_NOT_FOUND); - } - - @Test - @DisplayName("선택 문항 1개를 삭제하고 남은 문항 목록을 반환한다") - void deleteQuestion() { - User user = saveUser("question-delete@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("삭제할 문항", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("남길 문항", 800, false) - ))); - Long deletedQuestionId = selected.questions().get(0).questionId(); - - QuestionSelectionResponse response = questionService.deleteQuestion(user, mockApply.getId(), deletedQuestionId); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("남길 문항"); - assertThat(questionRepository.findById(deletedQuestionId)).isEmpty(); - assertThat(questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId())) - .extracting(Question::getContent) - .containsExactly("남길 문항"); - } - - @Test - @DisplayName("문항 삭제 시 해당 문항의 분석 상세도 함께 삭제한다") - void deleteQuestionDeletesQuestionAnalyses() { - User user = saveUser("question-delete-analysis@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("삭제할 분석 문항", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("남길 분석 문항", 800, false) - ))); - Question deletedQuestion = questionRepository.findById(selected.questions().get(0).questionId()).orElseThrow(); - Question retainedQuestion = questionRepository.findById(selected.questions().get(1).questionId()).orElseThrow(); - Analysis analysis = analysisRepository.save(Analysis.create(mockApply, 80, 80, 80, 80, "분석 피드백")); - QuestionAnalysis deletedQuestionAnalysis = questionAnalysisRepository.save(QuestionAnalysis.create( - deletedQuestion, - analysis, - "삭제할 문장", - "삭제할 이유", - "삭제할 개선안", - QuestionAnalysisStatus.MENTIONED, - 0, - 5 - )); - QuestionAnalysis retainedQuestionAnalysis = questionAnalysisRepository.save(QuestionAnalysis.create( - retainedQuestion, - analysis, - "남길 문장", - "남길 이유", - "남길 개선안", - QuestionAnalysisStatus.PROVEN, - 0, - 4 - )); - - questionService.deleteQuestion(user, mockApply.getId(), deletedQuestion.getId()); - - assertThat(questionAnalysisRepository.findById(deletedQuestionAnalysis.getId())).isEmpty(); - assertThat(questionAnalysisRepository.findById(retainedQuestionAnalysis.getId())).isPresent(); - } - - @Test - @DisplayName("마지막 남은 문항은 삭제할 수 없다") - void deleteQuestionThrowsWhenOnlyOneQuestionRemains() { - User user = saveUser("question-delete-last@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("마지막 문항", 700, false) - ))); - - assertThatThrownBy(() -> questionService.deleteQuestion(user, mockApply.getId(), selected.questions().get(0).questionId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).hasSize(1); - } - - @Test - @DisplayName("해당 지원서에 속하지 않은 문항은 삭제할 수 없다") - void deleteQuestionThrowsWhenQuestionDoesNotBelongToMockApply() { - User user = saveUser("question-delete-invalid@example.com"); - MockApply mockApply = saveMockApply(user); - MockApply otherMockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("현재 지원서 문항 1", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("현재 지원서 문항 2", 700, false) - ))); - QuestionSelectionResponse otherSelected = questionService.saveSelectedQuestions(user, otherMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("다른 지원서 문항", 700, false) - ))); - - assertThatThrownBy(() -> questionService.deleteQuestion(user, mockApply.getId(), otherSelected.questions().get(0).questionId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.QUESTION_NOT_FOUND); - } - - private User saveUser(String email) { - return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); - } - - private MockApply saveMockApply(User user) { - JobPosting jobPosting = saveJobPosting(); - return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - } - - private JobPosting saveJobPosting() { - User user = saveUser("question-jobposting" + System.nanoTime() + "@example.com"); - Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); - DetailClassification detailClassification = saveDetailClassification(); - return jobPostingRepository.save(JobPosting.create( - user, - company, - detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" - )); - } - - private DetailClassification saveDetailClassification() { - Classification classification = Classification.create("테스트 대분류 " + System.nanoTime()); - MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류"); - DetailClassification detailClassification = middleClassification.addDetailClassification("테스트 소분류"); - classificationRepository.save(classification); - return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); - } -} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java index 3b264fdc..38c33550 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.retrieval; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java index a987a2cb..8439e656 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java similarity index 70% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java index 7c358441..a7e98601 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java @@ -1,16 +1,21 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateReviewSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; +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.io.TempDir; +import org.mockito.ArgumentCaptor; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -21,7 +26,7 @@ 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.isNull; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -32,17 +37,15 @@ class EvaluationAnalysisBatchServiceTest { @TempDir Path tempDir; + private final ObjectMapper objectMapper = new ObjectMapper(); + @Test @DisplayName("LLM 응답을 검증해 평가 결과 CSV로 저장한다") void runWritesSanitizedEvaluationResults() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -103,20 +106,58 @@ void runWritesSanitizedEvaluationResults() throws Exception { assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("잘못된 출처"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("답변에 없는 문장"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("missing은 저장하지 않습니다."); - verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), any()); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(EvaluationAnalysisCommand.class); + verify(generator).generate(commandCaptor.capture()); + assertThat(commandCaptor.getValue().caseId()).isEqualTo("EV-01"); + assertThat(commandCaptor.getValue().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + assertThat(commandCaptor.getValue().jobCategorySmall()).isEqualTo("백엔드"); + assertThat(commandCaptor.getValue().question()).isEqualTo("경험을 쓰세요"); + assertThat(commandCaptor.getValue().answer()).isEqualTo("데이터 처리 경험이 있습니다."); + } + + @Test + @DisplayName("CSV 입력 순서대로 evaluation command를 generator에 전달한다") + void runPassesEvaluationCommandsInCsvOrder() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())).thenReturn(result(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of()))); + + Path input = tempDir.resolve("evaluation_cases_order.csv"); + Path output = tempDir.resolve("evaluation_cases_order_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-01,AI·개발·데이터,백엔드,API 개발,Spring,,첫 질문,첫 답변\n" + + "EV-02,디자인,프로덕트 디자이너,UX 설계,Figma,커뮤니케이션,둘째 질문,둘째 답변\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(EvaluationAnalysisCommand.class); + verify(generator, org.mockito.Mockito.times(2)).generate(commandCaptor.capture()); + assertThat(commandCaptor.getAllValues()) + .extracting( + EvaluationAnalysisCommand::caseId, + EvaluationAnalysisCommand::jobCategoryMiddle, + EvaluationAnalysisCommand::jobCategorySmall, + EvaluationAnalysisCommand::question, + EvaluationAnalysisCommand::answer + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("EV-01", "AI·개발·데이터", "백엔드", "첫 질문", "첫 답변"), + org.assertj.core.groups.Tuple.tuple("EV-02", "디자인", "프로덕트 디자이너", "둘째 질문", "둘째 답변") + ); } @Test @DisplayName("평가 결과도 운영과 동일하게 missingKeywords와 improvement를 후처리한다") void runAppliesProductionSanitizationRules() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -177,14 +218,10 @@ void runAppliesProductionSanitizationRules() throws Exception { @Test @DisplayName("평가 결과도 운영과 동일하게 유효한 PROVEN/FABRICATED를 보존하고 raw/final 비교 정보를 남긴다") void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -253,14 +290,10 @@ void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { @Test @DisplayName("평가 저장 경로도 reason이 null 또는 빈 PROVEN을 제외한다") void runSkipsProvenWithMissingReason() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -301,16 +334,12 @@ void runSkipsProvenWithMissingReason() throws Exception { } @Test - @DisplayName("없는 직무 중분류는 보조 기준 없이 분석한다") - void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + @DisplayName("중분류 기준 조회 여부와 무관하게 evaluation generator를 통해 분석한다") + void runDelegatesAnalysisThroughEvaluationGenerator() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of()))); Path input = tempDir.resolve("evaluation_cases.csv"); @@ -324,19 +353,15 @@ void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { service.run(input, output); - verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull()); + verify(generator).generate(any()); } @Test @DisplayName("필수 CSV header가 없으면 AI 호출 전에 실패한다") void runFailsFastWhenRequiredHeaderIsMissing() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); Path input = tempDir.resolve("evaluation_cases.csv"); Path output = tempDir.resolve("evaluation_ai_results.csv"); Files.writeString( @@ -350,20 +375,16 @@ void runFailsFastWhenRequiredHeaderIsMissing() throws Exception { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("missing required headers") .hasMessageContaining("answer"); - verifyNoInteractions(analysisAiClient); + verifyNoInteractions(generator); } @Test @DisplayName("LLM 호출 실패 케이스도 실패 row로 CSV에 기록한다") void runWritesFailureRowWhenAnalyzeFails() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenThrow(new RuntimeException("rate limit exceeded")); Path input = tempDir.resolve("evaluation_cases.csv"); @@ -387,18 +408,54 @@ void runWritesFailureRowWhenAnalyzeFails() throws Exception { assertThat(row.get("errorMessage")).contains("rate limit exceeded"); } + @Test + @DisplayName("한 사례가 timeout 나도 다음 사례와 CSV 출력은 계속된다") + void runContinuesAfterCaseDeadlineTimeout() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + doAnswer(invocation -> { + EvaluationAnalysisCommand command = invocation.getArgument(0); + if ("EV-TIMEOUT".equals(command.caseId())) { + throw new GeneralException(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, "평가 사례 처리 시간이 제한을 초과했습니다."); + } + return result(new AnalysisLlmResponse(90, 80, 70, "정상", List.of(), List.of())); + }).when(generator).generate(any()); + + Path input = tempDir.resolve("evaluation_timeout_cases.csv"); + Path output = tempDir.resolve("evaluation_timeout_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-TIMEOUT,AI·개발·데이터,백엔드,API 개발,Spring,,첫 질문,첫 답변\n" + + "EV-OK,AI·개발·데이터,백엔드,API 개발,Spring,,둘째 질문,둘째 답변\n", + StandardCharsets.UTF_8 + ); + + EvaluationAnalysisBatchService.EvaluationBatchSummary summary = service.run(input, output); + + assertThat(summary.totalCount()).isEqualTo(2); + assertThat(summary.successCount()).isEqualTo(1); + assertThat(summary.failureCount()).isEqualTo(1); + List> rows = EvaluationCsvSupport.read(output); + assertThat(rows).hasSize(2); + assertThat(rows.get(0).get("caseId")).isEqualTo("EV-TIMEOUT"); + assertThat(rows.get(0).get("errorMessage")).contains("제한을 초과"); + assertThat(rows.get(1).get("caseId")).isEqualTo("EV-OK"); + assertThat(rows.get(1).get("errorMessage")).isEmpty(); + assertThat(rows.get(1).get("aiJobFit")).isEqualTo("90"); + assertThat(rows.get(1).get("aiImpact")).isEqualTo("80"); + assertThat(rows.get(1).get("aiCompleteness")).isEqualTo("70"); + } + @Test @DisplayName("평가 CSV 후보/decision 통계는 검증 후 결과 기준으로 기록한다") void runWritesValidatedCandidateDecisionCounts() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) - .thenReturn(new AnalysisAiCallResult( + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) + .thenReturn(result( new AnalysisLlmResponse( 80, 70, @@ -412,7 +469,6 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { ), List.of() ), - null, new AnalysisCandidateResponse( List.of(), List.of( @@ -482,7 +538,6 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { 60, "피드백" ), - true, 10, 20 )); @@ -512,7 +567,43 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { assertThat(row.get("rejectionCodeCounts")).doesNotContain("NONE"); } - private AnalysisAiCallResult result(AnalysisLlmResponse response) { - return new AnalysisAiCallResult(response, null, null, null, false, 0, 1); + private EvaluationGeneratedResult result(AnalysisLlmResponse response) { + try { + return result(response, null, null, 0, 1); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private EvaluationGeneratedResult result( + AnalysisLlmResponse response, + AnalysisCandidateResponse sanitizedCandidateResponse, + CandidateReviewResponse candidateReviewResponse, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) throws Exception { + String rawLlmResponseJson = objectMapper.writeValueAsString(response == null ? List.of() : response); + String rawCandidateResponseJson = objectMapper.writeValueAsString(List.of()); + String sanitizedCandidateResponseJson = objectMapper.writeValueAsString( + sanitizedCandidateResponse == null ? List.of() : sanitizedCandidateResponse + ); + String candidateReviewResponseJson = objectMapper.writeValueAsString( + candidateReviewResponse == null ? List.of() : candidateReviewResponse + ); + return new EvaluationGeneratedResult( + new EvaluationLlmSnapshotParser(objectMapper).parseRawLlmResponse(rawLlmResponseJson), + rawLlmResponseJson, + rawCandidateResponseJson, + sanitizedCandidateResponseJson, + new EvaluationCandidateSnapshotParser(objectMapper).parse( + sanitizedCandidateResponseJson, + "sanitizedCandidateResponseJson", + "TEST" + ), + candidateReviewResponseJson, + new EvaluationCandidateReviewSnapshotParser(objectMapper).parse(candidateReviewResponseJson), + candidateCallLatencyMs, + finalCallLatencyMs + ); } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java new file mode 100644 index 00000000..af5bb3a7 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java @@ -0,0 +1,53 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationAnalysisPackageDependencyTest { + + private static final Path EVALUATION_ANALYSIS_ROOT = Path.of( + "src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis" + ); + private static final List FORBIDDEN_IMPORT_PREFIXES = List.of( + "com.jobdri.jobdri_api.domain.analysis.dto.", + "com.jobdri.jobdri_api.domain.analysis.service." + ); + + @Test + @DisplayName("evaluation analysis 패키지는 runtime analysis dto/service에 직접 의존하지 않는다") + void evaluationAnalysisPackageDoesNotDependOnRuntimeAnalysisDtoOrService() throws IOException { + List violations = new ArrayList<>(); + + try (var paths = Files.walk(EVALUATION_ANALYSIS_ROOT)) { + paths.filter(path -> path.toString().endsWith(".java")) + .forEach(path -> collectViolations(path, violations)); + } + + assertThat(violations).isEmpty(); + } + + private void collectViolations(Path file, List violations) { + try { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).stripLeading(); + for (String forbiddenPrefix : FORBIDDEN_IMPORT_PREFIXES) { + if (line.startsWith("import " + forbiddenPrefix) + || line.startsWith("import static " + forbiddenPrefix)) { + violations.add(file + ":" + (i + 1) + " -> " + forbiddenPrefix); + } + } + } + } catch (IOException e) { + throw new IllegalStateException("Failed to inspect file: " + file, e); + } + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java index 423f3b5a..41859e9e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java @@ -1,9 +1,9 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.domain.corpus.service.CorpusAdminRunner; import com.jobdri.jobdri_api.global.scheduling.AsyncTaskSweepScheduler; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.DefaultApplicationArguments; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java index 9ea41f71..1e9f1baf 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java index e824d952..93c5771d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java similarity index 97% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java index 1ffd58cb..21fdc13d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java similarity index 70% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java index 8d90ffd2..72fba441 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java @@ -1,7 +1,7 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -85,6 +85,84 @@ void replayFailsWhenExistingSanitizedDoesNotMatch() throws Exception { .hasMessageContaining("EV-01"); } + @Test + @DisplayName("blank missing keyword 후보는 parser 단계에서 fail-fast 한다") + void blankMissingKeywordCandidateFailsFast() throws Exception { + Path input = tempDir.resolve("input.csv"); + Map row = row("EV-01", response(List.of()), response(List.of())); + row.put("rawCandidateResponseJson", """ + {"missingKeywordCandidates":[{"keyword":" ","source":"MAIN_TASK","relatedRequirement":"재고 관리"}]} + """); + EvaluationCsvSupport.writeRows(input, headers(), List.of(row)); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("missingKeywordCandidates[0].keyword") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("unknown source 후보는 parser 단계에서 fail-fast 한다") + void unknownSourceCandidateFailsFast() throws Exception { + Path input = tempDir.resolve("input.csv"); + Map row = row("EV-01", response(List.of()), response(List.of())); + row.put("rawCandidateResponseJson", """ + {"missingKeywordCandidates":[{"keyword":"재고 분석 경험","source":"UNKNOWN","relatedRequirement":"재고 분석 경험"}]} + """); + EvaluationCsvSupport.writeRows(input, headers(), List.of(row)); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("missingKeywordCandidates[0].source") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("relatedRequirement가 다르면 기존 sanitized JSON과 mismatch로 fail-fast 한다") + void replayFailsWhenRelatedRequirementDoesNotMatch() throws Exception { + Path input = tempDir.resolve("input.csv"); + AnalysisCandidateResponse raw = response(List.of(candidate("재고 관리 및 분석 경험", "MAIN_TASK"))); + AnalysisCandidateResponse sanitized = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "재고 관리 및 분석 경험", + "MAIN_TASK", + "다른 요구사항" + )) + ); + writeInput(input, List.of(row("EV-01", raw, sanitized))); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("accepted candidates mismatch") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("legacy sanitized JSON에 relatedRequirement가 비어 있으면 mismatch로 보지 않는다") + void replayAllowsLegacyMissingRelatedRequirement() throws Exception { + Path input = tempDir.resolve("input.csv"); + AnalysisCandidateResponse raw = response(List.of(candidate("재고 관리 및 분석 경험", "MAIN_TASK"))); + AnalysisCandidateResponse sanitized = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "재고 관리 및 분석 경험", + "MAIN_TASK", + null + )) + ); + writeInput(input, List.of(row("EV-01", raw, sanitized))); + + MissingKeywordSanitizerReplayService.ReplaySummary summary = + service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv")); + + assertThat(summary.acceptedCandidateCount()).isEqualTo(1); + } + @Test @DisplayName("malformed rawCandidateResponseJson은 fail-fast 한다") void malformedRawCandidateResponseFailsFast() throws Exception { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java index 304b71c9..cc93db90 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.openai.client.OpenAIClient; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java index dbcbe468..b2582f93 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java @@ -1,7 +1,7 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java new file mode 100644 index 00000000..4ddc1c8e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java @@ -0,0 +1,64 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationCandidateReviewSnapshotParserTest { + + private final EvaluationCandidateReviewSnapshotParser parser = + new EvaluationCandidateReviewSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("candidate review json을 evaluation snapshot으로 파싱한다") + void parsesReviewJson() { + String json = """ + { + "decisions": [ + {"accepted": true, "rejectionCode": "NONE"}, + {"accepted": false, "rejectionCode": "NOT_ACTIONABLE"} + ] + } + """; + + EvaluationCandidateReviewSnapshot snapshot = parser.parse(json); + + assertThat(snapshot.decisions()) + .extracting(decision -> decision.accepted() + ":" + decision.rejectionCode()) + .containsExactly("true:NONE", "false:NOT_ACTIONABLE"); + } + + @Test + @DisplayName("candidate review json이 비어 있거나 malformed면 empty snapshot을 반환한다") + void returnsEmptySnapshotForEmptyOrMalformedJson() { + assertThat(parser.parse(null).decisions()).isEmpty(); + assertThat(parser.parse("").decisions()).isEmpty(); + assertThat(parser.parse("{bad-json").decisions()).isEmpty(); + } + + @Test + @DisplayName("decisions가 없거나 배열이 아니면 empty snapshot을 반환한다") + void returnsEmptySnapshotWhenDecisionsMissingOrNotArray() { + assertThat(parser.parse("{\"other\":[]}").decisions()).isEmpty(); + assertThat(parser.parse("{\"decisions\":{}}").decisions()).isEmpty(); + } + + @Test + @DisplayName("accepted가 boolean이 아니면 null로 파싱한다") + void parsesNonBooleanAcceptedAsNull() { + EvaluationCandidateReviewSnapshot snapshot = parser.parse(""" + { + "decisions": [ + {"accepted": "true", "rejectionCode": "NONE"} + ] + } + """); + + assertThat(snapshot.decisions()).singleElement() + .extracting(decision -> decision.accepted() + ":" + decision.rejectionCode()) + .isEqualTo("null:NONE"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java new file mode 100644 index 00000000..cf6cbdb4 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java @@ -0,0 +1,73 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvaluationCandidateSnapshotParserTest { + + private final EvaluationCandidateSnapshotParser parser = new EvaluationCandidateSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("candidate response json을 evaluation snapshot으로 파싱한다") + void parsesCandidateResponseJson() { + String json = """ + { + "strengthCandidates": [{"quote": "강점"}], + "analysisCandidates": [{"candidateId": "candidate-1"}], + "missingKeywordCandidates": [ + {"keyword": "Spring Boot", "source": "qualification", "relatedRequirement": "Spring Boot 실무 경험"}, + {"keyword": "ignored", "source": "unknown", "relatedRequirement": "N/A"} + ] + } + """; + + EvaluationCandidateSnapshot snapshot = parser.parse(json, "rawCandidateResponseJson", "EV-01"); + + assertThat(snapshot.strengthCandidates()).hasSize(1); + assertThat(snapshot.strengthCandidates()) + .extracting(node -> ((JsonNode) node).path("quote").asText()) + .containsExactly("강점"); + assertThat(snapshot.analysisCandidates()).hasSize(1); + assertThat(snapshot.analysisCandidates()) + .extracting(node -> ((JsonNode) node).path("candidateId").asText()) + .containsExactly("candidate-1"); + assertThat(snapshot.missingKeywordCandidates()) + .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) + .containsExactly("Spring Boot:QUALIFICATION"); + } + + @Test + @DisplayName("candidate response json이 malformed면 caseId를 포함해 fail-fast 한다") + void rejectsMalformedCandidateResponseJson() { + assertThatThrownBy(() -> parser.parse("{bad-json", "rawCandidateResponseJson", "EV-01")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("candidate 배열 필드가 없거나 배열이 아니면 빈 목록으로 파싱한다") + void returnsEmptyListsWhenCandidateArraysMissingOrNotArray() { + EvaluationCandidateSnapshot missingArrays = parser.parse("{}", "rawCandidateResponseJson", "EV-01"); + EvaluationCandidateSnapshot nonArrayFields = parser.parse(""" + { + "strengthCandidates": {}, + "analysisCandidates": "", + "missingKeywordCandidates": 1 + } + """, "rawCandidateResponseJson", "EV-01"); + + assertThat(missingArrays.strengthCandidates()).isEmpty(); + assertThat(missingArrays.analysisCandidates()).isEmpty(); + assertThat(missingArrays.missingKeywordCandidates()).isEmpty(); + assertThat(nonArrayFields.strengthCandidates()).isEmpty(); + assertThat(nonArrayFields.analysisCandidates()).isEmpty(); + assertThat(nonArrayFields.missingKeywordCandidates()).isEmpty(); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java new file mode 100644 index 00000000..d0f12d4e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java @@ -0,0 +1,79 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvaluationLlmSnapshotParserTest { + + private final EvaluationLlmSnapshotParser parser = new EvaluationLlmSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("raw llm response json을 evaluation snapshot으로 파싱한다") + void parsesRawLlmResponseJson() { + String rawLlmResponseJson = """ + { + "keyStrengths": [{"quote": "강점 문장"}], + "missingKeywords": [{"keyword": "Spring Boot", "source": "MAIN_TASK"}], + "questionAnalyses": [{"questionId": 1, "sentence": "문장", "status": "mentioned", "reason": "근거", "improvement": "개선"}] + } + """; + + EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(rawLlmResponseJson); + + assertThat(snapshot.jobFit()).isNull(); + assertThat(snapshot.impact()).isNull(); + assertThat(snapshot.completeness()).isNull(); + assertThat(snapshot.feedback()).isEmpty(); + assertThat(snapshot.keyStrengthQuotes()).containsExactly("강점 문장"); + assertThat(snapshot.missingKeywords()) + .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) + .containsExactly("Spring Boot:MAIN_TASK"); + assertThat(snapshot.questionAnalyses()) + .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) + .containsExactly("문장:mentioned"); + } + + @Test + @DisplayName("숫자 점수 필드를 모두 파싱한다") + void parsesNumericScores() { + EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(""" + { + "jobFit": 80, + "impact": 70, + "completeness": 60 + } + """); + + assertThat(snapshot.jobFit()).isEqualTo(80); + assertThat(snapshot.impact()).isEqualTo(70); + assertThat(snapshot.completeness()).isEqualTo(60); + } + + @Test + @DisplayName("missing keyword json을 evaluation 모델로 파싱한다") + void parsesMissingKeywordJson() { + List missingKeywords = parser.parseMissingKeywords( + "[{\"keyword\":\"Spring Boot\",\"source\":\"QUALIFICATIONS\"}]", + "EV-01" + ); + + assertThat(missingKeywords) + .extracting(keyword -> keyword.toString()) + .containsExactly("EvaluationMissingKeyword[keyword=Spring Boot, source=QUALIFICATION]"); + } + + @Test + @DisplayName("missing keyword json이 배열이 아니면 예외를 던진다") + void rejectsNonArrayMissingKeywordJson() { + assertThatThrownBy(() -> parser.parseMissingKeywords("{\"keyword\":\"Spring Boot\"}", "EV-01")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aiMissingKeywordsJson must be a JSON array"); + } +} 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 eb1db2e2..dc808c1f 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 @@ -6,7 +6,7 @@ import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; 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.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -120,7 +120,7 @@ void getTaskMarksPendingTimeout() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.QUEUE_TIMEOUT.name()); } @Test @@ -137,7 +137,7 @@ void getTaskMarksRunningTimeout() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.WORKER_TIMEOUT.name()); } @Test @@ -153,7 +153,7 @@ void getTaskMarksPendingTimeoutUsingSeconds() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.QUEUE_TIMEOUT.name()); } @Test @@ -170,7 +170,7 @@ void getTaskMarksRunningTimeoutUsingSeconds() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.WORKER_TIMEOUT.name()); } @Test @@ -182,13 +182,13 @@ void markRetryScheduledFailsWhenMaxRetryReached() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry exhausted", 3 ); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); - assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.FAILED); + assertThat(task.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.INTERNAL_ERROR); assertThat(task.getRetryCount()).isEqualTo(3); verify(jobPostingAsyncSseService).publish(any()); } @@ -210,7 +210,7 @@ void updateWorkerMetadataUpdatesWorkerFields() { @DisplayName("이미 종료된 task에 대한 재시도 예약은 무시된다") void markRetryScheduledDoesNothingWhenTaskIsTerminal() { JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); - task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "failed", 1); String originalError = task.getError(); int originalRetryCount = task.getRetryCount(); @@ -218,12 +218,12 @@ void markRetryScheduledDoesNothingWhenTaskIsTerminal() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.QUEUE_TIMEOUT, + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, "should be ignored", 2 ); - assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.INTERNAL_ERROR); assertThat(task.getError()).isEqualTo(originalError); assertThat(task.getRetryCount()).isEqualTo(originalRetryCount); verify(jobPostingAsyncSseService, never()).publish(any()); @@ -282,7 +282,7 @@ void markFailedCreatesSanitizedNotification() { jobPostingAsyncTaskService.markFailed( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "worker stack trace", 1 ); @@ -310,7 +310,7 @@ void cancelTaskMarksCancelledAndPublishesStatus() { var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("CANCELLED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.CANCELLED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.CANCELLED); assertThat(task.isCancelRequested()).isTrue(); verify(jobPostingAsyncSseService).publish(any()); verify(notificationService, never()).createNotification(any(), any(), any(), any(), any(), any(), any()); @@ -341,7 +341,7 @@ void cancelTaskKeepsSucceededTaskWithoutPublish() throws Exception { var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("SUCCEEDED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.SUCCEEDED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.SUCCEEDED); assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 성공했습니다."); verify(jobPostingAsyncSseService, never()).publish(any()); } @@ -352,14 +352,14 @@ void cancelTaskKeepsFailedTaskWithoutPublish() { User user = User.signup("테스트 사용자", "job-posting-cancel-failed@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); - task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "failed", 1); when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("FAILED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.FAILED); assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 실패했습니다."); verify(jobPostingAsyncSseService, never()).publish(any()); } @@ -410,7 +410,7 @@ void markRetryScheduledDoesNotCreateNotification() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry scheduled", 1 ); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java index 83150971..9c0154db 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java @@ -5,9 +5,6 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; -import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; -import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; -import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.domain.user.entity.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,9 +17,7 @@ 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.eq; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,14 +27,8 @@ class JobPostingExtensionIngestServiceTest { @Mock private JobPostingIngestService jobPostingIngestService; - @Mock - private JobPostingService jobPostingService; - - @Mock - private MockApplyService mockApplyService; - @InjectMocks - private JobPostingExtensionIngestService jobPostingExtensionIngestService; + private JobPostingExtensionIngestService ingestService; private User user; @@ -50,8 +39,8 @@ void setUp() { } @Test - @DisplayName("익스텐션 공고 수집 성공 시 공고 저장 후 모의 서류 지원을 생성한다") - void ingestCreatesMockApplyWhenJobPostingSaved() { + @DisplayName("익스텐션 공고 수집 성공 시 저장만 수행하고 mock 지원은 생성하지 않는다") + void ingestDoesNotCreateMockApplyWhenJobPostingSaved() { JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( "https://www.wanted.co.kr/wd/123", "WANTED", @@ -77,14 +66,10 @@ void ingestCreatesMockApplyWhenJobPostingSaved() { null, saved ); - MockApplyCreateResponse mockApply = new MockApplyCreateResponse(10L, 20L, ApplyType.MOCK, 1); - when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - when(mockApplyService.createMockApplyFromJobPosting(user, 10L)) - .thenReturn(mockApply); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); ArgumentCaptor ingestRequestCaptor = ArgumentCaptor.forClass(JobPostingIngestRequest.class); verify(jobPostingIngestService).ingestAndCreate(eq(user), ingestRequestCaptor.capture()); @@ -92,7 +77,7 @@ void ingestCreatesMockApplyWhenJobPostingSaved() { assertThat(ingestRequestCaptor.getValue().imageObjectKey()).isNull(); assertThat(response.sourceUrl()).isEqualTo("https://www.wanted.co.kr/wd/123"); assertThat(response.sourceSite()).isEqualTo("WANTED"); - assertThat(response.mockApply()).isEqualTo(mockApply); + assertThat(response.mockApply()).isNull(); } @Test @@ -116,9 +101,8 @@ void ingestSkipsMockApplyWhenJobPostingNotSaved() { when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); - verify(mockApplyService, never()).createMockApplyFromJobPosting(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); assertThat(response.savedToDatabase()).isFalse(); assertThat(response.mockApply()).isNull(); } @@ -144,51 +128,9 @@ void ingestSkipsMockApplyWhenSavedJobPostingIsNull() { when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); - verify(mockApplyService, never()).createMockApplyFromJobPosting(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); assertThat(response.savedToDatabase()).isTrue(); assertThat(response.mockApply()).isNull(); } - - @Test - @DisplayName("모의 서류 지원 생성 실패 시 저장된 공고를 보상 삭제한다") - void ingestDeletesSavedJobPostingWhenMockApplyCreationFails() { - JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( - "https://www.wanted.co.kr/wd/123", - "WANTED", - "채용 공고 원문" - ); - JobPostingResponse saved = JobPostingResponse.builder() - .jobPostingId(10L) - .userId(1L) - .companyId(2L) - .companyName("테스트 회사") - .detailClassificationId(3L) - .detailClassificationName("백엔드 개발") - .task("주요 업무") - .requirement("자격 요건") - .preferred("우대 사항") - .build(); - JobPostingIngestResponse ingest = new JobPostingIngestResponse( - true, - "저장 성공", - null, - null, - null, - null, - saved - ); - RuntimeException failure = new RuntimeException("mock apply create failed"); - - when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) - .thenReturn(ingest); - when(mockApplyService.createMockApplyFromJobPosting(user, 10L)) - .thenThrow(failure); - - assertThatThrownBy(() -> jobPostingExtensionIngestService.ingest(user, request)) - .isSameAs(failure); - - verify(jobPostingService).deleteJobPosting(user, 10L); - } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java index e17c4631..3012a700 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 14fedcc8..f1abb333 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -4,7 +4,8 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; @@ -17,12 +18,9 @@ import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.entity.CompanySize; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeItemResponse; @@ -42,7 +40,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Page; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; @@ -59,8 +56,6 @@ 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.Mockito.when; @SpringBootTest @ActiveProfiles("test") @@ -103,9 +98,6 @@ class MockApplyServiceTest { @Autowired private PlatformTransactionManager transactionManager; - @MockBean - private MockJobPostingGenerationService mockJobPostingGenerationService; - @Test @DisplayName("기존 공고를 기준으로 ACTUAL 타입 모의 서류 지원을 생성한다") void createActualApply() { @@ -191,67 +183,6 @@ void createActualApplyThrowsWhenRequestedSequenceDuplicated() { .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); } - @Test - @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") - void createMockApply() { - User user = saveUser("mock-apply@example.com"); - String companyName = "선택 기업 " + UUID.randomUUID(); - Company company = saveCompany(companyName, CompanySize.MEDIUM); - DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); - Long middleClassificationId = detailClassification.getMiddleClassification().getId(); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( - company.getId(), - middleClassificationId, - detailClassification.getId() - ); - when(mockJobPostingGenerationService.generate(any())) - .thenReturn(new JobPostingMockGenerateResponse( - companyName, - "프론트엔드 개발자", - "웹 프론트엔드 개발 및 운영", - "HTML/CSS/JavaScript 기본기", - "React 경험 우대", - "프론트엔드 직무 대상 모의 공고입니다.", - List.of("질문 1", "질문 2") - )); - - MockApplyCreateResponse response = mockApplyService.createMockApply(user, request); - - MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); - JobPosting jobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); - assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); - assertThat(response.sequence()).isEqualTo(1); - assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); - assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); - assertThat(mockApply.getSequence()).isEqualTo(1); - assertThat(jobPosting.getCompany().getId()).isEqualTo(company.getId()); - assertThat(jobPosting.getCompany().getName()).isEqualTo(companyName); - assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); - assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); - assertThat(jobPosting.getTask()).isEqualTo("웹 프론트엔드 개발 및 운영"); - assertThat(jobPosting.getRequirement()).isEqualTo("HTML/CSS/JavaScript 기본기"); - assertThat(jobPosting.getPreferred()).isEqualTo("React 경험 우대"); - } - - @Test - @DisplayName("저장된 공고를 기준으로 MOCK 타입 모의 서류 지원을 생성한다") - void createMockApplyFromJobPosting() { - User user = saveUser("mock-from-job-posting@example.com"); - JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); - - MockApplyCreateResponse response = mockApplyService.createMockApplyFromJobPosting(user, jobPosting.getId()); - - MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); - assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); - assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); - assertThat(response.sequence()).isEqualTo(1); - assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); - assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); - assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); - assertThat(mockApply.getSequence()).isEqualTo(1); - } - @Test @DisplayName("기존 지원의 공고에 새 회차와 문항을 생성한다") void retryMockApply() { @@ -424,7 +355,7 @@ void getMyMockAppliesUsesNewestActiveAnalysisTaskOnly() { MockApply failedTaskApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.MOCK)); failedTaskApply.updateStatus(MockApplyStatus.ANSWER_WRITE); AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(user.getId(), failedTaskApply.getId(), 0); - failedTask.markFailed(AnalysisAsyncTask.FailureReason.OPENAI_TIMEOUT, "timeout", 0); + failedTask.markFailed(AnalysisAsyncFailureReason.OPENAI_TIMEOUT, "timeout", 0); analysisAsyncTaskRepository.save(failedTask); MockApply cancelledTaskApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.MOCK)); cancelledTaskApply.updateStatus(MockApplyStatus.ANSWER_WRITE); @@ -794,68 +725,6 @@ void createActualApplyThrowsWhenJobPostingNotFound() { .isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND); } - @Test - @DisplayName("다른 사용자의 공고로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyFromJobPostingThrowsWhenForbidden() { - User owner = saveUser("mock-owner@example.com"); - User otherUser = saveUser("mock-other@example.com"); - JobPosting jobPosting = saveJobPosting(owner, "프론트엔드 개발"); - - assertThatThrownBy(() -> mockApplyService.createMockApplyFromJobPosting(otherUser, jobPosting.getId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.FORBIDDEN); - } - - @Test - @DisplayName("존재하지 않는 소분류 ID로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenDetailClassificationNotFound() { - User user = saveUser("missing-detail-classification@example.com"); - Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 1L, 9999L); - when(mockJobPostingGenerationService.generate(any())) - .thenThrow(new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류를 찾을 수 없습니다. detailClassificationId=9999" - )); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); - } - - @Test - @DisplayName("존재하지 않는 회사 ID로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenCompanyNotFound() { - User user = saveUser("missing-company@example.com"); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(9999L, 1L, 1L); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.COMPANY_NOT_FOUND); - } - - @Test - @DisplayName("소분류가 중분류에 속하지 않으면 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenMiddleClassificationMismatched() { - User user = saveUser("middle-mismatch@example.com"); - Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); - DetailClassification detailClassification = saveDetailClassification("데이터 분석"); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, detailClassification.getId()); - when(mockJobPostingGenerationService.generate(any())) - .thenThrow(new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=9999, detailClassificationId=" + detailClassification.getId() - )); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); - } - @Test @DisplayName("다른 사용자의 mockApplyId로 공고 조회 시 예외를 던진다") void getMockApplyJobPostingThrowsWhenForbidden() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java index d5cb25a7..3c5ffcd8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java @@ -2,7 +2,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.CouponRedeemRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.CouponRedeemResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CouponRedemptionRepository; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java index 2df4d58e..5e01ba4a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; 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 3a1430bf..3cc2a262 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 @@ -1,10 +1,10 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancellation; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneAmount; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancellation; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneAmount; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePrepareData; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; @@ -13,31 +13,36 @@ import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentPrepareResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossEasyPayInfo; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossEasyPayInfo; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.controller.PaymentController; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -48,6 +53,8 @@ import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.IntFunction; import static org.assertj.core.api.Assertions.assertThat; @@ -58,13 +65,19 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest +@AutoConfigureMockMvc @ActiveProfiles("test") class PaymentServiceTest { private static final String TEST_PORTONE_WEBHOOK_SECRET = "whsec_dGVzdC13ZWJob29rLXNlY3JldA=="; private static final String ORDER_ID_PATTERN = "^jobdri-[0-9a-f]{32}$"; + private static final long CONCURRENCY_TEST_TIMEOUT_SECONDS = 10L; @Autowired private PaymentService paymentService; @@ -81,6 +94,9 @@ class PaymentServiceTest { @Autowired private CreditTransactionRepository creditTransactionRepository; + @Autowired + private MockMvc mockMvc; + @MockitoBean private TossPaymentClient tossPaymentClient; @@ -282,6 +298,118 @@ void confirmConcurrentlyChargesOnlyOnce() throws Exception { )).hasSize(1); } + @Test + @DisplayName("토스 직접 승인 중 PAY_COMPLETE 콜백이 동시에 와도 한 번만 충전하고 둘 다 성공 처리한다") + void confirmAndTossPayCallbackRaceCompletesIdempotently() throws Exception { + User user = saveUser("payment-confirm-callback-race@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + PaymentConfirmRequest confirmRequest = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + TossPayCallbackRequest callbackRequest = tossPayCompleteCallback(prepared); + + CountDownLatch confirmEnteredExternalCall = new CountDownLatch(1); + CountDownLatch callbackAttemptFinished = new CountDownLatch(1); + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenAnswer(invocation -> { + confirmEnteredExternalCall.countDown(); + assertThat(callbackAttemptFinished.await(5, TimeUnit.SECONDS)).isTrue(); + return tossPayResponse(paymentKey, prepared, 2500); + }); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + + List results = runConcurrentlyIndexed(2, index -> { + try { + if (index == 0) { + paymentService.confirm(user, confirmRequest); + return Result.ok(); + } + + assertThat(confirmEnteredExternalCall.await(5, TimeUnit.SECONDS)).isTrue(); + paymentService.handleTossPayCallback(callbackRequest); + return Result.ok(); + } catch (Exception e) { + return Result.failure(e); + } finally { + if (index == 1) { + callbackAttemptFinished.countDown(); + } + } + }); + + assertThat(results).hasSize(2); + assertThat(results.get(0).success()).isTrue(); + assertThat(results.get(1).success()).isTrue(); + + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(payment.getTossStatus()).isEqualTo("PAY_COMPLETE"); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + + @Test + @DisplayName("PROCESSING 상태에서 PAY_CANCEL 콜백이 오면 결제를 실패 처리하고 크레딧을 지급하지 않는다") + void tossPayCallbackFailsProcessingPaymentWhenCanceled() { + User user = saveUser("payment-callback-processing-cancel@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_CANCEL, prepared.amount()); + + paymentService.handleTossPayCallback(new TossPayCallbackRequest( + "PAY_CANCEL", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + )); + + assertPaymentFailedWithoutCreditCharge(user, prepared.orderId()); + } + + @Test + @DisplayName("PROCESSING 상태에서 중간 상태 콜백이 오면 PROCESSING을 유지하고 상태만 갱신한다") + void tossPayCallbackKeepsProcessingPaymentWhenStatusIsIntermediate() { + User user = saveUser("payment-callback-processing-approved@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_APPROVED, prepared.amount()); + + paymentService.handleTossPayCallback(new TossPayCallbackRequest( + "PAY_APPROVED", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + )); + + Payment updatedPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.PROCESSING); + assertThat(updatedPayment.getTossStatus()).isEqualTo("PAY_APPROVED"); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).isEmpty(); + } + @Test @DisplayName("토스 승인 응답이 간편결제가 아니면 결제를 실패 처리하고 크레딧을 충전하지 않는다") void confirmThrowsWhenPaymentMethodIsNotEasyPay() { @@ -577,6 +705,42 @@ void tossPayCallbackIgnoresCancelAfterCompleted() { )).hasSize(1); } + @Test + @DisplayName("이미 완료된 결제에 REFUND_SUCCESS 콜백이 와도 환불 처리와 크레딧 회수는 한 번만 수행한다") + void tossPayCallbackRefundsCompletedPaymentOnce() { + User user = saveUser("payment-callback-refund-success@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + paymentService.handleTossPayCallback(tossPayCompleteCallback(prepared)); + + mockTossPayStatus(prepared, TossPayStatus.REFUND_SUCCESS, prepared.amount()); + TossPayCallbackRequest refundCallback = new TossPayCallbackRequest( + "REFUND_SUCCESS", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + ); + + paymentService.handleTossPayCallback(refundCallback); + paymentService.handleTossPayCallback(refundCallback); + + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REFUNDED); + assertThat(payment.getTossStatus()).isEqualTo("REFUND_SUCCESS"); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.USE + )).filteredOn(transaction -> transaction.getReferenceId().equals("PAYMENT_REFUND:TOSS_PAY_DIRECT:" + prepared.orderId())) + .hasSize(1); + } + @Test @DisplayName("토스페이 중간 상태 콜백은 상태만 저장하고 크레딧을 지급하지 않는다") void tossPayCallbackDoesNotChargeWhenStatusIsNotComplete() { @@ -625,6 +789,25 @@ void tossPayCallbackControllerAcknowledgesUnrecoverableValidationFailure() { assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); } + @Test + @DisplayName("이미 PROCESSING 상태인 결제의 PAY_COMPLETE 콜백은 컨트롤러에서 성공 처리한다") + void tossPayCallbackControllerProcessesProcessingPayment() { + User user = saveUser("payment-callback-already-processed@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + + var response = paymentController.tossPayCallback(tossPayCompleteCallback(prepared)); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + Payment updatedPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + } + @Test @DisplayName("재시도 가능한 토스페이 콜백 상태 조회 실패는 컨트롤러에서 전파한다") void tossPayCallbackControllerPropagatesRetryableStatusFailure() { @@ -1086,6 +1269,99 @@ void portOneWebhookPropagatesTimeout() { assertPaymentPendingWithoutCreditCharge(user, prepared.orderId()); } + @Test + @DisplayName("크레딧 거래 내역을 페이지 단위로 최신순 조회한다") + void getTransactionsPage() { + User user = saveUser("payment-transactions-page@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "첫 충전", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 1, "사용", "use-1"); + saveCreditTransaction(user, CreditTransactionType.COUPON, 1, 2, "쿠폰", "coupon-1"); + + Page firstPage = paymentService.getTransactionsPage(user, null, 0, 2); + Page secondPage = paymentService.getTransactionsPage(user, null, 1, 2); + + assertThat(firstPage.getTotalElements()).isEqualTo(3); + assertThat(firstPage.getContent()).extracting("referenceId") + .containsExactly("coupon-1", "use-1"); + assertThat(secondPage.getContent()).extracting("referenceId") + .containsExactly("charge-1"); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 조회는 타입 필터와 최대 페이지 크기 제한을 적용한다") + void getTransactionsPageWithTypeFilter() { + User user = saveUser("payment-transactions-page-filter@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "충전 1", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 5, 7, "충전 2", "charge-2"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 6, "사용", "use-1"); + + Page page = paymentService.getTransactionsPage(user, CreditTransactionType.CHARGE, 0, 999); + + assertThat(page.getSize()).isEqualTo(100); + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting("referenceId") + .containsExactly("charge-2", "charge-1"); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 조회는 page와 size 경계값을 보정한다") + void getTransactionsPageNormalizesBounds() { + User user = saveUser("payment-transactions-page-bounds@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "충전 1", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 1, "사용", "use-1"); + saveCreditTransaction(user, CreditTransactionType.COUPON, 1, 2, "쿠폰", "coupon-1"); + + Page negativePage = paymentService.getTransactionsPage(user, null, -1, 2); + Page zeroSize = paymentService.getTransactionsPage(user, null, 0, 0); + Page negativeSize = paymentService.getTransactionsPage(user, null, 0, -5); + Page oversized = paymentService.getTransactionsPage(user, null, 0, 999); + + assertThat(negativePage.getNumber()).isEqualTo(0); + assertThat(negativePage.getContent()).extracting("referenceId") + .containsExactly("coupon-1", "use-1"); + assertThat(zeroSize.getSize()).isEqualTo(1); + assertThat(zeroSize.getContent()).extracting("referenceId") + .containsExactly("coupon-1"); + assertThat(negativeSize.getSize()).isEqualTo(1); + assertThat(negativeSize.getContent()).extracting("referenceId") + .containsExactly("coupon-1"); + assertThat(oversized.getSize()).isEqualTo(100); + assertThat(oversized.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 API는 기본 page/size와 type 필터를 적용한다") + void getTransactionsPageEndpointUsesDefaultsAndTypeFilter() throws Exception { + User user = saveUser("payment-transactions-page-endpoint@example.com"); + for (int i = 1; i <= 21; i++) { + saveCreditTransaction(user, CreditTransactionType.CHARGE, i, i + 1, "충전 " + i, "charge-" + i); + } + saveCreditTransaction(user, CreditTransactionType.USE, -1, 21, "사용", "use-1"); + + mockMvc.perform(get("/api/payments/credits/me/transactions/page") + .param("type", "CHARGE") + .with(user(new UserDetailsImpl(userRepository.findById(user.getId()).orElseThrow())))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON2000")) + .andExpect(jsonPath("$.message").value("크레딧 거래 내역 페이징 조회에 성공했습니다.")) + .andExpect(jsonPath("$.result.number").value(0)) + .andExpect(jsonPath("$.result.size").value(20)) + .andExpect(jsonPath("$.result.totalElements").value(21)) + .andExpect(jsonPath("$.result.content.length()").value(20)) + .andExpect(jsonPath("$.result.content[0].type").value("CHARGE")) + .andExpect(jsonPath("$.result.content[0].referenceId").value("charge-21")); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 API는 인증이 없으면 401을 반환한다") + void getTransactionsPageEndpointRequiresAuthentication() throws Exception { + mockMvc.perform(get("/api/payments/credits/me/transactions/page")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4011")); + } + @Test @DisplayName("결제 주문 상태 조회는 소유자의 주문만 반환한다") void getOrderStatus() { @@ -1164,6 +1440,24 @@ private void mockPortOneCancel(Payment payment, String reason) { ))); } + private CreditTransaction saveCreditTransaction( + User user, + CreditTransactionType type, + int amount, + int balanceAfter, + String description, + String referenceId + ) { + return creditTransactionRepository.save(CreditTransaction.create( + user, + type, + amount, + balanceAfter, + description, + referenceId + )); + } + private String portOneWebhookBody(String paymentId) { return """ {"type":"Transaction.Paid","timestamp":"2026-07-30T10:00:00.000Z","data":{"paymentId":"%s","storeId":"store-test","transactionId":"transaction-%s"}} @@ -1332,12 +1626,26 @@ private List runConcurrentlyIndexed(int threadCount, IntFunction var futures = tasks.stream() .map(executor::submit) .toList(); - ready.await(); + if (!ready.await(CONCURRENCY_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + cancelAll(futures); + throw new AssertionError("Concurrent test setup timed out before all workers became ready."); + } start.countDown(); List results = new java.util.ArrayList<>(); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(CONCURRENCY_TEST_TIMEOUT_SECONDS); for (var future : futures) { - results.add(future.get()); + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + cancelAll(futures); + throw new AssertionError("Concurrent test timed out while waiting for worker completion."); + } + try { + results.add(future.get(remainingNanos, TimeUnit.NANOSECONDS)); + } catch (TimeoutException e) { + cancelAll(futures); + throw new AssertionError("Concurrent test timed out while waiting for worker completion.", e); + } } return results; } finally { @@ -1345,6 +1653,12 @@ private List runConcurrentlyIndexed(int threadCount, IntFunction } } + private void cancelAll(List> futures) { + for (var future : futures) { + future.cancel(true); + } + } + private record Result(boolean success, Exception exception) { static Result ok() { return new Result(true, null); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java index a8aea923..d7120bd1 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java index dfea0c37..8505322b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; @@ -113,7 +113,7 @@ void getPaymentStatusDoesNotRequireCallbackUrls() { when(restClient.post()).thenReturn(requestBodyUriSpec); when(requestBodyUriSpec.uri("/api/v2/status")).thenReturn(requestBodyUriSpec); when(requestBodyUriSpec.contentType(MediaType.APPLICATION_JSON)).thenReturn(requestBodyUriSpec); - when(requestBodyUriSpec.body(new com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusRequest( + when(requestBodyUriSpec.body(new com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusRequest( "test-api-key", "pay-token-1", "order-1"