diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5099e367..4138d1b6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,7 @@ services: APP_AUDIT_LOG_PATH: ${APP_AUDIT_LOG_PATH:-/var/log/spring-boot/audit} ports: - "${APP_PORT:-8080}:8080" + - "127.0.0.1:${API_METRICS_HOST_PORT:-19090}:9090" volumes: - spring-boot-logs:/var/log/spring-boot depends_on: @@ -70,6 +71,8 @@ services: condition: service_started rabbitmq: condition: service_healthy + ports: + - "127.0.0.1:${WORKER_METRICS_HOST_PORT:-18000}:8000" restart: unless-stopped loki: 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 4cbc916a..ffd4ba77 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 @@ -13,7 +13,6 @@ import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,7 +22,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -34,20 +32,15 @@ @Tag(name = "Analysis Worker Internal", description = "자소서 분석 worker 내부 통신 API") public class AnalysisWorkerInternalController { - private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; - - private final InternalApiKeyValidator internalApiKeyValidator; private final AnalysisWorkerBridgeService analysisWorkerBridgeService; private final AnalysisAsyncTaskService analysisAsyncTaskService; @Operation(summary = "자소서 분석 worker 작업 실행 상태 반영", description = "worker가 taskId 기준 자소서 분석 작업을 실행 중 상태로 변경합니다.") @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerRunningRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("자소서 분석 worker 작업 시작 상태를 반영했습니다."); } @@ -55,11 +48,9 @@ public ApiResponse markRunning( @Operation(summary = "자소서 분석 worker 작업 재시도 상태 반영", description = "worker가 자소서 분석 작업 실패 후 재시도 상태와 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/retry") public ApiResponse markRetry( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerRetryRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.markRetry( taskId, request.failureReason(), @@ -74,11 +65,9 @@ public ApiResponse markRetry( @Operation(summary = "자소서 분석 worker 작업 실패 반영", description = "worker가 자소서 분석 작업 실패 상태와 실패 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/failed") public ApiResponse failTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerFailureRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.failTask( taskId, request.failureReason(), @@ -93,10 +82,8 @@ public ApiResponse failTask( @Operation(summary = "자소서 분석 worker 컨텍스트 조회", description = "worker가 분석 실행에 필요한 자소서, 공고, 지원 정보 컨텍스트를 조회합니다.") @PostMapping("/context") public ApiResponse getContext( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody AnalysisWorkerContextRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 컨텍스트 조회에 성공했습니다.", analysisWorkerBridgeService.getContext(request.taskId(), request.userId(), request.mockApplyId()) @@ -106,11 +93,9 @@ public ApiResponse getContext( @Operation(summary = "자소서 분석 worker 작업 완료 반영", description = "worker가 생성한 분석 결과를 저장하고 taskId 기준 작업 완료 상태를 반영합니다.") @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerCompleteRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 작업 완료 상태를 반영했습니다.", analysisWorkerBridgeService.completeTask(taskId, request) @@ -120,11 +105,9 @@ public ApiResponse completeTask( @Operation(summary = "자소서 분석 worker 결과 선저장", description = "worker가 complete 호출 전에 taskId 기준 분석 결과를 durable storage에 저장합니다.") @PostMapping("/tasks/{taskId}/result") public ApiResponse storeResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerResultStoreRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.storeGeneratedResult(taskId, request); return ApiResponse.onSuccess("자소서 분석 worker 결과 선저장에 성공했습니다."); } @@ -132,10 +115,8 @@ public ApiResponse storeResult( @Operation(summary = "자소서 분석 worker 저장 결과 조회", description = "worker가 taskId 기준으로 저장된 분석 결과 payload를 조회합니다.") @GetMapping("/tasks/{taskId}/result") public ApiResponse getStoredResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 저장 결과 조회에 성공했습니다.", analysisWorkerBridgeService.getStoredResult(taskId) @@ -145,10 +126,8 @@ public ApiResponse getStoredResult( @Operation(summary = "자소서 분석 worker 작업 상태 조회", description = "taskId 기준 자소서 분석 worker 비동기 작업 상태를 내부 용도로 조회합니다.") @GetMapping("/tasks/{taskId}") public ApiResponse getTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 작업 상태 조회에 성공했습니다.", analysisAsyncTaskService.getTaskStatusByTaskId(taskId) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index efc2b91c..69f56597 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -133,7 +133,16 @@ public static Analysis create( } public void updateHighlightsJson(String keyStrengthsJson, String keyWeaknessesJson) { + updateResultPayloadJson(keyStrengthsJson, keyWeaknessesJson, this.missingKeywordsJson); + } + + public void updateResultPayloadJson( + String keyStrengthsJson, + String keyWeaknessesJson, + String missingKeywordsJson + ) { this.keyStrengthsJson = keyStrengthsJson == null ? "[]" : keyStrengthsJson; this.keyWeaknessesJson = keyWeaknessesJson == null ? "[]" : keyWeaknessesJson; + this.missingKeywordsJson = missingKeywordsJson == null ? "[]" : missingKeywordsJson; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java new file mode 100644 index 00000000..ef9a3129 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java @@ -0,0 +1,85 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +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.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.service.sanitization.AnalysisResultSanitizationService; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AnalysisResponseAssembler { + private final MockApplyRepository mockApplyRepository; + + AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses, + AnalysisResultSanitizationService.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); + } +} 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 index cf5d5ff6..30551189 100644 --- 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 @@ -1,16 +1,10 @@ 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; @@ -18,24 +12,21 @@ 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.AnalysisResultSanitizationService; 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; @@ -47,30 +38,18 @@ 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; + private final AnalysisResponseAssembler analysisResponseAssembler; + private final AnalysisResultSanitizationService analysisResultSanitizationService; + private final AnalysisResultValidationService analysisResultValidationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -85,44 +64,50 @@ public AnalysisResponse finalizeAnalysis( 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 - ); + AnalysisResultValidationService.ValidatedAnalysisResult validatedResult = + analysisResultValidationService.validateForPersistence(questions, payloadSnapshots, llmResponse); + AnalysisResultSanitizationService.SanitizedAnalysisContent sanitizedContent = + analysisResultSanitizationService.sanitizeForPersistence( + lockedMockApply.getJobPosting(), + validatedResult.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"), + calculateScore( + validatedResult.jobFit(), + validatedResult.impact(), + validatedResult.completeness() + ), + validatedResult.jobFit(), + validatedResult.impact(), + validatedResult.completeness(), + validatedResult.feedback(), + analysisResultSanitizationService.serializeMissingKeywords(sanitizedContent.missingKeywords()), + analysisResultSanitizationService.serializeHighlights(sanitizedContent.keyStrengths(), "keyStrengths"), + analysisResultSanitizationService.serializeHighlights(sanitizedContent.keyWeaknesses(), "keyWeaknesses"), inputFingerprint )); List questionAnalyses = buildQuestionAnalyses( analysis, questions, - answerSnapshot.answerByQuestionId(), + validatedResult.answerSnapshot().answerByQuestionId(), llmResponse ); questionAnalysisRepository.saveAll(questionAnalyses); lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); mockApplyRepository.flush(); - return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + return analysisResponseAssembler.toResponse( + lockedMockApply, + analysis, + questions, + questionAnalyses, + analysisResultSanitizationService.analysisResultPayload(analysis) + ); } @Transactional @@ -136,12 +121,12 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, true) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, true) ); } @@ -151,12 +136,12 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, false) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, false) ); } @@ -173,49 +158,6 @@ private void replaceExistingAnalysis(MockApply mockApply) { 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, @@ -270,7 +212,7 @@ private List buildQuestionAnalyses( } String sentence = item.sentence(); if (status != QuestionAnalysisStatus.PROVEN - && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + && keyStrengthQuotes.contains(analysisResultSanitizationService.normalizeKeyword(sentence))) { continue; } String dedupeKey = question.getId() + ":" + sentence.trim(); @@ -297,7 +239,12 @@ private List buildQuestionAnalyses( analysis, sentence, defaultString(item.reason()), - normalizeImprovement(sentence, answer, item.improvement(), status), + analysisResultValidationService.normalizeImprovement( + sentence, + answer, + item.improvement(), + status + ), status, start, start + sentence.length() @@ -313,372 +260,10 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) } return llmResponse.keyStrengths().stream() .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) + .map(item -> analysisResultSanitizationService.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 @@ -695,31 +280,10 @@ private int findNextSentenceStart(String answer, String sentence, int fromIndex) 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; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java new file mode 100644 index 00000000..c07f2151 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java @@ -0,0 +1,137 @@ +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.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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 +@RequiredArgsConstructor +public class AnalysisResultValidationService { + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + + public ValidatedAnalysisResult validateForPersistence( + List databaseQuestions, + List payloadSnapshots, + AnalysisLlmResponse llmResponse + ) { + VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(databaseQuestions, payloadSnapshots); + validateRequiredScores(llmResponse); + return new ValidatedAnalysisResult( + answerSnapshot, + validateScore("jobFit", llmResponse.jobFit()), + validateScore("impact", llmResponse.impact()), + validateScore("completeness", llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + ); + } + + public 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)); + } + + public void validateRequiredScores(AnalysisLlmResponse llmResponse) { + if (llmResponse == null + || llmResponse.jobFit() == null + || llmResponse.impact() == null + || llmResponse.completeness() == null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." + ); + } + } + + public 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; + } + + public String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + public String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); + } + + public record VerifiedAnswerSnapshot( + List answers, + Map answerByQuestionId + ) { + public String combinedAnswers() { + return answers.stream() + .map(AnalysisExecutionPayload.AnswerSnapshot::answer) + .filter(StringUtils::hasText) + .collect(Collectors.joining("\n")); + } + } + + public record ValidatedAnalysisResult( + VerifiedAnswerSnapshot answerSnapshot, + int jobFit, + int impact, + int completeness, + String feedback + ) { + } +} 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 c2d7fff3..4a6e0db2 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 @@ -102,7 +102,7 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisGenerator.analyze(payload); } @@ -112,6 +112,17 @@ public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } + @Transactional + public AnalysisResponse finalizeAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + return persistAnalysis(user, mockApplyId, payload, llmResponse, inputFingerprint); + } + @Transactional public AnalysisResponse finalizeAnalysis( User user, @@ -119,7 +130,7 @@ public AnalysisResponse finalizeAnalysis( AnalysisExecutionPayload payload, AnalysisLlmResponse llmResponse ) { - return finalizeAnalysis( + return persistAnalysis( user, mockApplyId, payload, @@ -128,8 +139,7 @@ public AnalysisResponse finalizeAnalysis( ); } - @Transactional - public AnalysisResponse finalizeAnalysis( + private AnalysisResponse persistAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java new file mode 100644 index 00000000..1925c12b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java @@ -0,0 +1,353 @@ +package com.jobdri.jobdri_api.domain.analysis.service.sanitization; + +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.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; +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.Analysis; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +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.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; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AnalysisResultSanitizationService { + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; + private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper objectMapper; + + public SanitizedAnalysisContent sanitizeForPersistence( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + List keyStrengths = buildHighlights(llmResponse.keyStrengths()); + return new SanitizedAnalysisContent( + keyStrengths, + buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths), + buildMissingKeywords(jobPosting, combinedAnswers, llmResponse) + ); + } + + public AnalysisResultPayload analysisResultPayload(Analysis analysis) { + List keyStrengths = readHighlights( + analysis, + analysis.getKeyStrengthsJson(), + "keyStrengths" + ); + return new AnalysisResultPayload( + keyStrengths, + removeOverlappingHighlights( + readHighlights(analysis, analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + keyStrengths + ), + readMissingKeywords(analysis) + ); + } + + public AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis, boolean persistIfChanged) { + AnalysisResultPayload payload = analysisResultPayload(analysis); + String sanitizedKeyStrengthsJson = serializeHighlights(payload.keyStrengths(), "keyStrengths"); + String sanitizedKeyWeaknessesJson = serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses"); + String sanitizedMissingKeywordsJson = serializeMissingKeywords(payload.missingKeywords()); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()) + || !sanitizedMissingKeywordsJson.equals(analysis.getMissingKeywordsJson()))) { + analysis.updateResultPayloadJson( + sanitizedKeyStrengthsJson, + sanitizedKeyWeaknessesJson, + sanitizedMissingKeywordsJson + ); + } + return payload; + } + + public List buildHighlights(List items) { + return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); + } + + public List buildNonOverlappingHighlights( + List items, + List existingHighlights + ) { + return sanitizeHighlights( + removeOverlappingRawHighlights(items, existingHighlights), + AnalysisLlmResponse.HighlightItem::title, + AnalysisLlmResponse.HighlightItem::quote + ); + } + + public List buildMissingKeywords( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new java.util.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; + } + + public String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + public 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 "[]"; + } + } + + public 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 "[]"; + } + } + + public 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(); + } + } + + public 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 java.util.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 java.util.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()); + } + + public record AnalysisResultPayload( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } + + public record SanitizedAnalysisContent( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index 031ffe9e..3b7ca25f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -15,7 +15,6 @@ import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,7 +24,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -38,20 +36,15 @@ @Tag(name = "JobPosting Worker Internal", description = "채용 공고 worker 내부 통신 API") public class JobPostingWorkerInternalController { - private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; - - private final InternalApiKeyValidator internalApiKeyValidator; private final JobPostingWorkerBridgeService jobPostingWorkerBridgeService; private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService; @Operation(summary = "채용 공고 worker 작업 실행 상태 반영", description = "worker가 taskId 기준 채용 공고 작업을 실행 중 상태로 변경합니다.") @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerRunningRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("채용 공고 worker 작업 시작 상태를 반영했습니다."); } @@ -59,11 +52,9 @@ public ApiResponse markRunning( @Operation(summary = "채용 공고 worker 작업 재시도 상태 반영", description = "worker가 채용 공고 작업 실패 후 재시도 상태와 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/retry") public ApiResponse markRetry( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerRetryRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.markRetry( taskId, request.failureReason(), @@ -78,11 +69,9 @@ public ApiResponse markRetry( @Operation(summary = "채용 공고 worker 작업 완료 반영", description = "legacy worker가 조립한 최종 응답으로 taskId 기준 작업 완료 상태를 반영합니다.") @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingIngestResponse result ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 완료 상태를 반영했습니다.", jobPostingWorkerBridgeService.completeTask(taskId, result) @@ -92,11 +81,9 @@ public ApiResponse completeTask( @Operation(summary = "채용 공고 worker 결과 선저장", description = "worker가 finalize 호출 전에 taskId 기준 채용 공고 결과를 durable storage에 저장합니다.") @PostMapping("/tasks/{taskId}/result") public ApiResponse storeResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerResultStoreRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.storeFinalizeResult(taskId, request); return ApiResponse.onSuccess("채용 공고 worker 결과 선저장에 성공했습니다."); } @@ -104,10 +91,8 @@ public ApiResponse storeResult( @Operation(summary = "채용 공고 worker 저장 결과 조회", description = "worker가 taskId 기준으로 저장된 채용 공고 결과 payload를 조회합니다.") @GetMapping("/tasks/{taskId}/result") public ApiResponse getStoredResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 저장 결과 조회에 성공했습니다.", jobPostingWorkerBridgeService.getStoredResult(taskId) @@ -117,11 +102,9 @@ public ApiResponse getStoredResult( @Operation(summary = "채용 공고 worker 작업 실패 반영", description = "worker가 채용 공고 작업 실패 상태와 실패 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/failed") public ApiResponse failTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerFailureRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.failTask( taskId, request.failureReason(), @@ -136,10 +119,8 @@ public ApiResponse failTask( @Operation(summary = "채용 공고 worker 컨텍스트 조회", description = "worker가 이미지 기반 채용 공고 처리를 위해 읽기 가능한 컨텍스트 정보를 조회합니다.") @PostMapping("/ingest/context") public ApiResponse getContext( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingWorkerContextRequest request ) { - internalApiKeyValidator.validate(internalApiKey); List imageUrls = jobPostingWorkerBridgeService.createReadableImageUrls( request.userId(), request.imageObjectKey(), @@ -157,10 +138,8 @@ public ApiResponse getContext( @Operation(summary = "채용 공고 분류 후보 조회", description = "추출된 채용 공고 정보를 바탕으로 분류 후보 목록을 조회합니다.") @PostMapping("/classification/candidates") public ApiResponse> getCandidates( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingExtractResponse extracted ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 분류 후보 조회에 성공했습니다.", jobPostingWorkerBridgeService.findCandidates(extracted) @@ -170,10 +149,8 @@ public ApiResponse> getCandidate @Operation(summary = "채용 공고 적재 후처리 및 완료", description = "추출, 분류, 생성 결과를 바탕으로 채용 공고 저장과 비동기 완료 처리를 한 번에 수행하는 주 성공 callback입니다.") @PostMapping("/ingest/finalize") public ApiResponse finalizeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingWorkerFinalizeRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 저장 및 비동기 완료 처리에 성공했습니다.", jobPostingWorkerBridgeService.finalizeAndComplete( @@ -190,10 +167,8 @@ public ApiResponse finalizeTask( @Operation(summary = "채용 공고 worker 작업 상태 조회", description = "taskId 기준 채용 공고 worker 비동기 작업 상태를 내부 용도로 조회합니다.") @GetMapping("/tasks/{taskId}") public ApiResponse getTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 상태 조회에 성공했습니다.", jobPostingAsyncFacadeService.getTaskInternal(taskId) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentLogMasking.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentLogMasking.java index 83353aa1..79e59b19 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentLogMasking.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentLogMasking.java @@ -10,7 +10,6 @@ private PaymentLogMasking() { public static Map paymentContext( String orderId, - String paymentKey, Long userId, String planCode, Integer amount @@ -19,10 +18,6 @@ public static Map paymentContext( if (orderId != null) { context.put("orderId", orderId); } - String maskedPaymentKey = maskPaymentKey(paymentKey); - if (maskedPaymentKey != null) { - context.put("paymentKey", maskedPaymentKey); - } if (userId != null) { context.put("paymentUserId", String.valueOf(userId)); } @@ -35,17 +30,7 @@ public static Map paymentContext( return context; } - public static Map paymentContext(String orderId, String paymentKey, Integer amount) { - return paymentContext(orderId, paymentKey, null, null, amount); - } - - public static String maskPaymentKey(String paymentKey) { - if (paymentKey == null || paymentKey.isBlank()) { - return null; - } - if (paymentKey.length() <= 10) { - return "****"; - } - return paymentKey.substring(0, 6) + "..." + paymentKey.substring(paymentKey.length() - 4); + public static Map paymentContext(String orderId, Integer amount) { + return paymentContext(orderId, null, null, amount); } } 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 17a2b3ea..31b7db9d 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 @@ -85,7 +85,7 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) try (var ignored = LoggingContext.with( "payment.prepare.started", null, - PaymentLogMasking.paymentContext(null, null, validatedUser.getId(), request.planCode(), plan.getPrice()) + PaymentLogMasking.paymentContext(null, validatedUser.getId(), request.planCode(), plan.getPrice()) )) { log.info("Starting payment preparation"); } @@ -139,7 +139,7 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) try (var ignored = LoggingContext.with( "payment.create.completed", null, - PaymentLogMasking.paymentContext(payment.getOrderId(), null, validatedUser.getId(), plan.getCode(), plan.getPrice()) + PaymentLogMasking.paymentContext(payment.getOrderId(), validatedUser.getId(), plan.getCode(), plan.getPrice()) )) { log.info("Payment preparation completed"); } @@ -175,7 +175,7 @@ private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan pla try (var ignored = LoggingContext.with( "payment.portone.prepare.completed", null, - PaymentLogMasking.paymentContext(payment.getOrderId(), null, validatedUser.getId(), plan.getCode(), plan.getPrice()) + PaymentLogMasking.paymentContext(payment.getOrderId(), validatedUser.getId(), plan.getCode(), plan.getPrice()) )) { log.info("PortOne payment preparation completed"); } @@ -197,7 +197,6 @@ public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) User validatedUser = userService.validateUser(user); Map paymentContext = PaymentLogMasking.paymentContext( request.orderId(), - request.paymentKey(), validatedUser.getId(), null, request.amount() @@ -329,7 +328,7 @@ public void handlePortOneWebhook(String rawBody, HttpHeaders headers) { try (var ignored = LoggingContext.with( "payment.portone.webhook.ignored", e.getCode(), - PaymentLogMasking.paymentContext(payload.data().paymentId(), null, null) + PaymentLogMasking.paymentContext(payload.data().paymentId(), null) )) { log.warn("PortOne webhook ignored: {}", e.getMessage()); } @@ -343,7 +342,6 @@ public void handlePortOneWebhook(String rawBody, HttpHeaders headers) { public void handleTossPayCallback(TossPayCallbackRequest request) { Map paymentContext = PaymentLogMasking.paymentContext( request == null ? null : request.orderNo(), - request == null ? null : request.payToken(), request == null ? null : request.amount() ); try (var ignored = LoggingContext.with("payment.callback.received", null, paymentContext)) { @@ -436,7 +434,6 @@ private void validateTossResponse(Long userId, PaymentConfirmRequest request, To String easyPayProvider = response.easyPay() == null ? null : response.easyPay().provider(); Map paymentContext = PaymentLogMasking.paymentContext( request.orderId(), - request.paymentKey(), userId, null, request.amount() 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 91d84b74..1dad2352 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 @@ -85,7 +85,7 @@ public String storeId() { public PortOnePaymentResponse getPayment(String paymentId) { ensureConfigured(); ensureRequestValue(paymentId, "paymentId"); - Map paymentContext = PaymentLogMasking.paymentContext(paymentId, null, null); + Map paymentContext = PaymentLogMasking.paymentContext(paymentId, null); try (var ignored = LoggingContext.with("payment.portone.status.external_called", null, paymentContext)) { log.info("Calling PortOne get payment API"); } @@ -129,7 +129,7 @@ public PortOnePaymentResponse getPayment(String paymentId) { public PortOneCancelResponse cancelPayment(String paymentId, int amount, String reason) { ensureConfigured(); ensureRequestValue(paymentId, "paymentId"); - Map paymentContext = PaymentLogMasking.paymentContext(paymentId, null, amount); + Map paymentContext = PaymentLogMasking.paymentContext(paymentId, amount); try (var ignored = LoggingContext.with("payment.portone.refund.external_called", null, paymentContext)) { log.info("Calling PortOne cancel payment API"); } 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 c7e391b1..6fb09100 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 @@ -62,7 +62,7 @@ void init() { public TossPayCreateResponse createPayment(String orderNo, int amount, String productDesc) { ensureCreatePaymentConfigured(); - Map paymentContext = PaymentLogMasking.paymentContext(orderNo, null, amount); + Map paymentContext = PaymentLogMasking.paymentContext(orderNo, amount); try (var ignored = LoggingContext.with("payment.create.external_called", null, paymentContext)) { log.info("Calling Toss Pay create payment API"); } @@ -148,7 +148,7 @@ public TossPayRefundResponse refundPayment( String reason ) { ensureRefundConfigured(payToken, orderNo, refundNo); - Map paymentContext = PaymentLogMasking.paymentContext(orderNo, payToken, amount); + Map paymentContext = PaymentLogMasking.paymentContext(orderNo, amount); try (var ignored = LoggingContext.with("payment.tosspay.refund.external_called", null, paymentContext)) { log.info("Calling Toss Pay refund API"); } 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 fdcdbbf5..9358498d 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 @@ -57,7 +57,7 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int if (secretKey == null || secretKey.isBlank()) { throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "토스페이먼츠 시크릿 키가 설정되지 않았습니다."); } - Map paymentContext = PaymentLogMasking.paymentContext(orderId, paymentKey, amount); + Map paymentContext = PaymentLogMasking.paymentContext(orderId, amount); try (var ignored = LoggingContext.with("payment.confirm.external_called", null, paymentContext)) { log.info("Calling Toss payment confirm API"); } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java index 919495ea..22d2afb2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java @@ -9,6 +9,8 @@ import com.jobdri.jobdri_api.global.jwt.JwtAuthenticationFilter; import com.jobdri.jobdri_api.global.jwt.JwtUtil; import com.jobdri.jobdri_api.global.metrics.AuthRedisMetricsRecorder; +import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; +import com.jobdri.jobdri_api.global.security.InternalWorkerApiKeyFilter; import com.jobdri.jobdri_api.global.security.UserDetailsServiceImpl; import com.jobdri.jobdri_api.global.logging.RequestContextLoggingFilter; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.web.cors.CorsConfigurationSource; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -52,6 +55,14 @@ public PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter() { return new PortOneWebhookRateLimitFilter(); } + @Bean + public InternalWorkerApiKeyFilter internalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + ObjectMapper objectMapper + ) { + return new InternalWorkerApiKeyFilter(internalApiKeyValidator, objectMapper); + } + @Bean public RequestContextLoggingFilter requestContextLoggingFilter( @Value("${app.logging.request-id-max-length:64}") int requestIdMaxLength, @@ -87,12 +98,22 @@ public FilterRegistrationBean portOneWebhookRateL return registration; } + @Bean + public FilterRegistrationBean internalWorkerApiKeyFilterRegistration( + InternalWorkerApiKeyFilter internalWorkerApiKeyFilter + ) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(internalWorkerApiKeyFilter); + registration.setEnabled(false); + return registration; + } + @Bean public SecurityFilterChain securityFilterChain( HttpSecurity http, RequestContextLoggingFilter requestContextLoggingFilter, JwtAuthenticationFilter jwtAuthenticationFilter, - PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter + PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter, + InternalWorkerApiKeyFilter internalWorkerApiKeyFilter ) throws Exception { http.cors((cors) -> cors.configurationSource(corsConfigurationSource())); @@ -128,6 +149,7 @@ public SecurityFilterChain securityFilterChain( ); http.addFilterBefore(portOneWebhookRateLimitFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(internalWorkerApiKeyFilter, PortOneWebhookRateLimitFilter.class); http.addFilterBefore(requestContextLoggingFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterAfter(jwtAuthenticationFilter, RequestContextLoggingFilter.class); diff --git a/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java new file mode 100644 index 00000000..ea34680a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java @@ -0,0 +1,63 @@ +package com.jobdri.jobdri_api.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class InternalWorkerApiKeyFilter extends OncePerRequestFilter { + + private static final String INTERNAL_WORKER_PATH_PREFIX = "/api/internal/worker/"; + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + private final InternalApiKeyValidator internalApiKeyValidator; + private final ObjectMapper objectMapper; + + public InternalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + ObjectMapper objectMapper + ) { + this.internalApiKeyValidator = internalApiKeyValidator; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(INTERNAL_WORKER_PATH_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + internalApiKeyValidator.validate(request.getHeader(INTERNAL_API_KEY_HEADER)); + } catch (GeneralException exception) { + writeForbiddenResponse(response, exception); + return; + } + + filterChain.doFilter(request, response); + } + + private void writeForbiddenResponse(HttpServletResponse response, GeneralException exception) throws IOException { + response.setStatus(GeneralErrorCode.FORBIDDEN.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue( + response.getWriter(), + ApiResponse.onFailure( + GeneralErrorCode.FORBIDDEN, + exception.getError() != null ? exception.getError() : exception.getMessage() + ) + ); + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 6cbbb987..b351123f 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -81,6 +81,7 @@ app: logging: path: ${APP_LOG_PATH:/var/log/spring-boot} audit-path: ${APP_AUDIT_LOG_PATH:/var/log/spring-boot/audit} + environment: ${APP_LOG_ENVIRONMENT:production} oauth2: redirect-uri: ${APP_OAUTH2_REDIRECT_URI} worker: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a7e1dfbe..a909d3ce 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -18,5 +18,6 @@ app: logging: path: ${APP_LOG_PATH:logs} audit-path: ${APP_AUDIT_LOG_PATH:logs/audit} + environment: ${APP_LOG_ENVIRONMENT:local} request-id-max-length: ${APP_LOG_REQUEST_ID_MAX_LENGTH:64} trusted-proxies: ${APP_LOG_TRUSTED_PROXIES:} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index e30e645f..9d5428ca 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,6 +3,7 @@ + @@ -11,12 +12,42 @@ name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg %mdc%n%ex{full}" /> - - - ${CONSOLE_LOG_PATTERN} - UTF-8 - - + + + + Asia/Seoul + true + requestId + method + uri + logType + event + errorCode + taskId + messageId + taskType + retryCount + workerId + queueLatencyMillis + {"service":"${APP_NAME}","service_name":"${APP_NAME}","environment":"${APP_ENVIRONMENT}"} + + timestamp + logger_name + thread + stack_trace + + + + + + + + + ${CONSOLE_LOG_PATTERN} + UTF-8 + + + ${APPLICATION_LOG_FILE} @@ -29,7 +60,7 @@ Asia/Seoul true - {"service":"${APP_NAME}"} + {"service":"${APP_NAME}","service_name":"${APP_NAME}","environment":"${APP_ENVIRONMENT}"} timestamp logger_name @@ -50,7 +81,7 @@ Asia/Seoul true - {"service":"${APP_NAME}"} + {"service":"${APP_NAME}","service_name":"${APP_NAME}","environment":"${APP_ENVIRONMENT}"} timestamp logger_name 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 ed112019..6d7aa5a8 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 @@ -1733,9 +1733,12 @@ void getAnalysisReturnsEmptyMissingKeywordsWhenPersistedJsonIsMalformed() { entityManager.clear(); AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + entityManager.clear(); + Analysis persisted = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); assertThat(response.analysisId()).isEqualTo(saved.analysisId()); assertThat(response.missingKeywords()).isEmpty(); + assertThat(persisted.getMissingKeywordsJson()).isEqualTo("[]"); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java new file mode 100644 index 00000000..011d7f9a --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java @@ -0,0 +1,82 @@ +package com.jobdri.jobdri_api.global.security; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "app.worker.internal-api-key=test-internal-api-key") +@AutoConfigureMockMvc +@ActiveProfiles("test") +class InternalWorkerApiKeyFilterIntegrationTest { + + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Test + @DisplayName("내부 worker API는 헤더가 없으면 403을 반환한다") + void rejectsRequestWithoutInternalApiKey() throws Exception { + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task")) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4031")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.error").value("내부 worker 인증에 실패했습니다.")); + + verifyNoInteractions(analysisAsyncTaskService); + } + + @Test + @DisplayName("내부 worker API는 잘못된 키면 403을 반환한다") + void rejectsRequestWithInvalidInternalApiKey() throws Exception { + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task") + .header(INTERNAL_API_KEY_HEADER, "wrong-key")) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4031")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.error").value("내부 worker 인증에 실패했습니다.")); + + verifyNoInteractions(analysisAsyncTaskService); + } + + @Test + @DisplayName("내부 worker API는 올바른 키면 컨트롤러까지 요청을 전달한다") + void allowsRequestWithValidInternalApiKey() throws Exception { + when(analysisAsyncTaskService.getTaskStatusByTaskId(anyString())) + .thenReturn(AnalysisAsyncStatusResponse.builder() + .taskId("test-task") + .status("RUNNING") + .message("processing") + .build()); + + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task") + .header(INTERNAL_API_KEY_HEADER, "test-internal-api-key")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.taskId").value("test-task")) + .andExpect(jsonPath("$.result.status").value("RUNNING")); + } +}