From 8810f91773da8df965fc123714ab76e98971fd0e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:15:37 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[Refactor]=20AnalysisResponseAssembler=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/AnalysisResponseAssembler.java | 84 +++++++++++++++++++ .../AnalysisResultPersistenceService.java | 76 +++-------------- 2 files changed, 95 insertions(+), 65 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java 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..86624502 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResponseAssembler.java @@ -0,0 +1,84 @@ +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.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, + AnalysisResultPersistenceService.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..f4e51811 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 @@ -6,11 +6,9 @@ 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; @@ -32,7 +30,6 @@ 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; @@ -70,6 +67,7 @@ public class AnalysisResultPersistenceService { private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final AnalysisResponseAssembler analysisResponseAssembler; private final ObjectMapper objectMapper; @Transactional @@ -122,7 +120,13 @@ public AnalysisResponse finalizeAnalysis( lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); mockApplyRepository.flush(); - return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + return analysisResponseAssembler.toResponse( + lockedMockApply, + analysis, + questions, + questionAnalyses, + analysisResultPayload(analysis) + ); } @Transactional @@ -136,7 +140,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, @@ -151,7 +155,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy List questionAnalyses = questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - return toResponse( + return analysisResponseAssembler.toResponse( mockApply, analysis, questions, @@ -317,64 +321,6 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) .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( @@ -650,7 +596,7 @@ private Set normalizedHighlightQuotes(List hi .collect(Collectors.toSet()); } - private record AnalysisResultPayload( + record AnalysisResultPayload( List keyStrengths, List keyWeaknesses, List missingKeywords From fed079863ebb87d286c7dd25c94ca23671c8a182 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:18:51 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[Refactor]=20Analysis=20payload=20sanitiz?= =?UTF-8?q?ation=20=EC=A0=84=EC=9A=A9=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/AnalysisResponseAssembler.java | 3 +- .../AnalysisResultPersistenceService.java | 329 +----------------- .../AnalysisResultSanitizationService.java | 327 +++++++++++++++++ 3 files changed, 347 insertions(+), 312 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java 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 index 86624502..ef9a3129 100644 --- 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 @@ -6,6 +6,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.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; @@ -29,7 +30,7 @@ AnalysisResponse toResponse( Analysis analysis, List questions, List questionAnalyses, - AnalysisResultPersistenceService.AnalysisResultPayload resultPayload + AnalysisResultSanitizationService.AnalysisResultPayload resultPayload ) { Map questionById = questions.stream() .collect(Collectors.toMap(Question::getId, Function.identity())); 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 f4e51811..a469f6d5 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,14 +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.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.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; @@ -16,15 +12,14 @@ 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; @@ -44,31 +39,20 @@ 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 AnalysisResponseAssembler analysisResponseAssembler; - private final ObjectMapper objectMapper; + private final AnalysisResultSanitizationService analysisResultSanitizationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -88,9 +72,14 @@ public AnalysisResponse finalizeAnalysis( 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( + List keyStrengths = analysisResultSanitizationService.buildHighlights( + llmResponse.keyStrengths() + ); + List keyWeaknesses = analysisResultSanitizationService.buildNonOverlappingHighlights( + llmResponse.keyWeaknesses(), + keyStrengths + ); + List missingKeywords = analysisResultSanitizationService.buildMissingKeywords( lockedMockApply.getJobPosting(), answerSnapshot.combinedAnswers(), llmResponse @@ -104,9 +93,9 @@ public AnalysisResponse finalizeAnalysis( impact, completeness, normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords), - serializeHighlights(keyStrengths, "keyStrengths"), - serializeHighlights(keyWeaknesses, "keyWeaknesses"), + analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), + analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), + analysisResultSanitizationService.serializeHighlights(keyWeaknesses, "keyWeaknesses"), inputFingerprint )); @@ -125,7 +114,7 @@ public AnalysisResponse finalizeAnalysis( analysis, questions, questionAnalyses, - analysisResultPayload(analysis) + analysisResultSanitizationService.analysisResultPayload(analysis) ); } @@ -145,7 +134,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, true) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, true) ); } @@ -160,7 +149,7 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analy analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis, false) + analysisResultSanitizationService.sanitizeAndPersistAnalysisPayload(analysis, false) ); } @@ -274,7 +263,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(); @@ -317,292 +306,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 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()); - } - - record AnalysisResultPayload( - List keyStrengths, - List keyWeaknesses, - List missingKeywords - ) { - } - private void validateRequiredScores(AnalysisLlmResponse llmResponse) { if (llmResponse == null || llmResponse.jobFit() == null 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..5767e07d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java @@ -0,0 +1,327 @@ +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 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"); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()))) { + analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); + } + 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 + ) { + } +} From d83ff0137f57a24ce6da87a7977be0d5927c3f99 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:21:43 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[Refactor]=20Analysis=20result=20validati?= =?UTF-8?q?on/normalization=20=EB=B6=84=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisResultPersistenceService.java | 111 +++-------------- .../core/AnalysisResultValidationService.java | 112 ++++++++++++++++++ 2 files changed, 126 insertions(+), 97 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java 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 a469f6d5..1e876b90 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 @@ -27,7 +27,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,8 +38,6 @@ 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_SCORE; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; @Service @RequiredArgsConstructor @@ -50,9 +47,9 @@ public class AnalysisResultPersistenceService { private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; private final QuestionAnalysisRepository questionAnalysisRepository; - private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final AnalysisResponseAssembler analysisResponseAssembler; private final AnalysisResultSanitizationService analysisResultSanitizationService; + private final AnalysisResultValidationService analysisResultValidationService; @Transactional public AnalysisResponse finalizeAnalysis( @@ -67,11 +64,12 @@ 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()); + AnalysisResultValidationService.VerifiedAnswerSnapshot answerSnapshot = + analysisResultValidationService.verifyAnswerSnapshot(questions, payloadSnapshots); + analysisResultValidationService.validateRequiredScores(llmResponse); + int jobFit = analysisResultValidationService.validateScore("jobFit", llmResponse.jobFit()); + int impact = analysisResultValidationService.validateScore("impact", llmResponse.impact()); + int completeness = analysisResultValidationService.validateScore("completeness", llmResponse.completeness()); List keyStrengths = analysisResultSanitizationService.buildHighlights( llmResponse.keyStrengths() ); @@ -92,7 +90,7 @@ public AnalysisResponse finalizeAnalysis( jobFit, impact, completeness, - normalizeFeedback(llmResponse.feedback()), + analysisResultValidationService.normalizeFeedback(llmResponse.feedback()), analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), analysisResultSanitizationService.serializeHighlights(keyWeaknesses, "keyWeaknesses"), @@ -166,49 +164,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, @@ -290,7 +245,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() @@ -310,28 +270,6 @@ private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) .collect(Collectors.toSet()); } - 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 @@ -348,31 +286,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..291d6be9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultValidationService.java @@ -0,0 +1,112 @@ +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 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")); + } + } +} From 8d11a159885e51399103306ebf77333c71ccbac4 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:24:11 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[Refactor]=20AnalysisResultPersistenceSer?= =?UTF-8?q?vice=20=EC=A0=80=EC=9E=A5=20=EC=B1=85=EC=9E=84=EB=A7=8C=20?= =?UTF-8?q?=EB=82=A8=EB=8F=84=EB=A1=9D=20=EC=B6=95=EC=86=8C=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisResultPersistenceService.java | 48 ++++++++----------- .../core/AnalysisResultValidationService.java | 25 ++++++++++ .../AnalysisResultSanitizationService.java | 20 ++++++++ 3 files changed, 66 insertions(+), 27 deletions(-) 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 1e876b90..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 @@ -64,43 +64,37 @@ public AnalysisResponse finalizeAnalysis( GeneralErrorCode.MOCK_APPLY_NOT_FOUND, "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApply.getId() )); - AnalysisResultValidationService.VerifiedAnswerSnapshot answerSnapshot = - analysisResultValidationService.verifyAnswerSnapshot(questions, payloadSnapshots); - analysisResultValidationService.validateRequiredScores(llmResponse); - int jobFit = analysisResultValidationService.validateScore("jobFit", llmResponse.jobFit()); - int impact = analysisResultValidationService.validateScore("impact", llmResponse.impact()); - int completeness = analysisResultValidationService.validateScore("completeness", llmResponse.completeness()); - List keyStrengths = analysisResultSanitizationService.buildHighlights( - llmResponse.keyStrengths() - ); - List keyWeaknesses = analysisResultSanitizationService.buildNonOverlappingHighlights( - llmResponse.keyWeaknesses(), - keyStrengths - ); - List missingKeywords = analysisResultSanitizationService.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, - analysisResultValidationService.normalizeFeedback(llmResponse.feedback()), - analysisResultSanitizationService.serializeMissingKeywords(missingKeywords), - analysisResultSanitizationService.serializeHighlights(keyStrengths, "keyStrengths"), - analysisResultSanitizationService.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); 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 index 291d6be9..c07f2151 100644 --- 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 @@ -24,6 +24,22 @@ 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 @@ -109,4 +125,13 @@ public String combinedAnswers() { .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/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java index 5767e07d..45eec2db 100644 --- 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 @@ -38,6 +38,19 @@ public class AnalysisResultSanitizationService { 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, @@ -324,4 +337,11 @@ public record AnalysisResultPayload( List missingKeywords ) { } + + public record SanitizedAnalysisContent( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } } From dd17a1f54a11f2b9f4a0b42433899a9d687d32de Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:25:25 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[Refactor]=20AnalysisService=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20API=20=EC=A0=95=EB=A6=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncWorkerBridge.java | 9 +++-- .../service/core/AnalysisService.java | 40 ++++++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) 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 index b742e801..849726f9 100644 --- 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 @@ -148,7 +148,10 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long return contextAccess.snapshot(); } - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); + AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( + userService.getUser(userId), + mockApplyId + ); AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); String contextSnapshot = writeContextSnapshot(context); String inputFingerprint = analysisInputFingerprintProvider.create(payload); @@ -211,7 +214,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques User user = userService.getUser(request.userId()); AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( + AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( user, request.mockApplyId(), contextSnapshot.similarJobPostings() @@ -223,7 +226,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques )) .toList()); AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.finalizeAnalysis( + AnalysisResponse response = analysisService.completeAsyncAnalysis( user, request.mockApplyId(), payload, 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..e70fec73 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 @@ -89,12 +89,24 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { - return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); + public AnalysisExecutionPayload prepareAsyncAnalysisExecution(User user, Long mockApplyId) { + return prepareAnalysisExecution(user, mockApplyId); } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAnalysisExecution( + public AnalysisExecutionPayload prepareAsyncAnalysisExecution( + User user, + Long mockApplyId, + List similarJobPostings + ) { + return prepareAnalysisExecution(user, mockApplyId, similarJobPostings); + } + + private AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); + } + + private AnalysisExecutionPayload prepareAnalysisExecution( User user, Long mockApplyId, List similarJobPostings @@ -102,24 +114,35 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { + private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisGenerator.analyze(payload); } @Transactional - public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + private AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { MockApply mockApply = lockOwnedMockApply(user, mockApplyId); return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } @Transactional - public AnalysisResponse finalizeAnalysis( + public AnalysisResponse completeAsyncAnalysis( + User user, + Long mockApplyId, + AnalysisExecutionPayload payload, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + return persistAnalysis(user, mockApplyId, payload, llmResponse, inputFingerprint); + } + + @Transactional + private AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, AnalysisLlmResponse llmResponse ) { - return finalizeAnalysis( + return persistAnalysis( user, mockApplyId, payload, @@ -128,8 +151,7 @@ public AnalysisResponse finalizeAnalysis( ); } - @Transactional - public AnalysisResponse finalizeAnalysis( + private AnalysisResponse persistAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, From 7c02ce8e28f52eec5c4ab9d959d725b10dcfb4d0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:38:23 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[Fix]=20AnalysisService=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20API=20=ED=98=B8=ED=99=98=EC=84=B1=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20(#279)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncWorkerBridge.java | 9 +++---- .../service/core/AnalysisService.java | 24 +++++-------------- 2 files changed, 9 insertions(+), 24 deletions(-) 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 index 849726f9..b742e801 100644 --- 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 @@ -148,10 +148,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long return contextAccess.snapshot(); } - AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( - userService.getUser(userId), - mockApplyId - ); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); String contextSnapshot = writeContextSnapshot(context); String inputFingerprint = analysisInputFingerprintProvider.create(payload); @@ -214,7 +211,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques User user = userService.getUser(request.userId()); AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAsyncAnalysisExecution( + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( user, request.mockApplyId(), contextSnapshot.similarJobPostings() @@ -226,7 +223,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques )) .toList()); AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.completeAsyncAnalysis( + AnalysisResponse response = analysisService.finalizeAnalysis( user, request.mockApplyId(), payload, 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 e70fec73..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 @@ -89,24 +89,12 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { } @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAsyncAnalysisExecution(User user, Long mockApplyId) { - return prepareAnalysisExecution(user, mockApplyId); - } - - @Transactional(readOnly = true) - public AnalysisExecutionPayload prepareAsyncAnalysisExecution( - User user, - Long mockApplyId, - List similarJobPostings - ) { - return prepareAnalysisExecution(user, mockApplyId, similarJobPostings); - } - - private AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { + public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); } - private AnalysisExecutionPayload prepareAnalysisExecution( + @Transactional(readOnly = true) + public AnalysisExecutionPayload prepareAnalysisExecution( User user, Long mockApplyId, List similarJobPostings @@ -119,13 +107,13 @@ private AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { } @Transactional - private AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { MockApply mockApply = lockOwnedMockApply(user, mockApplyId); return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); } @Transactional - public AnalysisResponse completeAsyncAnalysis( + public AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, @@ -136,7 +124,7 @@ public AnalysisResponse completeAsyncAnalysis( } @Transactional - private AnalysisResponse finalizeAnalysis( + public AnalysisResponse finalizeAnalysis( User user, Long mockApplyId, AnalysisExecutionPayload payload, From 70b2ec0ecdb7bd78c581e96419aab7cde4ca069a Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Thu, 13 Aug 2026 13:40:42 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobdri_api/domain/analysis/entity/Analysis.java | 9 +++++++++ .../AnalysisResultSanitizationService.java | 10 ++++++++-- .../analysis/service/core/AnalysisServiceTest.java | 3 +++ 3 files changed, 20 insertions(+), 2 deletions(-) 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/sanitization/AnalysisResultSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/AnalysisResultSanitizationService.java index 45eec2db..1925c12b 100644 --- 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 @@ -71,10 +71,16 @@ public AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis 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()))) { - analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()) + || !sanitizedMissingKeywordsJson.equals(analysis.getMissingKeywordsJson()))) { + analysis.updateResultPayloadJson( + sanitizedKeyStrengthsJson, + sanitizedKeyWeaknessesJson, + sanitizedMissingKeywordsJson + ); } return payload; } 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 From 973031ada81ab0ff2110959287b3ccf6b65ad809 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 15 Aug 2026 21:56:11 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[Fix]=20=EB=82=B4=EB=B6=80=20worker=20API?= =?UTF-8?q?=20=ED=82=A4=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A1=9C=20=EC=9D=B4=EC=A0=84=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 24 +++++++++- .../security/InternalWorkerApiKeyFilter.java | 47 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java 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..474a9592 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 org.springframework.web.servlet.HandlerExceptionResolver; import java.util.List; @@ -52,6 +55,14 @@ public PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter() { return new PortOneWebhookRateLimitFilter(); } + @Bean + public InternalWorkerApiKeyFilter internalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + HandlerExceptionResolver handlerExceptionResolver + ) { + return new InternalWorkerApiKeyFilter(internalApiKeyValidator, handlerExceptionResolver); + } + @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..998f2711 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java @@ -0,0 +1,47 @@ +package com.jobdri.jobdri_api.global.security; + +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 org.springframework.web.servlet.HandlerExceptionResolver; + +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 HandlerExceptionResolver handlerExceptionResolver; + + public InternalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + HandlerExceptionResolver handlerExceptionResolver + ) { + this.internalApiKeyValidator = internalApiKeyValidator; + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @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)); + filterChain.doFilter(request, response); + } catch (GeneralException exception) { + handlerExceptionResolver.resolveException(request, response, null, exception); + } + } +} From c19f95ba7979a52cd04c90d5db31751235464a6c Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 15 Aug 2026 21:57:29 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[Refactor]=20=EB=82=B4=EB=B6=80=20worker?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisWorkerInternalController.java | 21 ---------------- .../JobPostingWorkerInternalController.java | 25 ------------------- 2 files changed, 46 deletions(-) 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/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) From 0286f2f4a275201525a022f5b4addb52d185aaf5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 15 Aug 2026 22:00:03 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[Fix]=20=EB=82=B4=EB=B6=80=20worker=20API?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 6 +- .../security/InternalWorkerApiKeyFilter.java | 24 ++++-- ...rnalWorkerApiKeyFilterIntegrationTest.java | 78 +++++++++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java 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 474a9592..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 @@ -26,7 +26,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.servlet.HandlerExceptionResolver; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -58,9 +58,9 @@ public PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter() { @Bean public InternalWorkerApiKeyFilter internalWorkerApiKeyFilter( InternalApiKeyValidator internalApiKeyValidator, - HandlerExceptionResolver handlerExceptionResolver + ObjectMapper objectMapper ) { - return new InternalWorkerApiKeyFilter(internalApiKeyValidator, handlerExceptionResolver); + return new InternalWorkerApiKeyFilter(internalApiKeyValidator, objectMapper); } @Bean 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 index 998f2711..e65cc190 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java +++ b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java @@ -1,12 +1,14 @@ 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 org.springframework.web.servlet.HandlerExceptionResolver; import java.io.IOException; @@ -16,14 +18,14 @@ public class InternalWorkerApiKeyFilter extends OncePerRequestFilter { private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; private final InternalApiKeyValidator internalApiKeyValidator; - private final HandlerExceptionResolver handlerExceptionResolver; + private final ObjectMapper objectMapper; public InternalWorkerApiKeyFilter( InternalApiKeyValidator internalApiKeyValidator, - HandlerExceptionResolver handlerExceptionResolver + ObjectMapper objectMapper ) { this.internalApiKeyValidator = internalApiKeyValidator; - this.handlerExceptionResolver = handlerExceptionResolver; + this.objectMapper = objectMapper; } @Override @@ -41,7 +43,19 @@ protected void doFilterInternal( internalApiKeyValidator.validate(request.getHeader(INTERNAL_API_KEY_HEADER)); filterChain.doFilter(request, response); } catch (GeneralException exception) { - handlerExceptionResolver.resolveException(request, response, null, exception); + writeForbiddenResponse(response, exception); } } + + 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/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..ecb2843e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java @@ -0,0 +1,78 @@ +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.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.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(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(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")); + } +} From 3d0be2e952498f3180e2e88726e48b46fdd52cc5 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Sat, 15 Aug 2026 22:18:08 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[Fix]=20=EB=82=B4=EB=B6=80=20worker=20API?= =?UTF-8?q?=20=ED=82=A4=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A1=9C=20=EC=9D=B4=EC=A0=84=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/InternalWorkerApiKeyFilter.java | 4 +++- .../security/InternalWorkerApiKeyFilterIntegrationTest.java | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 index e65cc190..ea34680a 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java +++ b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java @@ -41,10 +41,12 @@ protected void doFilterInternal( ) throws ServletException, IOException { try { internalApiKeyValidator.validate(request.getHeader(INTERNAL_API_KEY_HEADER)); - filterChain.doFilter(request, response); } catch (GeneralException exception) { writeForbiddenResponse(response, exception); + return; } + + filterChain.doFilter(request, response); } private void writeForbiddenResponse(HttpServletResponse response, GeneralException exception) throws IOException { 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 index ecb2843e..011d7f9a 100644 --- a/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java +++ b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java @@ -7,6 +7,7 @@ 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; @@ -15,6 +16,7 @@ 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; @@ -36,6 +38,7 @@ class InternalWorkerApiKeyFilterIntegrationTest { 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("접근 권한이 없습니다.")) @@ -50,6 +53,7 @@ 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("접근 권한이 없습니다.")) From 44400da804e5ad1deea314a86c6339b9ed7b6b21 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 7 Sep 2026 13:35:20 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[Feat]=20Grafana=20Cloud=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=B0=8F=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prod 프로필의 Spring Boot 콘솔 로그를 JSON 형식으로 변경 - 로그에 service, service_name, environment 공통 필드 추가 - 개발 환경에서는 기존 패턴 기반 콘솔 로그 유지 - API Actuator 포트를 127.0.0.1:19090으로 제한 노출 - 분석 워커 metrics 포트를 127.0.0.1:18000으로 제한 노출 - 기존 로컬 Prometheus와 병행 운영할 수 있도록 포트 충돌 방지 --- docker-compose.prod.yml | 3 ++ src/main/resources/application-prod.yaml | 1 + src/main/resources/application.yaml | 1 + src/main/resources/logback-spring.xml | 35 ++++++++++++++++++------ 4 files changed, 32 insertions(+), 8 deletions(-) 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/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..cdab5e20 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,6 +3,7 @@ + @@ -11,12 +12,30 @@ 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 + {"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 +48,7 @@ Asia/Seoul true - {"service":"${APP_NAME}"} + {"service":"${APP_NAME}","service_name":"${APP_NAME}","environment":"${APP_ENVIRONMENT}"} timestamp logger_name @@ -50,7 +69,7 @@ Asia/Seoul true - {"service":"${APP_NAME}"} + {"service":"${APP_NAME}","service_name":"${APP_NAME}","environment":"${APP_ENVIRONMENT}"} timestamp logger_name From 0313dfd74532c9a65c95efc4eb02087d23e52da3 Mon Sep 17 00:00:00 2001 From: wooh Date: Mon, 7 Sep 2026 13:53:34 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[Fix]=20=EC=9A=B4=EC=98=81=20=EC=BD=98?= =?UTF-8?q?=EC=86=94=20=EB=A1=9C=EA=B7=B8=EC=97=90=EC=84=9C=20=EB=AF=BC?= =?UTF-8?q?=EA=B0=90=ED=95=9C=20MDC=20=EC=A0=95=EB=B3=B4=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grafana Cloud로 전송되는 prod JSON 콘솔 로그에 MDC 안전 allowlist 적용 - paymentKey, clientIp, userId 등 민감 식별 정보의 콘솔 출력을 차단 - 부분 마스킹된 paymentKey도 결제 MDC 컨텍스트에서 완전히 제거 - 결제 로그 컨텍스트 생성 API와 호출부를 민감정보 미수집 구조로 정리 - 로컬 감사 로그의 기존 식별 정보와 인증 요청 제한 동작은 유지 --- .../payment/service/PaymentLogMasking.java | 19 ++----------------- .../payment/service/PaymentService.java | 11 ++++------- .../domain/payment/service/PortOneClient.java | 4 ++-- .../domain/payment/service/TossPayClient.java | 4 ++-- .../payment/service/TossPaymentClient.java | 2 +- src/main/resources/logback-spring.xml | 12 ++++++++++++ 6 files changed, 23 insertions(+), 29 deletions(-) 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/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index cdab5e20..9d5428ca 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -17,6 +17,18 @@ 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