diff --git a/.env.example b/.env.example index 70626fc4..092ab7e4 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ APP_AUDIT_LOG_PATH=logs/audit TOSS_CLIENT_KEY=change-me TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com +PAYMENT_COUPON_CODE=ABCD-EFGH-IJKL JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65 APP_WORKER_EXCHANGE=jobdri.worker.exchange diff --git a/.env.production.example b/.env.production.example index 2407dad8..37bd8343 100644 --- a/.env.production.example +++ b/.env.production.example @@ -48,6 +48,7 @@ APP_AUDIT_LOG_PATH=/var/log/spring-boot/audit TOSS_CLIENT_KEY=change-me TOSS_SECRET_KEY=change-me TOSS_BASE_URL=https://api.tosspayments.com +PAYMENT_COUPON_CODE=ABCD-EFGH-IJKL AWS_REGION=ap-northeast-2 S3_BUCKET=your-s3-bucket diff --git a/evaluation/evaluation_nlg_judge_hybrid_comparison.csv b/evaluation/evaluation_nlg_judge_hybrid_comparison.csv new file mode 100644 index 00000000..bbe43906 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_comparison.csv @@ -0,0 +1,4 @@ +sourceResultFile,caseCount,successCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_v5a_v2.csv,20,20,3.06,2.75,3.75,3.19,2.06,4.0,4.13,3.81,3.69,4.06,2.05,4.45,4.45,3.45,3.45,2.75,,,4214.25,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":18,""MISSED_MISSING_KEYWORD"":10,""NONE"":1}" +evaluation/evaluation_nlg_judge_two_pass_v2_1_v2.csv,20,20,3.33,2.5,3.5,3.33,2.17,4.0,4.17,3.5,3.33,4.0,1.85,4.14,4.14,4.07,4.07,2.6,,,3821.65,0.45,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":2,""MISSED_ANALYSIS"":19,""MISSED_MISSING_KEYWORD"":2,""MISSED_STRENGTH"":2,""NONE"":1}" +evaluation/evaluation_nlg_judge_hybrid_v1.csv,20,20,3.17,2.75,3.58,3.17,2.0,3.92,4.08,3.67,4.0,4.08,2.0,3.55,3.55,1.95,1.95,2.1,3617.55,235.75,3506.55,0.65,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":1,""MISSED_ANALYSIS"":20,""MISSED_MISSING_KEYWORD"":20}" diff --git a/evaluation/evaluation_nlg_judge_hybrid_exact_comparison.csv b/evaluation/evaluation_nlg_judge_hybrid_exact_comparison.csv new file mode 100644 index 00000000..fb13a939 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_exact_comparison.csv @@ -0,0 +1,4 @@ +sourceResultFile,caseCount,successCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_v5a_v2.csv,20,20,3.06,2.75,3.75,3.19,2.06,4.0,4.13,3.81,3.69,4.06,2.05,4.45,4.45,3.45,3.45,2.75,,,4214.25,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":18,""MISSED_MISSING_KEYWORD"":10,""NONE"":1}" +evaluation/evaluation_nlg_judge_two_pass_v2_1_v2.csv,20,20,3.33,2.5,3.5,3.33,2.17,4.0,4.17,3.5,3.33,4.0,1.85,4.14,4.14,4.07,4.07,2.6,,,3821.65,0.45,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":2,""MISSED_ANALYSIS"":19,""MISSED_MISSING_KEYWORD"":2,""MISSED_STRENGTH"":2,""NONE"":1}" +evaluation/evaluation_nlg_judge_hybrid_exact_offline.csv,20,20,3.31,3.0,4.19,3.69,2.19,4.38,4.38,3.94,4.19,4.38,2.6,5.0,5.0,5.0,5.0,3.15,3477.65,255.1,3143.9,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":9,""MISSED_ANALYSIS"":13,""NONE"":4}" diff --git a/evaluation/evaluation_nlg_judge_hybrid_exact_offline.csv b/evaluation/evaluation_nlg_judge_hybrid_exact_offline.csv new file mode 100644 index 00000000..777d94c2 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_exact_offline.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","답변에 명백한 문제 문장이 없으나, questionAnalyses가 비어 있어 중요한 첨삭 대상을 놓쳤다고 판단됩니다.",3428,132,3744, +EV-02,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,4.0,3.0,5.0,4.0,2.0,5.0,5.0,5.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","상세페이지 제작 경험은 언급되었으나, 소비자 구매 결정 요소에 대한 구체적인 설명이 부족하여 문맥을 무시한 평가가 있었습니다. 또한, 명백한 첨삭 대상이 존재함에도 불구하고 분석이 부족했습니다.",2634,310,3468, +EV-03,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,5.0,3.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석 결과, 강점 및 핵심역량에 대한 구체적인 경험이 부족하여 문맥을 무시한 평가가 이루어졌습니다. 전반적으로 유용성은 중간 수준입니다.",4016,296,3329, +EV-04,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 지원자의 경험과 목표가 잘 연결되어 있으며, 주요 강점이 명확하게 드러납니다. 다만, 일부 문장에 대한 구체성이 부족하여 개선 여지가 있습니다.",4744,272,3271, +EV-05,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,3.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 중요한 문제를 놓쳤습니다. 문장 내용은 적절하나, 구체적인 설명이 부족해 평가가 낮아졌습니다.",3031,291,3717, +EV-06,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 명백한 문제 문장을 놓쳤고, 문맥을 무시한 평가가 있었습니다. 전반적으로 유용성은 중간 수준입니다.",5598,306,2937, +EV-07,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",답변에 명백한 문제 문장이 있음에도 불구하고 분석이 전혀 이루어지지 않아 중요한 첨삭 대상을 놓쳤습니다.,2434,101,2046, +EV-08,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,0,,,,,,,,,,,2,5,5,5,5,2,"[""MISSED_ANALYSIS""]","지원자의 경험과 역량이 잘 드러나지 않았고, 명백한 첨삭 대상이 존재함에도 분석이 없었습니다. 이로 인해 전반적인 유용성이 낮게 평가되었습니다.",3991,145,2969, +EV-09,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","지원자의 경험과 계획이 잘 드러나 있으며, 전반적으로 적절한 분석이 이루어졌습니다. 다만, 구체적인 실행 방법이 부족한 점이 아쉽습니다.",3387,291,3379, +EV-10,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,4.0,3.0,4.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기와 포부에 대한 구체적인 방법이 부족하여 분석이 미흡했습니다. 그러나 전반적으로 강점이 잘 드러나고 있습니다.,3404,300,3121, +EV-11,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,5.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 경험과 동기는 명확하나, 분석에서 중요한 문제를 놓쳤습니다. 문맥을 고려하지 않은 평가가 있어 전반적인 유용성이 낮아졌습니다.",3364,307,3018, +EV-12,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 문맥을 무시한 부분이 있어 평가가 낮아졌습니다.,3639,285,3343, +EV-13,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,4.0,3.0,4.0,4,5,5,5,5,4,"[""CONTEXT_IGNORED""]","전반적으로 경험이 잘 드러나지만, 품질 관리 방법에 대한 구체성이 부족하여 문맥을 간과한 부분이 있습니다.",3361,283,4128, +EV-14,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","지원자의 경험과 성과가 잘 드러나 있으며, 분석이 필요할 만한 문장이 없으므로 전반적으로 유용한 자기소개서입니다.",3471,126,1854, +EV-15,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원 동기는 설명되었으나 구체적인 경험이 부족하여 설득력이 떨어지며, 중요한 첨삭 대상을 놓쳤습니다.",2949,278,3540, +EV-16,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 명백한 문제를 놓쳤고, 개선 방법이 구체적이지 않아 평가가 낮아졌습니다.",3763,283,2796, +EV-17,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,3,5,5,5,5,3,"[""NONE""]","전반적으로 경험과 강점을 잘 서술했으나, 구체적인 실행 계획이 부족하여 개선 여지가 있습니다.",2939,247,3379, +EV-18,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,4.0,2.0,5.0,5.0,4.0,5.0,5.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 중요한 첨삭 대상을 놓쳤고, 문맥을 무시한 부분이 있어 평가가 낮아졌습니다.",2945,276,2879, +EV-19,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,3.0,3.0,4.0,4,5,5,5,5,4,"[""CONTEXT_IGNORED""]","지원자의 경험과 QA에 대한 이해는 긍정적이나, 일부 문장 분석에서 문맥을 간과한 부분이 있어 개선이 필요합니다.",2975,284,2647, +EV-20,evaluation/evaluation_ai_results_hybrid_exact_offline.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,3.0,3.0,4.0,3,5,5,5,5,3,"[""CONTEXT_IGNORED""]","지원자의 목표와 포부는 명확하나, 구체적인 실행 방안이 부족하여 문맥을 간과한 평가가 있었습니다. 전반적으로 유용성은 중간 수준입니다.",3480,289,3313, diff --git a/evaluation/evaluation_nlg_judge_hybrid_exact_rejudge_comparison.csv b/evaluation/evaluation_nlg_judge_hybrid_exact_rejudge_comparison.csv new file mode 100644 index 00000000..823e4ff4 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_exact_rejudge_comparison.csv @@ -0,0 +1,4 @@ +sourceResultFile,caseCount,successCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,averageJudgeInputTokens,averageJudgeOutputTokens,averageJudgeLatencyMs,averageAnalysisCount,metaImprovementRate,unsupportedFactRate,falsePositiveAnalysisRate,fatalErrorRate,errorCodeCounts +evaluation/evaluation_nlg_judge_v5a_exact_rejudge.csv,20,20,3.07,2.93,3.8,3.2,2.07,4.07,4.07,3.8,3.67,4.07,2.25,4.35,4.35,3.4,3.4,2.75,3492.45,255.9,3021.6,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":4,""MISSED_ANALYSIS"":16,""MISSED_MISSING_KEYWORD"":10,""NONE"":2}" +evaluation/evaluation_nlg_judge_two_pass_exact_rejudge.csv,20,19,3.5,2.67,3.33,3.5,2.33,3.83,4.0,3.33,3.5,3.83,1.95,3.67,3.67,3.86,3.93,2.63,3924.26,202.95,2477.37,0.47,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":2,""MISSED_ANALYSIS"":18,""MISSED_MISSING_KEYWORD"":2,""MISSED_STRENGTH"":2,""NONE"":1}" +evaluation/evaluation_nlg_judge_hybrid_exact_offline.csv,20,20,3.31,3.0,4.19,3.69,2.19,4.38,4.38,3.94,4.19,4.38,2.6,5.0,5.0,5.0,5.0,3.15,3477.65,255.1,3143.9,0.8,0.0,0.0,0.0,0.0,"{""CONTEXT_IGNORED"":9,""MISSED_ANALYSIS"":13,""NONE"":4}" diff --git a/evaluation/evaluation_nlg_judge_hybrid_exact_runtime.csv b/evaluation/evaluation_nlg_judge_hybrid_exact_runtime.csv new file mode 100644 index 00000000..c2098173 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_exact_runtime.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,0,,,,,,,,,,,2,4,4,5,5,3,"[""MISSED_ANALYSIS""]","답변에서 명확한 문제 문장이 없으나, questionAnalyses가 비어 있어 중요한 첨삭 대상을 놓쳤습니다.",3969,130,3817, +EV-02,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,3,3,2,2,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 잘 드러나지 않고, 구체적인 방법론이 부족하여 개선이 필요합니다. 또한, 누락된 키워드가 있어 전체적인 유용성이 낮습니다.",3238,288,2722, +EV-03,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,3.0,3.0,3.0,3.0,2,5,5,5,5,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석 결과에 명백한 문제 문장이 존재하지만, 적절한 분석이 이루어지지 않아 주요 점수가 낮게 평가되었습니다.",4721,204,3274, +EV-04,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,3.0,3.0,3.0,3.0,2,,,,,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 구체적인 행동과 실행 계획이 결여되어 있습니다.,5241,194,2760, +EV-05,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,4.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,1,1,1,1,3,"[""MISSED_ANALYSIS""]","지원 동기와 포부는 전반적으로 잘 작성되었으나, 구체적인 실행 방법이 부족하고 누락된 키워드가 있어 개선이 필요합니다.",3578,294,3071, +EV-06,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,0,,,,,,,,,,,1,5,5,5,5,2,"[""MISSED_ANALYSIS""]",자기소개서에서 명백한 문제 문장이 존재하지만 분석이 비어 있어 중요한 첨삭 대상을 놓쳤습니다. 구체적인 성과와 방법론을 추가하여 더욱 강력한 자기소개서를 만드세요.,6056,117,1840, +EV-07,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 구체적인 성과와 방법을 추가하면 더 좋습니다.,2996,278,5634, +EV-08,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,4.0,2.0,3.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험과 성과는 잘 드러났으나, 구체적인 실행 계획이 부족하여 중요한 문제를 놓쳤습니다.",4611,201,2553, +EV-09,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,4.0,3.0,4.0,3.0,3.0,4.0,4.0,4.0,4.0,4.0,3,5,5,5,5,3,"[""NONE""]","지원자의 경험이 잘 드러나지만, 구체적인 행동과 결과 설명이 부족하여 개선 여지가 있습니다.",3977,278,3384, +EV-10,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원자의 포부는 잘 드러나지만 구체적인 실행 계획이 부족하여 평가가 낮아졌습니다.,4086,270,3275, +EV-11,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,2,2,1,1,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원 동기와 포부는 명확하나 구체적인 실행 계획이 부족하여 전반적인 분석이 미흡합니다.,3839,284,4826, +EV-12,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 중요한 첨삭 대상을 놓쳤고, 구체적인 행동 계획이 결여되어 있습니다. 전반적으로 유용성은 중간 수준입니다.",4328,295,3478, +EV-13,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,4.0,3.0,3.0,3.0,3.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험이 잘 드러나지만, 구체적인 행동과 방법이 부족하여 개선이 필요합니다.",3824,252,2650, +EV-14,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-15,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,1,1,,,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원 동기와 직무 적합성 설명이 부족하여 구체적인 경험이 결여된 점이 아쉽습니다. 전반적으로 개선이 필요합니다.,3470,350,4404, +EV-16,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","경험을 구체적으로 서술하였으나, 성과와 방법에 대한 추가 설명이 필요합니다. 문맥을 고려하지 않은 분석이 있어 개선이 필요합니다.",4463,302,4396, +EV-17,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,2.0,2.0,2.0,2.0,2.0,3.0,3.0,3.0,3.0,3.0,2,,,,,2,"[""MISSED_ANALYSIS""]",지원 동기에서 구체적인 행동이나 방법이 부족하여 명백한 문제 문장이 존재합니다. 이로 인해 전반적인 유용성이 낮게 평가되었습니다.,3365,199,2668, +EV-18,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 구체적인 실행 계획과 성과가 결여된 점이 문제로 지적되었습니다. 문맥을 고려하지 않은 평가가 있었습니다.,3403,317,3480, +EV-19,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 QA 직무에 대한 이해는 높지만, 구체적인 행동과 방법이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",3467,131,1808, +EV-20,evaluation/evaluation_ai_results_hybrid_exact_runtime.csv,1,2.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",지원 동기와 포부의 구체성이 부족하여 문맥을 무시한 분석이 발생했습니다. 전반적으로 개선이 필요합니다.,3995,203,3047, diff --git a/evaluation/evaluation_nlg_judge_hybrid_v1.csv b/evaluation/evaluation_nlg_judge_hybrid_v1.csv new file mode 100644 index 00000000..0b6fd492 --- /dev/null +++ b/evaluation/evaluation_nlg_judge_hybrid_v1.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","답변에 명백한 문제 문장이 없으나, 분석이 비어 있어 중요한 첨삭 대상을 놓쳤습니다. 또한, JD의 핵심 경험 요구사항이 누락되었습니다.",3642,153,3460, +EV-02,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 잘 드러나지 않은 부분이 있으며, JD의 핵심 키워드가 누락되었습니다. 전반적으로 개선이 필요합니다.",2711,289,3483, +EV-03,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석에서 중요한 경험과 강점이 누락되었으며, 명백한 문제 문장이 존재하지만 분석이 부족하여 전반적인 유용성이 낮게 평가되었습니다.",4208,287,4661, +EV-04,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 전혀 이루어지지 않았고, 중요한 첨삭 대상을 놓쳤습니다. 또한, JD의 핵심 경험 요구사항이 누락되었습니다.",4793,147,2841, +EV-05,evaluation/evaluation_ai_results_hybrid_v1.csv,1,4.0,3.0,4.0,3.0,2.0,5.0,5.0,4.0,5.0,5.0,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 포부는 명확하나 구체적인 실행 방법이 부족하며, JD의 핵심 경험 요구사항이 누락되었습니다. 전반적으로 유용성은 중간 수준입니다.",3120,301,3162, +EV-06,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","자기소개서에서 주요 경험과 강점이 잘 드러나지 않았고, 중요한 키워드가 누락되었습니다. 분석이 부족하여 전반적인 유용성이 낮게 평가되었습니다.",5796,290,3698, +EV-07,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,5,5,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 전혀 이루어지지 않았고, 중요한 경험 키워드가 누락되었습니다. 전반적으로 개선이 필요합니다.",2545,139,1994, +EV-08,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 JD의 주요 요구사항을 충분히 반영하지 못하고 있으며, 분석이 전혀 이루어지지 않아 중요한 문제를 놓쳤습니다.",4207,144,2814, +EV-09,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험은 유용하지만, JD의 주요 요구사항인 부가세 및 법인세 신고 실무 경험과 결산 및 관리회계 실무 경험이 누락되었습니다. 또한, 분석이 부족하여 중요한 문제를 놓쳤습니다.",3584,324,4247, +EV-10,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원서에서 주요 업무와 관련된 경험이 부족하며, 분석이 결여된 부분이 있어 전반적인 유용성이 낮습니다.",3521,304,4867, +EV-11,evaluation/evaluation_ai_results_hybrid_v1.csv,1,4.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 포부는 잘 드러나지만, 구체적인 실행 방법과 JD의 핵심 경험 요구사항이 누락되어 있습니다.",3439,299,6653, +EV-12,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,4.0,4.0,2,4,4,2,2,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석에서 중요한 경험 키워드가 누락되었고, 문맥을 무시한 평가가 있었습니다. 전반적으로 개선이 필요합니다.",3806,295,3174, +EV-13,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 직무와 관련된 부분이 부족하며, 주요 키워드가 누락되었습니다. 분석이 부족하여 중요한 문제를 놓쳤습니다.",3511,270,3689, +EV-14,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 JD의 주요 요구사항을 충분히 반영하지 못하고 있으며, 분석이 전혀 이루어지지 않아 중요한 첨삭 대상을 놓쳤습니다.",3764,147,1857, +EV-15,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험과 강점이 잘 드러나지 않았고, 주요 요구사항이 누락되었습니다. 분석이 전혀 없어서 중요한 문제를 놓쳤습니다.",2927,145,2015, +EV-16,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,3.0,3.0,4.0,2.0,4.0,4.0,4.0,4.0,4.0,2,2,2,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 부족하여 중요한 문제를 놓쳤고, JD의 핵심 경험 요구사항이 누락되었습니다. 전반적으로 개선이 필요합니다.",3929,293,4032, +EV-17,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,4.0,3.0,3.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]",분석이 부족하고 주요 경험 키워드가 누락되어 전반적인 유용성이 낮습니다.,3064,270,3348, +EV-18,evaluation/evaluation_ai_results_hybrid_v1.csv,1,3.0,2.0,4.0,4.0,2.0,4.0,4.0,4.0,4.0,4.0,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석에서 병원 행정업무 지원 관련 경험이 부족하다는 점이 명확히 드러났으나, 이에 대한 구체적인 개선이 제시되지 않아 전반적인 유용성이 낮게 평가되었습니다.",3142,281,4608, +EV-19,evaluation/evaluation_ai_results_hybrid_v1.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험과 동기는 명확하나, JD의 주요 요구사항을 충분히 반영하지 못하고 있으며, 분석이 전혀 이루어지지 않아 중요한 첨삭 대상을 놓쳤습니다.",3079,154,2241, +EV-20,evaluation/evaluation_ai_results_hybrid_v1.csv,1,,,,,,,,,,,2,2,2,3,3,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 포부는 있으나 구체적인 실행 방법이 부족하며, JD의 주요 경험 요구사항이 누락되었습니다. 전반적으로 개선이 필요합니다.",3563,183,3287, diff --git a/evaluation/evaluation_nlg_judge_two_pass_exact_rejudge.csv b/evaluation/evaluation_nlg_judge_two_pass_exact_rejudge.csv new file mode 100644 index 00000000..877bdccc --- /dev/null +++ b/evaluation/evaluation_nlg_judge_two_pass_exact_rejudge.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","전반적으로 경험이 잘 서술되어 있으며, 구체적인 사례가 돋보입니다. 그러나 일부 문장에서 성과 수치와 구체적인 행동이 부족하여 개선의 여지가 있습니다.",4011,134,2982, +EV-02,evaluation/evaluation_ai_results_two_pass_v2_1.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험은 긍정적이나, 구체적인 방법론과 JD의 핵심 키워드가 부족하여 전반적인 유용성이 낮게 평가되었습니다.",3074,290,2488, +EV-03,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,1,,,,,2,"[""MISSED_ANALYSIS""]","분석 결과가 비어 있으며, 명백한 문제 문장이 존재합니다. 구체적인 행동과 방법론이 부족하여 전반적인 유용성이 낮습니다.",4358,103,1711, +EV-04,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 비어있어 중요한 첨삭 대상을 놓쳤으며, 구체적인 행동과 실행 계획이 부족합니다.",5367,128,1968, +EV-05,evaluation/evaluation_ai_results_two_pass_v2_1.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,1,1,1,1,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","지원자의 경험이 구체적이지 않아 분석이 부족하다고 판단됩니다. 또한, 누락된 키워드가 있어 전체적인 유용성이 낮습니다.",3481,455,3796, +EV-06,evaluation/evaluation_ai_results_two_pass_v2_1.csv,1,3.0,2.0,3.0,3.0,2.0,3.0,3.0,3.0,3.0,3.0,2,1,1,,,2,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]",분석이 부족하여 중요한 문제를 놓쳤습니다. 성과에 대한 구체적인 수치가 부족하다는 지적이 있었습니다.,6371,254,2362, +EV-07,evaluation/evaluation_ai_results_two_pass_v2_1.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,5.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험을 잘 표현했으나, 구체적인 수치와 방법론이 부족하여 분석이 미흡했습니다.",3000,397,4278, +EV-08,evaluation/evaluation_ai_results_two_pass_v2_1.csv,2,4.0,3.0,3.0,4.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",구체적인 실행 계획이 부족하여 명확한 개선 방향이 필요합니다. 전반적으로 유용성은 중간 수준입니다.,4793,386,5630, +EV-09,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,1,1,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 비어있고 중요한 첨삭 대상을 놓쳤으며, JD의 핵심 경험 요구사항이 누락되었습니다.",3680,138,1716, +EV-10,evaluation/evaluation_ai_results_two_pass_v2_1.csv,,,,,,,,,,,,,,,,,,[],,,,,judge_validation_failed +EV-11,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원 동기와 포부에서 구체적인 실행 계획이 부족하여 명백한 문제 문장이 존재하지만, 분석이 비어 있어 중요한 첨삭 대상을 놓쳤습니다.",3563,143,1922, +EV-12,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_STRENGTH"",""MISSED_ANALYSIS""]","분석이 비어있어 중요한 첨삭 대상을 놓쳤으며, 구체적인 행동 계획과 방법론이 부족합니다.",4033,138,1694, +EV-13,evaluation/evaluation_ai_results_two_pass_v2_1.csv,2,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","전반적으로 경험이 잘 드러나지만, 구체적인 행동과 방법론이 더 필요합니다.",4107,424,3936, +EV-14,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원자의 답변에서 구체적인 행동과 성과 수치가 부족하여 분석이 필요합니다. 명백한 문제 문장이 존재하지만 분석이 이루어지지 않았습니다.,3904,139,1846, +EV-15,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,1,,,,,2,"[""MISSED_ANALYSIS""]",지원자의 답변에서 구체적인 행동 계획이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 전반적으로 개선이 필요합니다.,3257,98,1558, +EV-16,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,2,2,2,3,2,"[""MISSED_STRENGTH"",""MISSED_ANALYSIS""]",분석이 비어있고 명백한 문제 문장이 존재하여 개선이 필요합니다. 구체적인 실행 계획과 행동이 부족한 점이 두드러집니다.,4121,147,2249, +EV-17,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,2,2,3,3,2,"[""MISSED_ANALYSIS""]","분석이 비어있어 중요한 첨삭 대상을 놓쳤으며, 구체적인 행동과 성과가 부족하다는 점에서 낮은 평가를 받았습니다.",3208,137,1958, +EV-18,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,1,,,,,2,"[""MISSED_ANALYSIS""]","분석이 비어있어 명백한 문제 문장이 존재하지만, 적절한 첨삭이 이루어지지 않았습니다. 구체적인 실행 계획과 방법론이 부족하여 전반적인 유용성이 낮습니다.",3139,117,1650, +EV-19,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,1,,,,,2,"[""MISSED_ANALYSIS""]",지원 동기와 입사 후 포부에서 구체적인 행동과 방법론이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 전반적으로 개선이 필요합니다.,3232,105,1649, +EV-20,evaluation/evaluation_ai_results_two_pass_v2_1.csv,0,,,,,,,,,,,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]",지원 동기와 포부는 명확하나 구체적인 경험이 부족하여 분석이 필요합니다.,3862,123,1677, diff --git a/evaluation/evaluation_nlg_judge_v5a_exact_rejudge.csv b/evaluation/evaluation_nlg_judge_v5a_exact_rejudge.csv new file mode 100644 index 00000000..b5a3f8dc --- /dev/null +++ b/evaluation/evaluation_nlg_judge_v5a_exact_rejudge.csv @@ -0,0 +1,21 @@ +caseId,sourceResultFile,analysisCount,averageRelevance,averageProblemValidity,averageSentenceTypeConsistency,averageReasonCorrectness,averageContextAwareness,averageFaithfulness,averageTenseConsistency,averageUsability,averageNonMeta,averageMeaningPreservation,noAnalysisAppropriateness,strengthsPrecision,strengthsCoverage,missingKeywordsPrecision,missingKeywordsCoverage,overallUsefulness,errorCodes,shortRationale,judgeInputTokens,judgeOutputTokens,judgeLatencyMs,failureStage +EV-01,evaluation/evaluation_ai_results_fewshot_v5a.csv,0,,,,,,,,,,,2,5,5,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","답변에 대한 분석이 전혀 이루어지지 않았으며, 중요한 키워드인 영어 어학능력이 누락되었습니다. 이로 인해 전반적인 유용성이 낮게 평가되었습니다.",3443,152,4626, +EV-02,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원자의 경험이 잘 드러나지만, 특정 문장에 대한 분석이 부족하여 중요한 첨삭 대상을 놓쳤습니다.",2635,271,2534, +EV-03,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,2.0,3.0,3.0,2.0,4.0,4.0,3.0,4.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석 결과, 강점 및 핵심역량에 대한 구체적인 경험이 부족하여 중요한 첨삭 대상을 놓쳤습니다. 전반적으로 유용성은 중간 수준입니다.",4017,282,4518, +EV-04,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,,,,,,,,,,,2,3,3,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","분석이 부족하여 주요 문제를 놓쳤고, JD의 핵심 키워드도 누락되었습니다. 전반적으로 개선이 필요합니다.",4777,180,2940, +EV-05,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,5.0,5.0,4.0,4.0,5.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","분석이 부족하여 중요한 문제를 놓쳤습니다. 문장 내용은 적절하나, 구체적인 설명이 부족해 평가가 낮아졌습니다.",3032,291,3987, +EV-06,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 중요한 문제를 놓쳤고, 문맥을 무시한 부분이 있습니다. 그러나 전반적으로 유용한 경험이 잘 드러나 있습니다.",5599,305,2662, +EV-07,evaluation/evaluation_ai_results_fewshot_v5a.csv,0,,,,,,,,,,,5,5,5,5,5,4,"[""NONE""]","지원자의 경험과 능력이 잘 드러나 있으며, 분석이 필요하지 않은 적절한 내용입니다. 다만, 전체적인 개선 여지가 있어 점수를 4로 평가했습니다.",2435,131,1818, +EV-08,evaluation/evaluation_ai_results_fewshot_v5a.csv,0,,,,,,,,,,,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 JD의 주요 요구사항과 잘 연결되지 않았으며, 분석이 전혀 이루어지지 않아 중요한 문제를 놓쳤습니다. 또한, 누락된 키워드가 있어 전반적인 유용성이 낮게 평가되었습니다.",4037,162,1905, +EV-09,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,3.0,4.0,2,2,2,1,1,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험이 일부 언급되었으나, JD의 주요 요구사항인 결산 실무 경험이 누락되었습니다. 또한, 분석이 부족하여 중요한 문제를 놓쳤습니다.",3406,310,2954, +EV-10,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,3.0,4.0,2,5,5,5,5,3,"[""MISSED_ANALYSIS""]","지원 동기와 포부에서 구체적인 실행 방법이 부족하여 분석이 미흡했습니다. 전반적으로 강점은 잘 드러났으나, 중요한 첨삭 대상을 놓쳤습니다.",3405,309,2841, +EV-11,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기와 자격증 관련 강점은 잘 드러났으나, 사업단 운영 및 관리에 대한 구체적인 계획이 부족하고, 주요 키워드가 누락되어 전반적인 유용성이 낮게 평가되었습니다.",3398,330,3192, +EV-12,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,4.0,4.0,4.0,2,5,5,5,5,3,"[""CONTEXT_IGNORED"",""MISSED_ANALYSIS""]","분석이 부족하여 중요한 첨삭 대상을 놓쳤고, 문맥을 무시한 부분이 있습니다. 전반적으로 유용성은 중간 수준입니다.",3640,291,2827, +EV-13,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,4.0,2.0,4.0,4.0,4.0,3.0,4.0,3,5,5,5,5,3,"[""CONTEXT_IGNORED""]","전반적으로 경험이 잘 드러나지만, 품질 관리의 구체적인 방법이 부족하여 문맥을 간과한 부분이 있습니다.",3362,279,2990, +EV-14,evaluation/evaluation_ai_results_fewshot_v5a.csv,0,,,,,,,,,,,1,5,5,5,5,2,[],"지원자의 경험이 JD의 주요 요구사항과 잘 맞지 않으며, 분석이 전혀 이루어지지 않아 중요한 문제를 놓쳤습니다.",3472,123,1943, +EV-15,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기는 잘 설명되었으나, 구체적인 경험이나 수치가 부족하여 설득력이 떨어지며, 주요 키워드가 누락되었습니다.",2981,291,2866, +EV-16,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험과 포부는 잘 드러나지만, 구체적인 방법론이 부족하고 JD의 핵심 키워드가 누락되어 전체적인 유용성이 낮게 평가되었습니다.",3778,305,3174, +EV-17,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,4.0,3.0,4.0,4.0,3.0,4.0,4.0,4.0,4.0,4.0,4,5,5,5,5,4,"[""NONE""]","전반적으로 경험과 강점을 잘 표현했으나, 일부 문장에서 구체성이 부족하여 개선 여지가 있습니다.",2940,246,2129, +EV-18,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,4.0,3.0,2.0,4.0,4.0,4.0,4.0,4.0,2,4,4,2,2,2,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원 동기에서 구체적인 경험 부족이 드러나며, 주요 키워드가 누락되었습니다. 분석이 부족하여 중요한 문제를 놓쳤습니다.",2961,278,3116, +EV-19,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,4,4,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 경험과 QA에 대한 이해는 긍정적이나, JD의 핵심 키워드가 누락되었고, 분석이 부족하여 개선이 필요합니다.",3002,295,4236, +EV-20,evaluation/evaluation_ai_results_fewshot_v5a.csv,1,3.0,3.0,3.0,3.0,2.0,4.0,4.0,3.0,3.0,4.0,2,3,3,2,2,3,"[""MISSED_ANALYSIS"",""MISSED_MISSING_KEYWORD""]","지원자의 포부는 구체적이지 않아 실행 가능성이 낮고, 주요 경험 키워드가 누락되어 전반적인 유용성이 떨어진다.",3529,287,3174, diff --git a/ops/db/migrations/20260726_coupon_redemptions.sql b/ops/db/migrations/20260726_coupon_redemptions.sql new file mode 100644 index 00000000..462bcf73 --- /dev/null +++ b/ops/db/migrations/20260726_coupon_redemptions.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS coupon_redemptions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + coupon_code VARCHAR(14) NOT NULL, + credit_amount INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_coupon_redemptions_user_coupon_code UNIQUE (user_id, coupon_code) +); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java index c9985a37..3ea37070 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.controller; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; @@ -57,6 +58,19 @@ public ApiResponse getAnalysisTask( ); } + @Operation(summary = "자소서 분석 비동기 작업 취소", description = "taskId로 접수된 자소서 분석 비동기 작업을 취소합니다.") + @PostMapping("/async/{taskId}/cancel") + public ApiResponse cancelAnalysisTask( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId, + @PathVariable String taskId + ) { + return ApiResponse.onSuccess( + "자소서 분석 비동기 작업 취소에 성공했습니다.", + analysisAsyncFacadeService.cancel(getAuthenticatedUser(userDetails), mockApplyId, taskId) + ); + } + @Operation(summary = "자소서 분석 비동기 작업 상태 SSE 구독", description = "taskId로 자소서 분석 비동기 작업 상태를 SSE 스트림으로 구독합니다.") @GetMapping(value = "/async/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamAnalysisTask( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncCancelResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncCancelResponse.java new file mode 100644 index 00000000..9513d60a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncCancelResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record AnalysisAsyncCancelResponse( + String taskId, + String status, + String message +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java index 62000ce8..55ca67f5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java @@ -3,6 +3,7 @@ import lombok.Builder; import java.time.LocalDateTime; +import java.util.List; @Builder public record AnalysisAsyncStatusResponse( @@ -21,6 +22,12 @@ public record AnalysisAsyncStatusResponse( LocalDateTime lastAttemptAt, LocalDateTime startedAt, LocalDateTime completedAt, + Boolean cancelRequested, + LocalDateTime cancelledAt, + String currentStep, + Integer progressPercent, + Integer estimatedRemainingSeconds, + List steps, AnalysisResponse result ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisProgressStepResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisProgressStepResponse.java new file mode 100644 index 00000000..56f71723 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisProgressStepResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +public record AnalysisProgressStepResponse( + String code, + String label, + String status +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 513ce37e..64bcfa4a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -77,6 +77,21 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(name = "completed_at") private LocalDateTime completedAt; + @Column(name = "cancel_requested", nullable = false) + private boolean cancelRequested; + + @Column(name = "cancelled_at") + private LocalDateTime cancelledAt; + + @Column(name = "current_step", length = 60) + private String currentStep; + + @Column(name = "progress_percent") + private Integer progressPercent; + + @Column(name = "estimated_remaining_seconds") + private Integer estimatedRemainingSeconds; + public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRetryCount) { AnalysisAsyncTask task = new AnalysisAsyncTask(); task.taskId = UUID.randomUUID().toString(); @@ -88,6 +103,9 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); task.submittedAt = LocalDateTime.now(); + task.cancelRequested = false; + task.currentStep = "VALIDATING_INPUT"; + task.progressPercent = 0; return task; } @@ -110,6 +128,8 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte } this.status = TaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; + this.currentStep = "PREPARING_CONTEXT"; + this.progressPercent = Math.max(resolveProgressPercent(), 10); this.failureReason = null; this.error = null; this.workerId = workerId; @@ -122,11 +142,17 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte } public void markSuccess() { + if (isTerminal()) { + return; + } this.status = TaskStatus.SUCCEEDED; this.message = "자소서 분석이 완료되었습니다."; this.error = null; this.failureReason = null; this.completedAt = LocalDateTime.now(); + this.currentStep = "COMPLETED"; + this.progressPercent = 100; + this.estimatedRemainingSeconds = 0; } public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { @@ -139,6 +165,8 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } this.status = TaskStatus.PENDING; this.message = "자소서 분석 재시도를 대기 중입니다."; + this.currentStep = "VALIDATING_INPUT"; + this.progressPercent = 0; this.failureReason = failureReason; this.error = errorMessage; this.retryCount = Math.max(0, retryCount); @@ -146,7 +174,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { - if (status == TaskStatus.SUCCEEDED) { + if (isTerminal()) { return; } this.status = TaskStatus.FAILED; @@ -155,6 +183,29 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret this.error = errorMessage; this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); + this.progressPercent = 0; + this.estimatedRemainingSeconds = 0; + } + + public void requestCancel() { + if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + return; + } + this.cancelRequested = true; + if (status == TaskStatus.CANCELLED) { + if (cancelledAt == null) { + this.cancelledAt = LocalDateTime.now(); + } + return; + } + this.status = TaskStatus.CANCELLED; + this.message = "자소서 분석 작업이 취소되었습니다."; + this.error = null; + this.failureReason = null; + this.completedAt = LocalDateTime.now(); + this.cancelledAt = this.completedAt; + this.progressPercent = 0; + this.estimatedRemainingSeconds = 0; } public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { @@ -167,14 +218,19 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + } + + private int resolveProgressPercent() { + return progressPercent == null ? 0 : progressPercent; } public enum TaskStatus { PENDING, RUNNING, SUCCEEDED, - FAILED + FAILED, + CANCELLED } public enum CreditStatus { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java index 617bd798..08e531d2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java @@ -48,6 +48,9 @@ public class EvaluationAnalysisRunner implements ApplicationRunner { @Value("${evaluation.nlg-judge.enabled:false}") private boolean nlgJudgeEnabled; + @Value("${evaluation.hybrid-merge.enabled:false}") + private boolean hybridMergeEnabled; + private final EvaluationAnalysisBatchService evaluationAnalysisBatchService; private final EvaluationExitCoordinator evaluationExitCoordinator; private final Environment environment; @@ -88,9 +91,9 @@ void validateProfiles() { } void validateExecutionProperties() { - if (nlgJudgeEnabled) { + if (nlgJudgeEnabled || hybridMergeEnabled) { throw new IllegalArgumentException( - "evaluation.analysis.enabled와 evaluation.nlg-judge.enabled를 동시에 true로 설정할 수 없습니다." + "evaluation.analysis.enabled는 evaluation.nlg-judge.enabled 또는 evaluation.hybrid-merge.enabled와 동시에 true로 설정할 수 없습니다." ); } if (!StringUtils.hasText(inputPath)) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java index bee1f050..c70343bc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java @@ -63,60 +63,53 @@ private static CsvRows readRows(Path path) throws IOException { } static void write(Path path, List results) throws IOException { - Path parent = path.toAbsolutePath().getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - - try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { - writeRow(writer, List.of( - "caseId", - "jobCategoryMiddle", - "jobCategorySmall", - "mainTasks", - "qualifications", - "preferences", - "question", - "answer", - "aiScore", - "aiJobFit", - "aiImpact", - "aiCompleteness", - "aiFeedback", - "aiMissingKeywordsJson", - "aiQuestionAnalysesJson", - "rawLlmResponseJson", - "rawCandidateResponseJson", - "sanitizedCandidateResponseJson", - "candidateReviewResponseJson", - "candidateCount", - "candidateAnalysisCount", - "candidateStrengthCount", - "candidateMissingKeywordCount", - "acceptedCandidateCount", - "rejectedCandidateCount", - "rejectionCodeCounts", - "finalAnalysisCount", - "strengthCandidateCount", - "finalStrengthCount", - "missingKeywordCandidateCount", - "finalMissingKeywordCount", - "candidateCallLatencyMs", - "finalCallLatencyMs", - "candidateLatencyMs", - "finalLatencyMs", - "candidateInputTokens", - "candidateOutputTokens", - "finalInputTokens", - "finalOutputTokens", - "totalInputTokens", - "totalOutputTokens", - "failureStage", - "errorMessage", - "createdAt" - )); - - for (EvaluationAnalysisResult result : results) { + writeCsv(path, List.of( + "caseId", + "jobCategoryMiddle", + "jobCategorySmall", + "mainTasks", + "qualifications", + "preferences", + "question", + "answer", + "aiScore", + "aiJobFit", + "aiImpact", + "aiCompleteness", + "aiFeedback", + "aiMissingKeywordsJson", + "aiQuestionAnalysesJson", + "rawLlmResponseJson", + "rawCandidateResponseJson", + "sanitizedCandidateResponseJson", + "candidateReviewResponseJson", + "candidateCount", + "candidateAnalysisCount", + "candidateStrengthCount", + "candidateMissingKeywordCount", + "acceptedCandidateCount", + "rejectedCandidateCount", + "rejectionCodeCounts", + "finalAnalysisCount", + "strengthCandidateCount", + "finalStrengthCount", + "missingKeywordCandidateCount", + "finalMissingKeywordCount", + "candidateCallLatencyMs", + "finalCallLatencyMs", + "candidateLatencyMs", + "finalLatencyMs", + "candidateInputTokens", + "candidateOutputTokens", + "finalInputTokens", + "finalOutputTokens", + "totalInputTokens", + "totalOutputTokens", + "failureStage", + "errorMessage", + "createdAt" + ), writer -> { + for (EvaluationAnalysisResult result : results == null ? List.of() : results) { writeRow(writer, List.of( value(result.caseId()), value(result.jobCategoryMiddle()), @@ -164,7 +157,37 @@ static void write(Path path, List results) throws IOEx value(result.createdAt()) )); } + }); + } + + static void writeRows(Path path, List headers, List> rows) throws IOException { + List safeHeaders = headers == null ? List.of() : headers; + writeCsv(path, safeHeaders, writer -> { + for (Map row : rows == null ? List.>of() : rows) { + List values = new ArrayList<>(); + for (String header : safeHeaders) { + values.add(value(row.get(header))); + } + writeRow(writer, values); + } + }); + } + + private static void writeCsv(Path path, List headers, CsvRowsWriter rowsWriter) throws IOException { + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); } + + try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + writeRow(writer, headers == null ? List.of() : headers); + rowsWriter.write(writer); + } + } + + @FunctionalInterface + private interface CsvRowsWriter { + void write(BufferedWriter writer) throws IOException; } private static List> parseRows(String content) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java index 7a931c45..f561505c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java @@ -14,11 +14,18 @@ class EvaluationRunnerFlagValidator implements SmartInitializingSingleton { @Value("${evaluation.nlg-judge.enabled:false}") private boolean nlgJudgeEnabled; + @Value("${evaluation.hybrid-merge.enabled:false}") + private boolean hybridMergeEnabled; + @Override public void afterSingletonsInstantiated() { - if (analysisEvaluationEnabled && nlgJudgeEnabled) { + int enabledRunnerCount = 0; + enabledRunnerCount += analysisEvaluationEnabled ? 1 : 0; + enabledRunnerCount += nlgJudgeEnabled ? 1 : 0; + enabledRunnerCount += hybridMergeEnabled ? 1 : 0; + if (enabledRunnerCount > 1) { throw new IllegalStateException( - "evaluation.analysis.enabled와 evaluation.nlg-judge.enabled를 동시에 true로 설정할 수 없습니다." + "analysis-eval runner flags are mutually exclusive: evaluation.analysis.enabled, evaluation.nlg-judge.enabled, evaluation.hybrid-merge.enabled" ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java new file mode 100644 index 00000000..ecfd665c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java @@ -0,0 +1,136 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +@Component +@Profile("analysis-eval") +@ConditionalOnProperty( + prefix = "evaluation.hybrid-merge", + name = "enabled", + havingValue = "true", + matchIfMissing = false +) +@RequiredArgsConstructor +@Slf4j +public class HybridExactMergeRunner implements ApplicationRunner { + + @Value("${evaluation.hybrid-merge.single-pass-input:}") + private String singlePassInputPath; + + @Value("${evaluation.hybrid-merge.two-pass-input:}") + private String twoPassInputPath; + + @Value("${evaluation.hybrid-merge.output:}") + private String outputPath; + + @Value("${evaluation.analysis.enabled:false}") + private boolean analysisEvaluationEnabled; + + @Value("${evaluation.nlg-judge.enabled:false}") + private boolean nlgJudgeEnabled; + + private final HybridExactMergeService hybridExactMergeService; + private final EvaluationExitCoordinator evaluationExitCoordinator; + private final Environment environment; + + @Override + public void run(ApplicationArguments args) throws Exception { + log.info("HybridExactMergeRunner run entered."); + try { + validateProfiles(); + validateExecutionProperties(); + log.info( + "Hybrid exact offline merge를 시작합니다. singlePassInput={}, twoPassInput={}, output={}", + singlePassInputPath, + twoPassInputPath, + outputPath + ); + HybridExactMergeService.HybridExactMergeSummary summary = hybridExactMergeService.merge( + Path.of(singlePassInputPath), + Path.of(twoPassInputPath), + Path.of(outputPath) + ); + validateOutputFile(summary.outputPath(), summary.mergedCases()); + log.info( + "Hybrid exact offline merge 완료. singlePassCases={}, twoPassCases={}, mergedCases={}, output={}", + summary.singlePassCases(), + summary.twoPassCases(), + summary.mergedCases(), + summary.outputPath() + ); + } catch (Exception e) { + log.error("Hybrid exact offline merge 실행에 실패했습니다. message={}", e.getMessage(), e); + evaluationExitCoordinator.exit("hybrid-exact-merge", 1); + throw e; + } + evaluationExitCoordinator.exit("hybrid-exact-merge", 0); + } + + void validateProfiles() { + List profiles = Arrays.asList(environment.getActiveProfiles()); + if (profiles.contains("prod")) { + throw new IllegalStateException("Hybrid exact merge must not run with prod profile."); + } + if (!profiles.contains("analysis-eval")) { + throw new IllegalStateException("Hybrid exact merge requires analysis-eval profile."); + } + } + + void validateExecutionProperties() { + if (analysisEvaluationEnabled || nlgJudgeEnabled) { + throw new IllegalArgumentException( + "evaluation.hybrid-merge.enabled는 evaluation.analysis.enabled 또는 evaluation.nlg-judge.enabled와 동시에 true로 설정할 수 없습니다." + ); + } + if (!StringUtils.hasText(singlePassInputPath)) { + throw new IllegalArgumentException("evaluation.hybrid-merge.single-pass-input 값을 지정해야 합니다."); + } + if (!StringUtils.hasText(twoPassInputPath)) { + throw new IllegalArgumentException("evaluation.hybrid-merge.two-pass-input 값을 지정해야 합니다."); + } + if (!StringUtils.hasText(outputPath)) { + throw new IllegalArgumentException("evaluation.hybrid-merge.output 값을 지정해야 합니다."); + } + if (!Files.isRegularFile(Path.of(singlePassInputPath))) { + throw new IllegalArgumentException( + "evaluation.hybrid-merge.single-pass-input 파일을 찾을 수 없습니다. path=" + singlePassInputPath + ); + } + if (!Files.isRegularFile(Path.of(twoPassInputPath))) { + throw new IllegalArgumentException( + "evaluation.hybrid-merge.two-pass-input 파일을 찾을 수 없습니다. path=" + twoPassInputPath + ); + } + } + + private void validateOutputFile(Path output, int expectedRows) throws java.io.IOException { + if (!Files.isRegularFile(output) || Files.size(output) == 0) { + throw new IllegalStateException("Hybrid exact offline merge output CSV가 생성되지 않았거나 비어 있습니다. path=" + output); + } + int actualRows = EvaluationCsvSupport.read(output).size(); + if (actualRows != expectedRows) { + throw new IllegalStateException( + "Hybrid exact offline merge output row count mismatch. expected=" + + expectedRows + + ", actual=" + + actualRows + + ", path=" + + output + ); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java new file mode 100644 index 00000000..68ec70a5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java @@ -0,0 +1,175 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +class HybridExactMergeService { + private static final List REQUIRED_SINGLE_PASS_HEADERS = List.of( + "caseId", + "aiScore", + "aiJobFit", + "aiImpact", + "aiCompleteness", + "aiFeedback", + "aiMissingKeywordsJson", + "aiQuestionAnalysesJson", + "rawLlmResponseJson", + "errorMessage", + "createdAt" + ); + private static final List REQUIRED_TWO_PASS_HEADERS = List.of( + "caseId", + "aiMissingKeywordsJson", + "rawLlmResponseJson", + "errorMessage" + ); + + private final ObjectMapper objectMapper; + + HybridExactMergeSummary merge(Path singlePassInput, Path twoPassInput, Path output) throws IOException { + validateDifferentFiles(singlePassInput, twoPassInput, "single-pass input and two-pass input"); + validateDifferentFiles(singlePassInput, output, "single-pass input"); + validateDifferentFiles(twoPassInput, output, "two-pass input"); + + List singleHeaders = EvaluationCsvSupport.readHeaders(singlePassInput); + List twoPassHeaders = EvaluationCsvSupport.readHeaders(twoPassInput); + validateHeaders("single-pass", singleHeaders, REQUIRED_SINGLE_PASS_HEADERS); + validateHeaders("two-pass", twoPassHeaders, REQUIRED_TWO_PASS_HEADERS); + + List> singleRows = EvaluationCsvSupport.read(singlePassInput); + List> twoPassRows = EvaluationCsvSupport.read(twoPassInput); + Map> singleByCaseId = indexByCaseId("single-pass", singleRows); + Map> twoPassByCaseId = indexByCaseId("two-pass", twoPassRows); + validateCaseIdSets(singleByCaseId.keySet(), twoPassByCaseId.keySet()); + + List> mergedRows = new ArrayList<>(); + for (Map singleRow : singleRows) { + String caseId = value(singleRow, "caseId"); + Map twoPassRow = twoPassByCaseId.get(caseId); + validateSuccessRow("single-pass", caseId, singleRow); + validateSuccessRow("two-pass", caseId, twoPassRow); + validateJson("single-pass aiQuestionAnalysesJson", caseId, value(singleRow, "aiQuestionAnalysesJson")); + validateJson("single-pass rawLlmResponseJson", caseId, value(singleRow, "rawLlmResponseJson")); + validateJson("single-pass aiMissingKeywordsJson", caseId, value(singleRow, "aiMissingKeywordsJson")); + validateJson("two-pass aiMissingKeywordsJson", caseId, value(twoPassRow, "aiMissingKeywordsJson")); + validateJson("two-pass rawLlmResponseJson", caseId, value(twoPassRow, "rawLlmResponseJson")); + + Map merged = new LinkedHashMap<>(singleRow); + merged.put("aiMissingKeywordsJson", value(twoPassRow, "aiMissingKeywordsJson")); + merged.put("createdAt", createdAt()); + mergedRows.add(merged); + } + + EvaluationCsvSupport.writeRows(output, singleHeaders, mergedRows); + log.info( + "Hybrid exact offline merge completed. singlePassCases={}, twoPassCases={}, mergedCases={}, output={}", + singleRows.size(), + twoPassRows.size(), + mergedRows.size(), + output + ); + return new HybridExactMergeSummary(singleRows.size(), twoPassRows.size(), mergedRows.size(), output); + } + + private void validateHeaders(String source, List headers, List requiredHeaders) { + Set headerSet = new HashSet<>(headers); + List missing = requiredHeaders.stream() + .filter(header -> !headerSet.contains(header)) + .toList(); + if (!missing.isEmpty()) { + throw new IllegalArgumentException(source + " CSV missing required headers: " + missing); + } + } + + private Map> indexByCaseId(String source, List> rows) { + Map> indexed = new LinkedHashMap<>(); + for (Map row : rows) { + String caseId = value(row, "caseId"); + if (!StringUtils.hasText(caseId)) { + throw new IllegalArgumentException(source + " CSV has blank caseId."); + } + if (indexed.putIfAbsent(caseId, row) != null) { + throw new IllegalArgumentException(source + " CSV has duplicate caseId: " + caseId); + } + } + return indexed; + } + + private void validateCaseIdSets(Set singleCaseIds, Set twoPassCaseIds) { + List missingInTwoPass = singleCaseIds.stream() + .filter(caseId -> !twoPassCaseIds.contains(caseId)) + .toList(); + List missingInSinglePass = twoPassCaseIds.stream() + .filter(caseId -> !singleCaseIds.contains(caseId)) + .toList(); + if (!missingInTwoPass.isEmpty() || !missingInSinglePass.isEmpty()) { + throw new IllegalArgumentException( + "Hybrid exact caseId mismatch. missingInTwoPass=" + + missingInTwoPass + + ", missingInSinglePass=" + + missingInSinglePass + ); + } + } + + private void validateSuccessRow(String source, String caseId, Map row) { + String errorMessage = value(row, "errorMessage"); + if (StringUtils.hasText(errorMessage)) { + throw new IllegalArgumentException(source + " row has errorMessage. caseId=" + caseId); + } + } + + private void validateJson(String field, String caseId, String json) { + if (!StringUtils.hasText(json)) { + throw new IllegalArgumentException(field + " is blank. caseId=" + caseId); + } + try { + objectMapper.readTree(json); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(field + " is not valid JSON. caseId=" + caseId, e); + } + } + + private void validateDifferentFiles(Path input, Path output, String inputName) { + if (input.toAbsolutePath().normalize().equals(output.toAbsolutePath().normalize())) { + if ("single-pass input and two-pass input".equals(inputName)) { + throw new IllegalArgumentException("Hybrid exact input paths must be different: " + inputName + "."); + } + throw new IllegalArgumentException("Hybrid exact output must not overwrite " + inputName + "."); + } + } + + private String value(Map row, String key) { + return row == null || row.get(key) == null ? "" : row.get(key).trim(); + } + + private String createdAt() { + return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + record HybridExactMergeSummary( + int singlePassCases, + int twoPassCases, + int mergedCases, + Path outputPath + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java index 3291ca92..80ddea40 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java @@ -51,6 +51,9 @@ public class NlgEvaluationRunner implements ApplicationRunner { @Value("${evaluation.analysis.enabled:false}") private boolean analysisEvaluationEnabled; + @Value("${evaluation.hybrid-merge.enabled:false}") + private boolean hybridMergeEnabled; + private final NlgEvaluationBatchService nlgEvaluationBatchService; private final EvaluationExitCoordinator evaluationExitCoordinator; private final Environment environment; @@ -170,9 +173,9 @@ private void validateCommonOutput() { } private void validateMutuallyExclusiveRunner() { - if (analysisEvaluationEnabled) { + if (analysisEvaluationEnabled || hybridMergeEnabled) { throw new IllegalArgumentException( - "evaluation.analysis.enabled와 evaluation.nlg-judge.enabled를 동시에 true로 설정할 수 없습니다." + "evaluation.nlg-judge.enabled는 evaluation.analysis.enabled 또는 evaluation.hybrid-merge.enabled와 동시에 true로 설정할 수 없습니다." ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java index 214603d0..bffd05b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -25,6 +25,7 @@ import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.StructuredResponse; import com.openai.models.responses.StructuredResponseOutputMessage; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -288,6 +289,14 @@ public class AnalysisAiClient { @Value("${analysis.two-pass.enabled:false}") private boolean twoPassEnabled; + @Value("${analysis.mode:}") + private String analysisMode; + + @PostConstruct + void validateAnalysisModeProperty() { + resolveAnalysisMode(); + } + public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { return analyze(payload.jobPosting(), payload.answeredQuestions(), payload.jobCategoryEvaluationCriteria()); } @@ -309,20 +318,27 @@ public AnalysisLlmResponse analyze( log.debug("analysis retrieval exception", e); } try { - if (twoPassEnabled) { - return analyzeTwoPass( - AnalysisPromptInput.from(jobPosting, questions), + AnalysisPromptInput promptInput = AnalysisPromptInput.from(jobPosting, questions); + return switch (resolveAnalysisMode()) { + case TWO_PASS -> analyzeTwoPass( + promptInput, referenceContext, jobCategoryEvaluationCriteria, "cover-letter-analysis" ).response(); - } - return analyzeSinglePass( - AnalysisPromptInput.from(jobPosting, questions), - referenceContext, - jobCategoryEvaluationCriteria, - "cover-letter-analysis" - ).response(); + case HYBRID_EXACT -> analyzeHybridExact( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + "cover-letter-analysis" + ).response(); + case SINGLE_PASS -> analyzeSinglePass( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + "cover-letter-analysis" + ).response(); + }; } catch (GeneralException e) { throw e; } catch (Exception e) { @@ -346,20 +362,26 @@ public AnalysisAiCallResult analyzeForEvaluationResult( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { try { - if (twoPassEnabled) { - return analyzeTwoPass( + return switch (resolveAnalysisMode()) { + case TWO_PASS -> analyzeTwoPass( promptInput, emptyContext(), jobCategoryEvaluationCriteria, "cover-letter-analysis-evaluation" ); - } - return analyzeSinglePass( - promptInput, - emptyContext(), - jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" - ); + case HYBRID_EXACT -> analyzeHybridExact( + promptInput, + emptyContext(), + jobCategoryEvaluationCriteria, + "cover-letter-analysis-evaluation" + ); + case SINGLE_PASS -> analyzeSinglePass( + promptInput, + emptyContext(), + jobCategoryEvaluationCriteria, + "cover-letter-analysis-evaluation" + ); + }; } catch (GeneralException e) { throw e; } catch (Exception e) { @@ -464,6 +486,47 @@ private AnalysisAiCallResult analyzeTwoPass( ); } + private AnalysisAiCallResult analyzeHybridExact( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + String operationName + ) { + AnalysisAiCallResult singlePassResult = analyzeSinglePass( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + operationName + "-single-pass" + ); + AnalysisAiCallResult twoPassResult = analyzeTwoPass( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + operationName + "-two-pass" + ); + AnalysisLlmResponse merged = mergeHybridExact( + singlePassResult.response(), + twoPassResult.response() + ); + log.debug( + "Hybrid exact response merged. questionAnalysesSource=single-pass, missingKeywordsSource=two-pass, scoreSource=single-pass, singlePassQuestionAnalyses={}, twoPassQuestionAnalyses={}, mergedQuestionAnalyses={}, singlePassMissingKeywords={}, twoPassMissingKeywords={}, mergedMissingKeywords={}", + size(singlePassResult.response() == null ? null : singlePassResult.response().questionAnalyses()), + size(twoPassResult.response() == null ? null : twoPassResult.response().questionAnalyses()), + size(merged == null ? null : merged.questionAnalyses()), + size(singlePassResult.response() == null ? null : singlePassResult.response().missingKeywords()), + size(twoPassResult.response() == null ? null : twoPassResult.response().missingKeywords()), + size(merged == null ? null : merged.missingKeywords()) + ); + return AnalysisAiCallResult.hybridExact( + merged, + twoPassResult.rawCandidateResponse(), + twoPassResult.sanitizedCandidateResponse(), + twoPassResult.candidateReviewResponse(), + twoPassResult.candidateCallLatencyMs(), + singlePassResult.finalCallLatencyMs() + twoPassResult.finalCallLatencyMs() + ); + } + private T createStructuredResponse(String operationName, String prompt, Class responseType) { var params = ResponseCreateParams.builder() .model(analysisModel) @@ -1782,6 +1845,29 @@ private List buildFinalMissingKeywords( return result; } + AnalysisLlmResponse mergeHybridExact( + AnalysisLlmResponse singlePassResponse, + AnalysisLlmResponse twoPassResponse + ) { + if (singlePassResponse == null) { + return null; + } + return new AnalysisLlmResponse( + singlePassResponse.jobFit(), + singlePassResponse.impact(), + singlePassResponse.completeness(), + singlePassResponse.feedback(), + singlePassResponse.keyStrengths() == null ? List.of() : List.copyOf(singlePassResponse.keyStrengths()), + singlePassResponse.keyWeaknesses() == null ? List.of() : List.copyOf(singlePassResponse.keyWeaknesses()), + twoPassResponse == null || twoPassResponse.missingKeywords() == null + ? List.of() + : List.copyOf(twoPassResponse.missingKeywords()), + singlePassResponse.questionAnalyses() == null + ? List.of() + : List.copyOf(singlePassResponse.questionAnalyses()) + ); + } + private int acceptedDecisionCount(CandidateReviewResponse reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return 0; @@ -2078,6 +2164,24 @@ private RetrievalContext emptyContext() { return new RetrievalContext(List.of(), List.of()); } + AnalysisMode resolveAnalysisMode() { + if (StringUtils.hasText(analysisMode)) { + String normalized = analysisMode.trim().replace('-', '_').toUpperCase(java.util.Locale.ROOT); + try { + return AnalysisMode.valueOf(normalized); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Unsupported analysis mode: " + analysisMode, e); + } + } + return twoPassEnabled ? AnalysisMode.TWO_PASS : AnalysisMode.SINGLE_PASS; + } + + enum AnalysisMode { + SINGLE_PASS, + TWO_PASS, + HYBRID_EXACT + } + private enum RecheckValidationFailureReason { UNKNOWN_CANDIDATE, LOW_PROBLEM_CLARITY, @@ -2124,5 +2228,24 @@ static AnalysisAiCallResult twoPass( finalCallLatencyMs ); } + + static AnalysisAiCallResult hybridExact( + AnalysisLlmResponse response, + AnalysisCandidateResponse rawCandidateResponse, + AnalysisCandidateResponse sanitizedCandidateResponse, + CandidateReviewResponse candidateReviewResponse, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) { + return new AnalysisAiCallResult( + response, + rawCandidateResponse, + sanitizedCandidateResponse, + candidateReviewResponse, + true, + candidateCallLatencyMs, + finalCallLatencyMs + ); + } } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java index 53d96786..a095519e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncFacadeService.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; @@ -59,10 +60,21 @@ public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String t .lastAttemptAt(status.lastAttemptAt()) .startedAt(status.startedAt()) .completedAt(status.completedAt()) + .cancelRequested(status.cancelRequested()) + .cancelledAt(status.cancelledAt()) + .currentStep(status.currentStep()) + .progressPercent(status.progressPercent()) + .estimatedRemainingSeconds(status.estimatedRemainingSeconds()) + .steps(status.steps()) .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) .build(); } + public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { + User validatedUser = userService.validateUser(user); + return analysisAsyncTaskService.cancelTask(validatedUser.getId(), mockApplyId, taskId); + } + private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); if (!pendingTaskResult.created()) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java index 895359a8..5e03d74b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncSseService.java @@ -41,6 +41,6 @@ private String channelKey(String taskId) { private boolean isTerminal(AnalysisAsyncStatusResponse statusResponse) { TaskStatus status = TaskStatus.valueOf(statusResponse.status()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java index 5d1fd532..87ae5af2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAsyncTaskService.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisProgressStepResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; @@ -10,9 +12,14 @@ import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator.AsyncTaskProgressStatus; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator.ProgressStepDefinition; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -24,6 +31,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.EnumSet; +import java.util.List; import java.util.Optional; import java.util.LinkedHashMap; import java.util.Map; @@ -33,12 +41,24 @@ @Slf4j // 분석 비동기 task 엔티티의 생성, 상태 전이, 조회를 전담하는 서비스다. public class AnalysisAsyncTaskService { + private static final int DEFAULT_ESTIMATED_REMAINING_SECONDS = 180; + private static final List PROGRESS_STEPS = List.of( + new ProgressStepDefinition("VALIDATING_INPUT", "분석할 내용을 확인하고 있어요"), + new ProgressStepDefinition("PREPARING_CONTEXT", "공고와 자소서를 준비하고 있어요"), + new ProgressStepDefinition("CALLING_LLM", "자기소개서를 평가하고 있어요"), + new ProgressStepDefinition("VALIDATING_RESULT", "분석 결과를 검증하고 있어요"), + new ProgressStepDefinition("SAVING_RESULT", "분석 결과를 저장하고 있어요"), + new ProgressStepDefinition("COMPLETED", "분석이 완료되었습니다") + ); private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisAsyncSseService analysisAsyncSseService; private final NotificationService notificationService; private final AsyncMetricsRecorder asyncMetricsRecorder; private final AnalysisQueueProperties analysisQueueProperties; + private final AnalysisService analysisService; + private final UserService userService; + private final AsyncProgressCalculator asyncProgressCalculator; @Transactional public AnalysisAsyncTask createPendingTask(Long userId, Long mockApplyId) { @@ -74,6 +94,21 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public void markSuccess(String taskId, AnalysisResponse result) { AnalysisAsyncTask task = getTask(taskId); + if (task.getStatus() == TaskStatus.SUCCEEDED) { + return; + } + if (task.getStatus() == TaskStatus.CANCELLED) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + if (task.getStatus() == TaskStatus.FAILED) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } task.markSuccess(); recordProcessingMetric(task, "succeeded"); publishAfterCommit(toStatusResponse(task, result)); @@ -97,6 +132,36 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM createFailureNotificationSafely(task); } + @Transactional + public AnalysisAsyncCancelResponse cancelTask(Long userId, Long mockApplyId, String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findByTaskIdAndUserId(taskId, userId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + if (!task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); + } + + TaskStatus previousStatus = task.getStatus(); + LocalDateTime previousCancelledAt = task.getCancelledAt(); + task.requestCancel(); + boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + if (newlyCancelled) { + releaseCreditIfNeeded(task); + recordProcessingMetric(task, "cancelled"); + } + if (newlyCancelled || previousCancelledAt == null && cancelled) { + publishAfterCommit(toStatusResponse(task)); + } + return new AnalysisAsyncCancelResponse( + task.getTaskId(), + task.getStatus().name(), + task.getMessage() + ); + } + @Transactional public void updateWorkerMetadata(String taskId, String workerId, Long queueLatencyMillis) { getTask(taskId).updateWorkerMetadata(workerId, queueLatencyMillis); @@ -174,10 +239,51 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task, Ana .lastAttemptAt(task.getLastAttemptAt()) .startedAt(task.getStartedAt()) .completedAt(task.getCompletedAt()) + .cancelRequested(task.isCancelRequested()) + .cancelledAt(task.getCancelledAt()) + .currentStep(resolveCurrentStep(task)) + .progressPercent(asyncProgressCalculator.resolveProgressPercent(toProgressStatus(task.getStatus()), task.getProgressPercent())) + .estimatedRemainingSeconds(asyncProgressCalculator.resolveEstimatedRemainingSeconds( + toProgressStatus(task.getStatus()), + task.getEstimatedRemainingSeconds(), + task.getStartedAt(), + DEFAULT_ESTIMATED_REMAINING_SECONDS + )) + .steps(buildSteps(task)) .result(task.getStatus() == TaskStatus.SUCCEEDED ? result : null) .build(); } + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + User user = userService.getUser(task.getUserId()); + analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); + task.markCreditReleased(); + } + + private String resolveCurrentStep(AnalysisAsyncTask task) { + return asyncProgressCalculator.resolveCurrentStep( + toProgressStatus(task.getStatus()), + task.getCurrentStep(), + "VALIDATING_INPUT" + ); + } + + private List buildSteps(AnalysisAsyncTask task) { + return asyncProgressCalculator.buildSteps( + toProgressStatus(task.getStatus()), + resolveCurrentStep(task), + PROGRESS_STEPS, + step -> new AnalysisProgressStepResponse(step.code(), step.label(), step.status()) + ); + } + + private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + return AsyncTaskProgressStatus.valueOf(status.name()); + } + private void publishAfterCommit(AnalysisAsyncStatusResponse statusResponse) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { analysisAsyncSseService.publish(statusResponse); @@ -248,4 +354,5 @@ private void createFailureNotification(AnalysisAsyncTask task) { payload ); } + } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java index 835dc8e1..3e2c0e61 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeService.java @@ -45,7 +45,7 @@ public class AnalysisWorkerBridgeService { @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { AnalysisAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (isTerminal(task)) { return; } analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); @@ -64,7 +64,7 @@ public void markRetry( Long queueLatencyMillis ) { AnalysisAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (isTerminal(task)) { return; } analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); @@ -84,7 +84,7 @@ public void failTask( Long queueLatencyMillis ) { AnalysisAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (isTerminal(task)) { return; } @@ -99,6 +99,7 @@ public void failTask( @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { AnalysisAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { throw new GeneralException( GeneralErrorCode.FORBIDDEN, @@ -130,7 +131,18 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { throw new GeneralException( GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + if (task.getStatus() == TaskStatus.CANCELLED) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "취소된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId ); } workerTaskResultService.upsertGenerated( @@ -175,6 +187,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques @Transactional public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { AnalysisAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId); if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { throw new GeneralException( GeneralErrorCode.FORBIDDEN, @@ -212,6 +225,18 @@ private AnalysisAsyncTask getTask(String taskId) { )); } + private boolean isTerminal(AnalysisAsyncTask task) { + return task.getStatus() == TaskStatus.SUCCEEDED + || task.getStatus() == TaskStatus.FAILED + || task.getStatus() == TaskStatus.CANCELLED; + } + + private void rejectIfCancelled(AnalysisAsyncTask task, String message) { + if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + } + private void reserveCreditIfNeeded(AnalysisAsyncTask task) { if (task.getCreditStatus() != CreditStatus.NONE) { return; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java index 158dafff..dee398ad 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingAiController.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncCancelResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -111,6 +112,22 @@ public ApiResponse getIngestJobPostingAsyncStatus ); } + @Operation( + summary = "채용 공고 비동기 작업 취소", + description = "taskId로 접수된 채용 공고 비동기 작업을 취소합니다." + ) + @PostMapping("/ingest/async/{taskId}/cancel") + public ApiResponse cancelIngestJobPostingAsyncTask( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable String taskId + ) { + var user = validateAuthenticatedUser(userDetails); + return ApiResponse.onSuccess( + "채용 공고 비동기 작업 취소에 성공했습니다.", + jobPostingAsyncFacadeService.cancel(user, taskId) + ); + } + @Operation( summary = "채용 공고 비동기 작업 상태 SSE 구독", description = "taskId로 비동기 작업 상태를 SSE 스트림으로 구독합니다." 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 2ab26dc9..031ffe9e 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 @@ -140,13 +140,16 @@ public ApiResponse getContext( @Valid @RequestBody JobPostingWorkerContextRequest request ) { internalApiKeyValidator.validate(internalApiKey); + List imageUrls = jobPostingWorkerBridgeService.createReadableImageUrls( + request.userId(), + request.imageObjectKey(), + request.imageObjectKeys() + ); return ApiResponse.onSuccess( "채용 공고 worker 컨텍스트 조회에 성공했습니다.", new JobPostingWorkerContextResponse( - jobPostingWorkerBridgeService.createReadableImageUrl( - request.userId(), - request.imageObjectKey() - ) + imageUrls.isEmpty() ? null : imageUrls.get(0), + imageUrls ) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java index 662f19a6..13b4e1a2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractRequest.java @@ -2,17 +2,28 @@ import jakarta.validation.constraints.AssertTrue; +import java.util.List; + public record JobPostingExtractRequest( String rawText, - String imageObjectKey + String imageObjectKey, + List imageObjectKeys ) { - @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public JobPostingExtractRequest(String rawText, String imageObjectKey) { + this(rawText, imageObjectKey, null); + } + + @AssertTrue(message = "rawText 또는 이미지 objectKey 중 하나는 반드시 포함되어야 합니다.") public boolean hasInput() { - return hasText(rawText) || hasText(imageObjectKey); + return hasText(rawText) || hasText(imageObjectKey) || hasImageObjectKeys(); } private boolean hasText(String value) { return value != null && !value.isBlank(); } + + private boolean hasImageObjectKeys() { + return imageObjectKeys != null && imageObjectKeys.stream().anyMatch(this::hasText); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java index 41dacfbf..ffe9c45d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestCommand.java @@ -3,6 +3,8 @@ import lombok.Builder; import lombok.Getter; +import java.util.List; + @Getter @Builder public class JobPostingIngestCommand { @@ -10,4 +12,5 @@ public class JobPostingIngestCommand { private Long userId; private String rawText; private String imageObjectKey; + private List imageObjectKeys; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java index e33bd535..3785d608 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java @@ -2,17 +2,28 @@ import jakarta.validation.constraints.AssertTrue; +import java.util.List; + public record JobPostingIngestRequest( String rawText, - String imageObjectKey + String imageObjectKey, + List imageObjectKeys ) { - @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public JobPostingIngestRequest(String rawText, String imageObjectKey) { + this(rawText, imageObjectKey, null); + } + + @AssertTrue(message = "rawText 또는 이미지 objectKey 중 하나는 반드시 포함되어야 합니다.") public boolean hasInput() { - return hasText(rawText) || hasText(imageObjectKey); + return hasText(rawText) || hasText(imageObjectKey) || hasImageObjectKeys(); } private boolean hasText(String value) { return value != null && !value.isBlank(); } + + private boolean hasImageObjectKeys() { + return imageObjectKeys != null && imageObjectKeys.stream().anyMatch(this::hasText); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncCancelResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncCancelResponse.java new file mode 100644 index 00000000..9cdeb7c6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncCancelResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingAsyncCancelResponse( + String taskId, + String status, + String message +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java index 9c20cc0b..88bc9311 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingAsyncStatusResponse.java @@ -5,6 +5,7 @@ import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; @Getter @Builder @@ -25,5 +26,11 @@ public class JobPostingAsyncStatusResponse { private LocalDateTime lastAttemptAt; private LocalDateTime startedAt; private LocalDateTime completedAt; + private Boolean cancelRequested; + private LocalDateTime cancelledAt; + private String currentStep; + private Integer progressPercent; + private Integer estimatedRemainingSeconds; + private List steps; private JobPostingIngestResponse result; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java index bea59caa..6e5a6748 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java @@ -1,6 +1,12 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.response; -public record JobPostingGenerateResponse(String companyName, String jobTitle, String task, String requirements, - String preferredQualifications, String summary) { - +public record JobPostingGenerateResponse( + String postingName, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String summary +) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java new file mode 100644 index 00000000..5fbb4279 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java @@ -0,0 +1,17 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import java.util.List; + +public record JobPostingIngestValidationErrorResponse( + String reason, + String message, + List invalidFields +) { + + public record InvalidField( + String field, + String label, + String message + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingProgressStepResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingProgressStepResponse.java new file mode 100644 index 00000000..c1860735 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingProgressStepResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingProgressStepResponse( + String code, + String label, + String status +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java index 1d9a8a1d..7b49c5dc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingIngestTaskMessage.java @@ -6,6 +6,7 @@ import org.slf4j.MDC; import java.time.Instant; +import java.util.List; import java.util.UUID; @Builder @@ -17,6 +18,7 @@ public record JobPostingIngestTaskMessage( Long userId, String rawText, String imageObjectKey, + List imageObjectKeys, int retryCount, int maxRetryCount, Instant submittedAt @@ -31,6 +33,7 @@ public static JobPostingIngestTaskMessage of(String taskId, JobPostingIngestComm .userId(command.getUserId()) .rawText(command.getRawText()) .imageObjectKey(command.getImageObjectKey()) + .imageObjectKeys(command.getImageObjectKeys()) .retryCount(0) .maxRetryCount(Math.max(0, maxRetryCount)) .submittedAt(Instant.now()) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextRequest.java index a43187da..afba8e47 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextRequest.java @@ -1,7 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; +import java.util.List; + public record JobPostingWorkerContextRequest( Long userId, - String imageObjectKey + String imageObjectKey, + List imageObjectKeys ) { + public JobPostingWorkerContextRequest(Long userId, String imageObjectKey) { + this(userId, imageObjectKey, null); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextResponse.java index bf7f134a..ae92e538 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerContextResponse.java @@ -1,6 +1,12 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; +import java.util.List; + public record JobPostingWorkerContextResponse( - String imageUrl + String imageUrl, + List imageUrls ) { + public JobPostingWorkerContextResponse(String imageUrl) { + this(imageUrl, imageUrl == null ? List.of() : List.of(imageUrl)); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index c2fc1c05..e1817253 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -72,6 +72,21 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Column(name = "result_payload") private String resultPayload; + @Column(name = "cancel_requested", nullable = false) + private boolean cancelRequested; + + @Column(name = "cancelled_at") + private LocalDateTime cancelledAt; + + @Column(name = "current_step", length = 60) + private String currentStep; + + @Column(name = "progress_percent") + private Integer progressPercent; + + @Column(name = "estimated_remaining_seconds") + private Integer estimatedRemainingSeconds; + public static JobPostingAsyncTask pending(Long userId, int maxRetryCount) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); @@ -81,12 +96,20 @@ public static JobPostingAsyncTask pending(Long userId, int maxRetryCount) { task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); task.submittedAt = LocalDateTime.now(); + task.cancelRequested = false; + task.currentStep = "VALIDATING_INPUT"; + task.progressPercent = 0; return task; } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { + if (isTerminal()) { + return; + } this.status = TaskStatus.RUNNING; this.message = "채용 공고 비동기 처리를 진행 중입니다."; + this.currentStep = "EXTRACTING_CONTENT"; + this.progressPercent = Math.max(resolveProgressPercent(), 20); this.failureReason = null; this.error = null; this.workerId = workerId; @@ -99,12 +122,18 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte } public void markSuccess(String resultPayload) { + if (isTerminal()) { + return; + } this.status = TaskStatus.SUCCEEDED; this.message = "채용 공고 비동기 처리에 성공했습니다."; this.error = null; this.failureReason = null; this.resultPayload = resultPayload; this.completedAt = LocalDateTime.now(); + this.currentStep = "COMPLETED"; + this.progressPercent = 100; + this.estimatedRemainingSeconds = 0; } public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { @@ -117,6 +146,8 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, } this.status = TaskStatus.PENDING; this.message = "채용 공고 비동기 재시도를 대기 중입니다."; + this.currentStep = "VALIDATING_INPUT"; + this.progressPercent = 0; this.failureReason = failureReason; this.error = errorMessage; this.retryCount = Math.max(0, retryCount); @@ -133,6 +164,29 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret this.error = errorMessage; this.retryCount = Math.max(0, retryCount); this.completedAt = LocalDateTime.now(); + this.progressPercent = 0; + this.estimatedRemainingSeconds = 0; + } + + public void requestCancel() { + if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + return; + } + this.cancelRequested = true; + if (status == TaskStatus.CANCELLED) { + if (cancelledAt == null) { + this.cancelledAt = LocalDateTime.now(); + } + return; + } + this.status = TaskStatus.CANCELLED; + this.message = "채용 공고 비동기 작업이 취소되었습니다."; + this.error = null; + this.failureReason = null; + this.completedAt = LocalDateTime.now(); + this.cancelledAt = this.completedAt; + this.progressPercent = 0; + this.estimatedRemainingSeconds = 0; } public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { @@ -145,7 +199,11 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + } + + private int resolveProgressPercent() { + return progressPercent == null ? 0 : progressPercent; } public enum FailureReason { @@ -161,6 +219,7 @@ public enum TaskStatus { PENDING, RUNNING, SUCCEEDED, - FAILED + FAILED, + CANCELLED } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 9649e9d3..90b47ab4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -54,7 +54,7 @@ public JobPostingExtractResponse extractJobPosting(String rawText) { } public JobPostingExtractResponse extractJobPosting(Long userId, JobPostingExtractRequest request) { - return extractJobPosting(userId, request.rawText(), request.imageObjectKey()); + return extractJobPosting(userId, request.rawText(), request.imageObjectKey(), request.imageObjectKeys()); } public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { @@ -185,19 +185,32 @@ public JobPostingClassificationResultResponse classifyDetailClassification( } public JobPostingExtractResponse extractJobPosting(Long userId, String rawText, String imageObjectKey) { - validateInput(rawText, imageObjectKey); - String imageUrl = hasText(imageObjectKey) - ? jobPostingImageStorageService.createReadableImageUrl(userId, imageObjectKey) - : null; + return extractJobPosting(userId, rawText, imageObjectKey, null); + } + + public JobPostingExtractResponse extractJobPosting( + Long userId, + String rawText, + String imageObjectKey, + List imageObjectKeys + ) { + List normalizedImageObjectKeys = jobPostingImageStorageService.normalizeImageObjectKeys( + imageObjectKey, + imageObjectKeys + ); + JobPostingIngestInputValidator.validate(rawText, normalizedImageObjectKeys); + List imageUrls = userId != null + ? jobPostingImageStorageService.createReadableImageUrls(userId, normalizedImageObjectKeys) + : List.of(); List contents = new ArrayList<>(); contents.add(ResponseInputContent.ofInputText( com.openai.models.responses.ResponseInputText.builder() - .text(buildPrompt(rawText, imageUrl != null)) + .text(buildPrompt(rawText, !imageUrls.isEmpty())) .build() )); - if (imageUrl != null) { + for (String imageUrl : imageUrls) { contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageUrl))); } @@ -365,18 +378,6 @@ private T extractStructuredContent(StructuredResponse response, Class )); } - private void validateInput(String rawText, String imageObjectKey) { - boolean hasRawText = hasText(rawText); - boolean hasImage = hasText(imageObjectKey); - - if (!hasRawText && !hasImage) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다." - ); - } - } - private JobPostingExtractResponse normalizeResponse(JobPostingExtractResponse response, String rawText) { if (response == null) { throw new GeneralException( @@ -424,6 +425,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. { + "postingName": "string", "companyName": "string", "jobTitle": "string", "task": "string", @@ -433,11 +435,13 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl } 작성 규칙: - 1. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요. - 2. requirements는 필수 자격 요건만 정리하세요. - 3. preferredQualifications는 우대 사항만 정리하세요. - 4. summary는 2~3문장으로 포지션 소개를 작성하세요. - 5. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요. + 1. postingName은 공고 제목으로 사용할 수 있게 회사명과 직무명을 반영해 간결하게 작성하세요. + 2. jobTitle은 직무명만 작성하세요. + 3. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요. + 4. requirements는 필수 자격 요건만 정리하세요. + 5. preferredQualifications는 우대 사항만 정리하세요. + 6. summary는 2~3문장으로 포지션 소개를 작성하세요. + 7. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요. [회사명] %s @@ -678,6 +682,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate } return new JobPostingGenerateResponse( + defaultIfBlank(response.postingName(), request.jobTitleHint()), companyName, defaultString(response.jobTitle()), defaultString(response.task()), @@ -804,6 +809,7 @@ private JobPostingClassificationResultResponse normalizeClassificationResponse( private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) { return new JobPostingGenerateResponse( + defaultString(request.jobTitleHint()), request.companyName(), defaultString(request.jobTitleHint()), defaultString(request.mainResponsibilities()), @@ -872,6 +878,10 @@ private String defaultString(String value) { return value == null ? "" : value; } + private String defaultIfBlank(String value, String fallback) { + return value == null || value.isBlank() ? defaultString(fallback) : value; + } + private RetrievalContext emptyRetrievalContext() { return new RetrievalContext(List.of(), List.of()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java index cc134a37..43759490 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncCancelResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; @@ -19,12 +20,13 @@ public class JobPostingAsyncFacadeService { private final JobPostingAsyncTaskService jobPostingAsyncTaskService; private final JobPostingAsyncProcessor jobPostingAsyncProcessor; private final UserService userService; + private final JobPostingImageStorageService jobPostingImageStorageService; public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); + JobPostingIngestCommand command = snapshot(validatedUser, request); JobPostingAsyncTask task = jobPostingAsyncTaskService.createPendingTask(validatedUser.getId()); String taskId = task.getTaskId(); - JobPostingIngestCommand command = snapshot(validatedUser, request); try { jobPostingAsyncProcessor.process(taskId, command, task.getMaxRetryCount()); @@ -51,11 +53,22 @@ public JobPostingAsyncStatusResponse getTaskInternal(String taskId) { return jobPostingAsyncTaskService.getTask(taskId); } + public JobPostingAsyncCancelResponse cancel(User user, String taskId) { + User validatedUser = userService.validateUser(user); + return jobPostingAsyncTaskService.cancelTask(validatedUser, taskId); + } + private JobPostingIngestCommand snapshot(User user, JobPostingIngestRequest request) { + var imageObjectKeys = jobPostingImageStorageService.normalizeImageObjectKeys( + request.imageObjectKey(), + request.imageObjectKeys() + ); + JobPostingIngestInputValidator.validate(request.rawText(), imageObjectKeys); return JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .imageObjectKey(request.imageObjectKey()) + .imageObjectKey(imageObjectKeys.isEmpty() ? null : imageObjectKeys.get(0)) + .imageObjectKeys(imageObjectKeys) .build(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java index 9a1aebce..ec18f267 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java @@ -41,6 +41,6 @@ private String channelKey(String taskId) { private boolean isTerminal(JobPostingAsyncStatusResponse statusResponse) { TaskStatus status = TaskStatus.valueOf(statusResponse.getStatus()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED; + return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index 969ee4d0..90a54da5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.jobposting.service; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncCancelResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingProgressStepResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; @@ -14,6 +16,9 @@ import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator.AsyncTaskProgressStatus; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator.ProgressStepDefinition; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -28,12 +33,22 @@ import java.time.LocalDateTime; import java.util.EnumSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; @Service @RequiredArgsConstructor @Slf4j public class JobPostingAsyncTaskService { + private static final int DEFAULT_ESTIMATED_REMAINING_SECONDS = 20; + private static final List PROGRESS_STEPS = List.of( + new ProgressStepDefinition("VALIDATING_INPUT", "공고 입력값을 확인하고 있어요"), + new ProgressStepDefinition("DOWNLOADING_IMAGES", "이미지를 준비하고 있어요"), + new ProgressStepDefinition("EXTRACTING_CONTENT", "공고 내용을 추출하고 있어요"), + new ProgressStepDefinition("STRUCTURING_JOB_POSTING", "공고 정보를 정리하고 있어요"), + new ProgressStepDefinition("SAVING_RESULT", "공고 분석 결과를 저장하고 있어요"), + new ProgressStepDefinition("COMPLETED", "공고 분석이 완료되었습니다") + ); private final JobPostingAsyncTaskRepository jobPostingAsyncTaskRepository; private final ObjectMapper objectMapper; @@ -41,6 +56,7 @@ public class JobPostingAsyncTaskService { private final NotificationService notificationService; private final AsyncMetricsRecorder asyncMetricsRecorder; private final JobPostingQueueProperties jobPostingQueueProperties; + private final AsyncProgressCalculator asyncProgressCalculator; @Transactional public JobPostingAsyncTask createPendingTask(Long userId) { @@ -79,6 +95,12 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); } + if (task.getStatus() == TaskStatus.CANCELLED) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId + ); + } task.markSuccess(serializeResult(result)); recordProcessingMetric(task, "succeeded"); publishAfterCommit(toStatusResponse(task)); @@ -109,6 +131,27 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM createFailureNotificationSafely(task); } + @Transactional + public JobPostingAsyncCancelResponse cancelTask(User user, String taskId) { + JobPostingAsyncTask task = getOwnedTaskState(user, taskId); + TaskStatus previousStatus = task.getStatus(); + LocalDateTime previousCancelledAt = task.getCancelledAt(); + task.requestCancel(); + boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + if (newlyCancelled) { + recordProcessingMetric(task, "cancelled"); + } + if (newlyCancelled || previousCancelledAt == null && cancelled) { + publishAfterCommit(toStatusResponse(task)); + } + return new JobPostingAsyncCancelResponse( + task.getTaskId(), + task.getStatus().name(), + task.getMessage() + ); + } + @Transactional public void updateWorkerMetadata(String taskId, String workerId, Long queueLatencyMillis) { getTaskState(taskId).updateWorkerMetadata(workerId, queueLatencyMillis); @@ -163,10 +206,35 @@ private JobPostingAsyncStatusResponse toStatusResponse(JobPostingAsyncTask taskS .lastAttemptAt(taskState.getLastAttemptAt()) .startedAt(taskState.getStartedAt()) .completedAt(taskState.getCompletedAt()) + .cancelRequested(taskState.isCancelRequested()) + .cancelledAt(taskState.getCancelledAt()) + .currentStep(resolveCurrentStep(taskState)) + .progressPercent(asyncProgressCalculator.resolveProgressPercent( + toProgressStatus(taskState.getStatus()), + taskState.getProgressPercent() + )) + .estimatedRemainingSeconds(asyncProgressCalculator.resolveEstimatedRemainingSeconds( + toProgressStatus(taskState.getStatus()), + taskState.getEstimatedRemainingSeconds(), + taskState.getStartedAt(), + DEFAULT_ESTIMATED_REMAINING_SECONDS + )) + .steps(buildSteps(taskState)) .result(deserializeResult(taskState.getResultPayload())) .build(); } + private JobPostingAsyncTask getOwnedTaskState(User user, String taskId) { + if (user.getRole() == UserRole.ADMIN) { + return getTaskState(taskId); + } + return jobPostingAsyncTaskRepository.findByTaskIdAndUserId(taskId, user.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.JOB_POSTING_ASYNC_TASK_NOT_FOUND, + "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + private JobPostingAsyncTask getTaskState(String taskId) { return jobPostingAsyncTaskRepository.findById(taskId) .orElseThrow(() -> new GeneralException( @@ -176,7 +244,9 @@ private JobPostingAsyncTask getTaskState(String taskId) { } private boolean isTerminal(JobPostingAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED; + return task.getStatus() == TaskStatus.SUCCEEDED + || task.getStatus() == TaskStatus.FAILED + || task.getStatus() == TaskStatus.CANCELLED; } private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { @@ -225,6 +295,27 @@ private void recordProcessingMetric(JobPostingAsyncTask task, String outcome) { asyncMetricsRecorder.recordProcessing("jobposting", outcome, durationMillis); } + private String resolveCurrentStep(JobPostingAsyncTask task) { + return asyncProgressCalculator.resolveCurrentStep( + toProgressStatus(task.getStatus()), + task.getCurrentStep(), + "VALIDATING_INPUT" + ); + } + + private List buildSteps(JobPostingAsyncTask task) { + return asyncProgressCalculator.buildSteps( + toProgressStatus(task.getStatus()), + resolveCurrentStep(task), + PROGRESS_STEPS, + step -> new JobPostingProgressStepResponse(step.code(), step.label(), step.status()) + ); + } + + private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + return AsyncTaskProgressStatus.valueOf(status.name()); + } + private String serializeResult(JobPostingIngestResponse result) { try { return objectMapper.writeValueAsString(result); @@ -326,4 +417,5 @@ private void createFailureNotification(JobPostingAsyncTask task) { payload ); } + } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java index 9cb19419..1cb32dfc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java @@ -12,20 +12,23 @@ import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import software.amazon.awssdk.services.s3.model.S3Exception; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; @Service public class JobPostingImageStorageService { private static final String BASE_DIR = "job-postings/tmp"; + private static final int MAX_IMAGE_COUNT = 2; private static final Map CONTENT_TYPE_TO_EXTENSION = Map.of( "image/png", "png", "image/jpeg", "jpg", - "image/jpg", "jpg", - "image/webp", "webp", - "image/gif", "gif" + "image/jpg", "jpg" ); private final S3ObjectUrlService s3ObjectUrlService; @@ -55,7 +58,7 @@ public JobPostingImageUploadPresignResponse createUploadPresignUrl( if (extension == null) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + "지원하는 이미지 형식은 png, jpg, jpeg 입니다." ); } @@ -82,6 +85,43 @@ public String createReadableImageUrl(Long userId, String objectKey) { return s3ObjectUrlService.createPresignedGetUrl(objectKey); } + public List createReadableImageUrls(Long userId, List objectKeys) { + List normalizedObjectKeys = normalizeImageObjectKeys(null, objectKeys); + List imageUrls = new ArrayList<>(); + for (String objectKey : normalizedObjectKeys) { + imageUrls.add(createReadableImageUrl(userId, objectKey)); + } + return imageUrls; + } + + public List normalizeImageObjectKeys(String imageObjectKey, List imageObjectKeys) { + Set normalized = new LinkedHashSet<>(); + if (imageObjectKey != null && !imageObjectKey.isBlank()) { + normalized.add(imageObjectKey.trim()); + } + if (imageObjectKeys != null) { + for (String objectKey : imageObjectKeys) { + if (objectKey == null || objectKey.isBlank()) { + continue; + } + String trimmed = objectKey.trim(); + if (!normalized.add(trimmed)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "동일한 이미지 objectKey를 중복으로 전달할 수 없습니다." + ); + } + } + } + if (normalized.size() > MAX_IMAGE_COUNT) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미지는 최대 2개까지 첨부할 수 있습니다." + ); + } + return List.copyOf(normalized); + } + public void validateOwnership(Long userId, String objectKey) { if (objectKey == null || objectKey.isBlank()) { return; @@ -144,11 +184,27 @@ private void validateUploadedObject(String objectKey) { if (!CONTENT_TYPE_TO_EXTENSION.containsKey(normalizedContentType)) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + "지원하는 이미지 형식은 png, jpg, jpeg 입니다." + ); + } + + String lowerObjectKey = objectKey.toLowerCase(Locale.ROOT); + if (!lowerObjectKey.endsWith(".png") + && !lowerObjectKey.endsWith(".jpg") + && !lowerObjectKey.endsWith(".jpeg")) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 확장자는 png, jpg, jpeg 입니다." ); } Long contentLength = headObject.contentLength(); + if (contentLength != null && contentLength <= 0) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "빈 이미지 파일은 사용할 수 없습니다." + ); + } if (contentLength != null && contentLength > maxImageSizeBytes) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidator.java new file mode 100644 index 00000000..c67824c0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidator.java @@ -0,0 +1,52 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; + +import java.util.List; + +public final class JobPostingIngestInputValidator { + + private static final int MIN_RAW_TEXT_NON_WHITESPACE_LENGTH = 10; + private static final int MAX_RAW_TEXT_LENGTH = 10_000; + + private JobPostingIngestInputValidator() { + } + + public static void validate(String rawText, List imageObjectKeys) { + boolean hasRawText = hasText(rawText); + boolean hasImage = imageObjectKeys != null && !imageObjectKeys.isEmpty(); + + if (!hasRawText && !hasImage) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "공고 텍스트를 입력하거나 채용 공고 이미지를 첨부해주세요." + ); + } + if (rawText != null && rawText.length() > MAX_RAW_TEXT_LENGTH) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "공고 내용은 최대 10,000자까지 입력할 수 있습니다." + ); + } + if (!hasImage && nonWhitespaceLength(rawText) < MIN_RAW_TEXT_NON_WHITESPACE_LENGTH) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "정확한 분석을 위해 공고 내용을 최소 10자 이상 입력해주세요." + ); + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static int nonWhitespaceLength(String value) { + if (value == null) { + return 0; + } + return (int) value.codePoints() + .filter(codePoint -> !Character.isWhitespace(codePoint)) + .count(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java index 4c5114ee..5fca20f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java @@ -2,17 +2,19 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse.InvalidField; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Set; final class JobPostingIngestQualityValidator { - private static final double MIN_EXTRACT_CONFIDENCE = 0.3; private static final int MIN_SHORT_FIELD_LENGTH = 2; - private static final int MIN_DESCRIPTION_LENGTH = 5; private static final Set PLACEHOLDER_VALUES = Set.of( "string", "null", @@ -34,41 +36,59 @@ private JobPostingIngestQualityValidator() { static void validateExtracted(JobPostingExtractResponse extracted) { if (extracted == null) { - throwInvalidJobPosting(); + throwInvalidJobPostingWithFields(List.of( + invalidField("companyName", "회사명"), + invalidField("jobTitle", "직무") + )); } if (Double.isNaN(extracted.confidence()) || Double.isInfinite(extracted.confidence()) - || extracted.confidence() < MIN_EXTRACT_CONFIDENCE + || extracted.confidence() < 0.0 || extracted.confidence() > 1.0) { throwInvalidJobPosting(); } - validateField(extracted.companyName(), MIN_SHORT_FIELD_LENGTH); - validateField(extracted.jobTitle(), MIN_SHORT_FIELD_LENGTH); - validateField(extracted.task(), MIN_DESCRIPTION_LENGTH); - validateField(extracted.requirements(), MIN_DESCRIPTION_LENGTH); + validateRequiredFields(List.of( + field("companyName", "회사명", extracted.companyName()), + field("jobTitle", "직무", extracted.jobTitle()) + )); } static void validateGenerated(JobPostingGenerateResponse generated) { if (generated == null) { - throwInvalidJobPosting(); + throwInvalidJobPostingWithFields(List.of( + invalidField("postingName", "공고명"), + invalidField("companyName", "회사명"), + invalidField("jobTitle", "직무") + )); } - validateField(generated.companyName(), MIN_SHORT_FIELD_LENGTH); - validateField(generated.jobTitle(), MIN_SHORT_FIELD_LENGTH); - validateField(generated.task(), MIN_DESCRIPTION_LENGTH); - validateField(generated.requirements(), MIN_DESCRIPTION_LENGTH); + validateRequiredFields(List.of( + field("postingName", "공고명", generated.postingName()), + field("companyName", "회사명", generated.companyName()), + field("jobTitle", "직무", generated.jobTitle()) + )); } - private static void validateField(String value, int minLength) { - if (value == null) { - throwInvalidJobPosting(); + private static void validateRequiredFields(List candidates) { + List invalidFields = new ArrayList<>(); + for (FieldCandidate candidate : candidates) { + if (isInvalidRequiredField(candidate.value())) { + invalidFields.add(invalidField(candidate.field(), candidate.label())); + } } - String normalized = value.trim(); - if (normalized.length() < minLength || isPlaceholder(normalized)) { - throwInvalidJobPosting(); + if (!invalidFields.isEmpty()) { + throwInvalidJobPostingWithFields(invalidFields); } } + private static boolean isInvalidRequiredField(String value) { + if (value == null) { + return true; + } + String normalized = value.trim(); + return normalized.length() < MIN_SHORT_FIELD_LENGTH || isPlaceholder(normalized); + } + private static boolean isPlaceholder(String value) { String normalized = value.trim().toLowerCase(Locale.ROOT); return PLACEHOLDER_VALUES.contains(normalized) || normalized.matches("string\\d*"); @@ -80,4 +100,31 @@ private static void throwInvalidJobPosting() { "채용 공고로 인식할 수 없는 입력입니다. 공고 내용을 확인해주세요." ); } + + private static void throwInvalidJobPostingWithFields(List invalidFields) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "채용 공고 필수 정보를 인식하지 못했습니다. 공고 내용을 확인해주세요.", + new JobPostingIngestValidationErrorResponse( + "INVALID_JOB_POSTING_FIELDS", + "공고명, 회사명, 직무 정보를 확인해주세요.", + invalidFields + ) + ); + } + + private static FieldCandidate field(String field, String label, String value) { + return new FieldCandidate(field, label, value); + } + + private static InvalidField invalidField(String field, String label) { + return new InvalidField(field, label, label + "을(를) 인식하지 못했습니다."); + } + + private record FieldCandidate( + String field, + String label, + String value + ) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index 5c71694a..181fee21 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -33,12 +33,19 @@ public class JobPostingIngestService { private final JobPostingClassificationService jobPostingClassificationService; private final JobPostingService jobPostingService; private final UserService userService; + private final JobPostingImageStorageService jobPostingImageStorageService; public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestRequest request) { + List imageObjectKeys = jobPostingImageStorageService.normalizeImageObjectKeys( + request.imageObjectKey(), + request.imageObjectKeys() + ); + JobPostingIngestInputValidator.validate(request.rawText(), imageObjectKeys); JobPostingIngestCommand command = JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .imageObjectKey(request.imageObjectKey()) + .imageObjectKey(imageObjectKeys.isEmpty() ? null : imageObjectKeys.get(0)) + .imageObjectKeys(imageObjectKeys) .build(); return ingestAndCreate(command); } @@ -48,7 +55,8 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( command.getUserId(), command.getRawText(), - command.getImageObjectKey() + command.getImageObjectKey(), + command.getImageObjectKeys() ); JobPostingIngestQualityValidator.validateExtracted(extracted); @@ -97,7 +105,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) resolveUser(command), new JobPostingCreateRequest( JobPostingProfileColor.DEFAULT, - generated.jobTitle(), + generated.postingName(), fallbackCompanyName(extracted.companyName()), null, generated.jobTitle(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 0ab856bc..4ed58f04 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -44,6 +44,10 @@ public class JobPostingWorkerBridgeService { private final WorkerTaskResultService workerTaskResultService; public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + JobPostingAsyncTask task = getTask(taskId); + if (isTerminal(task)) { + return; + } jobPostingAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); try (var ignored = LoggingContext.with("worker.task.running", null, workerContext(taskId, "JOB_POSTING_INGEST", workerId, retryCount, null))) { log.info("Job posting worker marked task as running"); @@ -53,6 +57,8 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResponse result) { // Legacy direct-complete path for older workers. New workers should prefer result -> finalize. + JobPostingAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId); workerTaskResultService.upsertGenerated(TaskType.JOB_POSTING_COMPLETE, taskId, result); JobPostingIngestResponse response = jobPostingAsyncTaskService.markSuccess(taskId, result); workerTaskResultService.markDeliveredIfPresent(TaskType.JOB_POSTING_COMPLETE, taskId); @@ -72,7 +78,7 @@ public void markRetry( Long queueLatencyMillis ) { JobPostingAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (isTerminal(task)) { return; } jobPostingAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); @@ -92,7 +98,7 @@ public void failTask( Long queueLatencyMillis ) { JobPostingAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (isTerminal(task)) { return; } jobPostingAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); @@ -106,6 +112,14 @@ public String createReadableImageUrl(Long userId, String imageObjectKey) { return jobPostingImageStorageService.createReadableImageUrl(userId, imageObjectKey); } + public List createReadableImageUrls(Long userId, String imageObjectKey, List imageObjectKeys) { + List normalizedImageObjectKeys = jobPostingImageStorageService.normalizeImageObjectKeys( + imageObjectKey, + imageObjectKeys + ); + return jobPostingImageStorageService.createReadableImageUrls(userId, normalizedImageObjectKeys); + } + public List findCandidates(JobPostingExtractResponse extracted) { return jobPostingClassificationService.findCandidates(extracted, 5); } @@ -129,15 +143,19 @@ public JobPostingIngestResponse finalizeAndComplete( workerTaskResultService.markDeliveredIfPresent(TaskType.JOB_POSTING_FINALIZE, taskId); return jobPostingAsyncTaskService.getTask(taskId).getResult(); } - if (task.getStatus() == TaskStatus.FAILED) { + if (task.getStatus() == TaskStatus.FAILED || task.getStatus() == TaskStatus.CANCELLED) { workerTaskResultService.markDeliveryFailedIfPresent( TaskType.JOB_POSTING_FINALIZE, taskId, - "이미 실패 처리된 채용 공고 비동기 작업입니다." + task.getStatus() == TaskStatus.CANCELLED + ? "취소된 채용 공고 비동기 작업입니다." + : "이미 실패 처리된 채용 공고 비동기 작업입니다." ); throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId + task.getStatus() == TaskStatus.CANCELLED + ? "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId + : "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); } JobPostingIngestQualityValidator.validateExtracted(extracted); @@ -153,7 +171,7 @@ public JobPostingIngestResponse finalizeAndComplete( userService.getUser(userId), new JobPostingCreateRequest( JobPostingProfileColor.DEFAULT, - generated.jobTitle(), + generated.postingName(), fallbackCompanyName(extracted.companyName()), null, generated.jobTitle(), @@ -184,6 +202,7 @@ public JobPostingIngestResponse finalizeAndComplete( @Transactional public void storeFinalizeResult(String taskId, JobPostingWorkerResultStoreRequest request) { JobPostingAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId); if (!task.getUserId().equals(request.userId())) { throw new GeneralException( GeneralErrorCode.FORBIDDEN, @@ -216,6 +235,18 @@ private JobPostingAsyncTask getTask(String taskId) { )); } + private boolean isTerminal(JobPostingAsyncTask task) { + return task.getStatus() == TaskStatus.SUCCEEDED + || task.getStatus() == TaskStatus.FAILED + || task.getStatus() == TaskStatus.CANCELLED; + } + + private void rejectIfCancelled(JobPostingAsyncTask task, String message) { + if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + } + private String fallbackCompanyName(String companyName) { if (companyName == null || companyName.isBlank()) { return "미분류 회사"; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index 5a8c17eb..030f82d7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -3,10 +3,12 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateActualRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockFromJobPostingRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; +import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyUpdateNameRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyUpdateNameResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; @@ -27,6 +29,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -112,6 +115,22 @@ public ApiResponse deleteMockApply( return ApiResponse.onSuccess("모의 서류 지원이 삭제되었습니다.", null); } + @Operation( + summary = "모의 서류 지원 이름 변경", + description = "대시보드 카드에서 표시할 모의 서류 지원 이름을 변경합니다." + ) + @PatchMapping("/{mockApplyId}/name") + public ApiResponse updateMockApplyName( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId, + @Valid @RequestBody MockApplyUpdateNameRequest request + ) { + return ApiResponse.onSuccess( + "모의 서류 지원 이름이 변경되었습니다.", + mockApplyService.updateMockApplyName(userDetails.getUser(), mockApplyId, request.name()) + ); + } + @Operation( summary = "실제 공고 기반 모의 서류 지원 생성", description = "공고 텍스트/URL 추출, 공고 저장, 사용자 확인 및 수정이 선행된 뒤 저장된 채용 공고 ID를 기준으로 로그인 사용자의 ACTUAL 타입 모의 서류 지원을 생성합니다." diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyUpdateNameRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyUpdateNameRequest.java new file mode 100644 index 00000000..ad6be16f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyUpdateNameRequest.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record MockApplyUpdateNameRequest( + @NotBlank(message = "이름은 필수입니다.") + String name +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java index eb38baa5..bf46381d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java @@ -13,6 +13,7 @@ public record MockApplyHomeItemResponse( Long mockApplyId, String resumePath, Long jobPostingId, + String displayName, int sequence, MockApplyStatus status, String companyName, @@ -32,6 +33,7 @@ public static MockApplyHomeItemResponse from(MockApply mockApply) { mockApply.getId(), resumePath(mockApply), jobPosting.getId(), + mockApply.getDisplayName(), mockApply.getSequence() == null ? 1 : mockApply.getSequence(), mockApply.getStatus(), jobPosting.getCompany().getName(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyUpdateNameResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyUpdateNameResponse.java new file mode 100644 index 00000000..acb84a0b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyUpdateNameResponse.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; + +import java.time.LocalDateTime; + +public record MockApplyUpdateNameResponse( + Long mockApplyId, + String name, + LocalDateTime updatedAt +) { + public static MockApplyUpdateNameResponse from(MockApply mockApply) { + return new MockApplyUpdateNameResponse( + mockApply.getId(), + mockApply.getDisplayName(), + mockApply.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java index 190ae18c..7a5b32d5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApply.java @@ -47,6 +47,9 @@ public class MockApply extends BaseEntity { private Integer sequence; + @Column(name = "display_name", length = 100) + private String displayName; + @OneToOne(mappedBy = "mockApply", cascade = CascadeType.ALL, orphanRemoval = true) private Analysis analysis; @@ -72,6 +75,10 @@ public void updateStatus(MockApplyStatus status) { this.status = status; } + public void updateDisplayName(String displayName) { + this.displayName = displayName; + } + public Question addQuestion(String content, int limit, String answer) { Question question = Question.create(this, content, limit, answer); this.questions.add(question); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 2bc4b21b..c66f8852 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -22,6 +22,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyUpdateNameResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; @@ -52,6 +53,7 @@ public class MockApplyService { public static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE; private static final int SEQUENCE_SAVE_MAX_RETRY = 5; private static final int SEQUENCE_ALLOCATE_MAX_RETRY = 5; + private static final int MAX_DISPLAY_NAME_LENGTH = 100; private static final String SEQUENCE_UNIQUE_CONSTRAINT = "uk_mock_apply_user_posting_sequence"; private static final String UNIQUE_VIOLATION_SQL_STATE = "23505"; @@ -239,6 +241,17 @@ public MockApplyHomeResponse getMyMockApplies(User user, int page, int size) { ); } + @Transactional + @AuditLogEvent(action = "MOCK_APPLY_NAME_UPDATE", targetType = "MOCK_APPLY", targetId = "#arg1") + public MockApplyUpdateNameResponse updateMockApplyName(User user, Long mockApplyId, String name) { + User validatedUser = userService.validateUser(user); + MockApply mockApply = getOwnedMockApply(validatedUser, mockApplyId); + String trimmedName = validateAndTrimDisplayName(name); + mockApply.updateDisplayName(trimmedName); + MockApply savedMockApply = mockApplyRepository.saveAndFlush(mockApply); + return MockApplyUpdateNameResponse.from(savedMockApply); + } + @Transactional @AuditLogEvent(action = "MOCK_APPLY_DELETE", targetType = "MOCK_APPLY", targetId = "#arg1") public void deleteMockApply(User user, Long mockApplyId) { @@ -279,6 +292,23 @@ private MockApply getOwnedMockApplyWithJobPosting(User user, Long mockApplyId) { return mockApply; } + private String validateAndTrimDisplayName(String name) { + if (name == null || name.isBlank()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이름은 필수입니다." + ); + } + String trimmedName = name.trim(); + if (trimmedName.length() > MAX_DISPLAY_NAME_LENGTH) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이름은 최대 100자까지 입력할 수 있습니다." + ); + } + return trimmedName; + } + private int resolveSequence(User user, JobPosting jobPosting, Integer requestedSequence) { if (isPositiveSequence(requestedSequence)) { return requestedSequence; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java index fa4e1604..d5637214 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java @@ -1,9 +1,11 @@ package com.jobdri.jobdri_api.domain.payment.controller; +import com.jobdri.jobdri_api.domain.payment.dto.request.CouponRedeemRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.service.CouponService; import com.jobdri.jobdri_api.domain.payment.service.PaymentService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.security.UserDetailsImpl; @@ -23,6 +25,7 @@ public class PaymentController { private final PaymentService paymentService; + private final CouponService couponService; @Operation(summary = "크레딧 가격 플랜 조회", description = "구매 가능한 크레딧 플랜 목록을 조회합니다.") @GetMapping("/plans") @@ -54,6 +57,18 @@ public ApiResponse confirm( ); } + @Operation(summary = "쿠폰으로 크레딧 충전", description = "쿠폰 번호를 검증하고 중복 사용을 방지한 뒤 크레딧 1회를 충전합니다.") + @PostMapping("/coupons/redeem") + public ApiResponse redeemCoupon( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody CouponRedeemRequest request + ) { + return ApiResponse.onSuccess( + "쿠폰이 등록되었습니다.", + couponService.redeem(userDetails.getUser(), request) + ); + } + @Operation(summary = "내 크레딧 잔액 조회", description = "로그인 사용자의 현재 크레딧 잔액을 조회합니다.") @GetMapping("/credits/me") public ApiResponse getBalance( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/CouponRedeemRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/CouponRedeemRequest.java new file mode 100644 index 00000000..2213dbe9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/request/CouponRedeemRequest.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.payment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record CouponRedeemRequest( + @NotBlank(message = "couponCode는 필수입니다.") + @Pattern( + regexp = "^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$", + message = "couponCode는 XXXX-XXXX-XXXX 형식이어야 합니다." + ) + String couponCode +) { + public CouponRedeemRequest { + if (couponCode != null) { + couponCode = couponCode.trim(); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CouponRedeemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CouponRedeemResponse.java new file mode 100644 index 00000000..87cbd105 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CouponRedeemResponse.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.payment.dto.response; + +import java.time.LocalDateTime; + +public record CouponRedeemResponse( + String couponCode, + int creditAmount, + int creditBalance, + LocalDateTime redeemedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CouponRedemption.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CouponRedemption.java new file mode 100644 index 00000000..da8a3fdb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CouponRedemption.java @@ -0,0 +1,45 @@ +package com.jobdri.jobdri_api.domain.payment.entity; + +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.entity.CreatedAtEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table( + name = "coupon_redemptions", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_coupon_redemptions_user_coupon_code", + columnNames = {"user_id", "coupon_code"} + ) + } +) +public class CouponRedemption extends CreatedAtEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, name = "coupon_code", length = 14) + private String couponCode; + + @Column(nullable = false) + private int creditAmount; + + public static CouponRedemption create(User user, String couponCode, int creditAmount) { + return CouponRedemption.builder() + .user(user) + .couponCode(couponCode) + .creditAmount(creditAmount) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CouponRedemptionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CouponRedemptionRepository.java new file mode 100644 index 00000000..f96edab0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CouponRedemptionRepository.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.payment.repository; + +import com.jobdri.jobdri_api.domain.payment.entity.CouponRedemption; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponRedemptionRepository extends JpaRepository { + Optional findByUserIdAndCouponCode(Long userId, String couponCode); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CouponService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CouponService.java new file mode 100644 index 00000000..2ec16891 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CouponService.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.dto.request.CouponRedeemRequest; +import com.jobdri.jobdri_api.domain.payment.dto.response.CouponRedeemResponse; +import com.jobdri.jobdri_api.domain.payment.entity.CouponRedemption; +import com.jobdri.jobdri_api.domain.payment.repository.CouponRedemptionRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Locale; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponService { + + private static final int COUPON_CREDIT_AMOUNT = 1; + + private final UserService userService; + private final CouponRedemptionRepository couponRedemptionRepository; + private final CreditService creditService; + + @Value("${payment.coupon.code:}") + private String couponCode; + + @Transactional + public CouponRedeemResponse redeem(User user, CouponRedeemRequest request) { + User validatedUser = userService.validateUser(user); + String normalizedCouponCode = normalizeCouponCode(request.couponCode()); + validateCouponConfiguration(); + validateCouponCode(normalizedCouponCode); + + try { + CouponRedemption redemption = couponRedemptionRepository.saveAndFlush( + CouponRedemption.create(validatedUser, normalizedCouponCode, COUPON_CREDIT_AMOUNT) + ); + int creditBalance = creditService.coupon( + validatedUser, + COUPON_CREDIT_AMOUNT, + "쿠폰 등록", + "coupon-redemption-" + redemption.getId() + ); + return new CouponRedeemResponse( + redemption.getCouponCode(), + redemption.getCreditAmount(), + creditBalance, + redemption.getCreatedAt() + ); + } catch (DataIntegrityViolationException e) { + throw new GeneralException(GeneralErrorCode.COUPON_ALREADY_REDEEMED, "이미 사용한 쿠폰입니다."); + } + } + + private void validateCouponConfiguration() { + if (!StringUtils.hasText(couponCode)) { + throw new GeneralException(GeneralErrorCode.INTERNAL_SERVER_ERROR, "쿠폰 설정이 올바르지 않습니다."); + } + } + + private void validateCouponCode(String inputCouponCode) { + String configuredCouponCode = normalizeCouponCode(couponCode); + if (!configuredCouponCode.equals(inputCouponCode)) { + throw new GeneralException(GeneralErrorCode.COUPON_INVALID, "유효하지 않은 쿠폰입니다."); + } + } + + private String normalizeCouponCode(String rawCouponCode) { + return rawCouponCode.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java index 9ce49140..9ec8c8d2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java @@ -34,6 +34,11 @@ public int refund(User user, int amount, String description, String referenceId) return apply(user, CreditTransactionType.REFUND, amount, description, referenceId); } + @Transactional + public int coupon(User user, int amount, String description, String referenceId) { + return apply(user, CreditTransactionType.COUPON, amount, description, referenceId); + } + private int apply(User user, CreditTransactionType type, int amount, String description, String referenceId) { validatePositiveAmount(amount); validateReferenceId(referenceId); diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java index afd51718..8db1cef4 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/code/GeneralErrorCode.java @@ -52,6 +52,8 @@ public enum GeneralErrorCode implements BaseErrorCode { PAYMENT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "PAYMENT_4002", "이미 처리된 결제입니다."), PAYMENT_CONFIRM_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_5021", "결제 승인에 실패했습니다."), INSUFFICIENT_CREDIT(HttpStatus.PAYMENT_REQUIRED, "CREDIT_4021", "크레딧이 부족합니다."), + COUPON_INVALID(HttpStatus.BAD_REQUEST, "COUPON_4001", "유효하지 않은 쿠폰입니다."), + COUPON_ALREADY_REDEEMED(HttpStatus.CONFLICT, "COUPON_4091", "이미 사용한 쿠폰입니다."), // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java index bd560bd7..2cf04320 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java @@ -7,18 +7,28 @@ public class GeneralException extends RuntimeException { private final BaseErrorCode code; + private final Object error; public GeneralException(BaseErrorCode code) { this.code = code; + this.error = null; } public GeneralException(BaseErrorCode code, String message) { super(message); this.code = code; + this.error = null; + } + + public GeneralException(BaseErrorCode code, String message, Object error) { + super(message); + this.code = code; + this.error = error; } public GeneralException(BaseErrorCode code, String message, Throwable cause) { super(message, cause); this.code = code; + this.error = null; } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java index 498adefa..8082056a 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java @@ -45,7 +45,7 @@ public ResponseEntity> handleCustomException(GeneralExceptio } return ResponseEntity .status(code.getHttpStatus()) - .body(ApiResponse.onFailure(code, e.getMessage())); + .body(ApiResponse.onFailure(code, e.getError() != null ? e.getError() : e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/src/main/java/com/jobdri/jobdri_api/global/async/AsyncProgressCalculator.java b/src/main/java/com/jobdri/jobdri_api/global/async/AsyncProgressCalculator.java new file mode 100644 index 00000000..3b98fb23 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/async/AsyncProgressCalculator.java @@ -0,0 +1,104 @@ +package com.jobdri.jobdri_api.global.async; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Function; + +@Component +public class AsyncProgressCalculator { + + public String resolveCurrentStep(AsyncTaskProgressStatus status, String currentStep, String defaultStep) { + if (status == AsyncTaskProgressStatus.SUCCEEDED) { + return "COMPLETED"; + } + return currentStep == null || currentStep.isBlank() ? defaultStep : currentStep; + } + + public Integer resolveProgressPercent(AsyncTaskProgressStatus status, Integer progressPercent) { + if (status == AsyncTaskProgressStatus.SUCCEEDED) { + return 100; + } + if (status == AsyncTaskProgressStatus.FAILED || status == AsyncTaskProgressStatus.CANCELLED) { + return 0; + } + return progressPercent == null ? 0 : Math.max(0, Math.min(100, progressPercent)); + } + + public Integer resolveEstimatedRemainingSeconds( + AsyncTaskProgressStatus status, + Integer estimatedRemainingSeconds, + LocalDateTime startedAt, + int defaultEstimatedRemainingSeconds + ) { + if (status == AsyncTaskProgressStatus.SUCCEEDED + || status == AsyncTaskProgressStatus.FAILED + || status == AsyncTaskProgressStatus.CANCELLED) { + return 0; + } + if (estimatedRemainingSeconds != null) { + return Math.max(0, estimatedRemainingSeconds); + } + if (startedAt == null) { + return defaultEstimatedRemainingSeconds; + } + long elapsedSeconds = Math.max(0L, Duration.between(startedAt, LocalDateTime.now()).toSeconds()); + return (int) Math.max(0L, defaultEstimatedRemainingSeconds - elapsedSeconds); + } + + public List buildSteps( + AsyncTaskProgressStatus status, + String currentStep, + List steps, + Function mapper + ) { + int currentIndex = indexOfStep(currentStep, steps); + int effectiveCurrentIndex = currentIndex < 0 ? 0 : currentIndex; + return steps.stream() + .map(step -> new ProgressStep( + step.code(), + step.label(), + resolveStepStatus(status, effectiveCurrentIndex, indexOfStep(step.code(), steps)) + )) + .map(mapper) + .toList(); + } + + public int indexOfStep(String code, List steps) { + for (int i = 0; i < steps.size(); i++) { + if (steps.get(i).code().equals(code)) { + return i; + } + } + return -1; + } + + private String resolveStepStatus(AsyncTaskProgressStatus taskStatus, int currentIndex, int stepIndex) { + if (taskStatus == AsyncTaskProgressStatus.SUCCEEDED) { + return "COMPLETED"; + } + if (taskStatus == AsyncTaskProgressStatus.CANCELLED) { + return stepIndex <= currentIndex ? "CANCELLED" : "PENDING"; + } + if (taskStatus == AsyncTaskProgressStatus.FAILED) { + return stepIndex < currentIndex ? "COMPLETED" : stepIndex == currentIndex ? "FAILED" : "PENDING"; + } + return stepIndex < currentIndex ? "COMPLETED" : stepIndex == currentIndex ? "IN_PROGRESS" : "PENDING"; + } + + public enum AsyncTaskProgressStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED + } + + public record ProgressStepDefinition(String code, String label) { + } + + public record ProgressStep(String code, String label, String status) { + } +} diff --git a/src/main/resources/application-analysis-eval.yaml b/src/main/resources/application-analysis-eval.yaml index 53be5f7e..7b96cca6 100644 --- a/src/main/resources/application-analysis-eval.yaml +++ b/src/main/resources/application-analysis-eval.yaml @@ -74,6 +74,11 @@ evaluation: input: "" output: "" model: ${OPENAI_NLG_JUDGE_MODEL:gpt-4o-mini} + hybrid-merge: + enabled: false + single-pass-input: "" + two-pass-input: "" + output: "" jwt: secret: diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index b398a472..bf1258a2 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -161,6 +161,8 @@ cohere: key: ${COHERE_API_KEY:} payment: + coupon: + code: ${PAYMENT_COUPON_CODE:} toss: client-key: ${TOSS_CLIENT_KEY:} secret-key: ${TOSS_SECRET_KEY:} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index c4cd22c7..428f630b 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -162,6 +162,8 @@ cohere: key: ${COHERE_API_KEY:} payment: + coupon: + code: ${PAYMENT_COUPON_CODE} toss: client-key: ${TOSS_CLIENT_KEY} secret-key: ${TOSS_SECRET_KEY} diff --git a/src/main/resources/db/migration/V202607261530__add_mock_apply_display_name.sql b/src/main/resources/db/migration/V202607261530__add_mock_apply_display_name.sql new file mode 100644 index 00000000..0d61bc6a --- /dev/null +++ b/src/main/resources/db/migration/V202607261530__add_mock_apply_display_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE mock_applies +ADD COLUMN IF NOT EXISTS display_name VARCHAR(100); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6613bbf0..c82cc488 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -62,3 +62,33 @@ WHERE jp.detail_classification_id = dc.id UPDATE job_postings SET posting_name = job_title WHERE posting_name IS NULL OR posting_name = '미입력'; + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMP; + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS current_step VARCHAR(60); + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS progress_percent INTEGER; + +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS estimated_remaining_seconds INTEGER; + +ALTER TABLE IF EXISTS job_posting_async_tasks + ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE IF EXISTS job_posting_async_tasks + ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMP; + +ALTER TABLE IF EXISTS job_posting_async_tasks + ADD COLUMN IF NOT EXISTS current_step VARCHAR(60); + +ALTER TABLE IF EXISTS job_posting_async_tasks + ADD COLUMN IF NOT EXISTS progress_percent INTEGER; + +ALTER TABLE IF EXISTS job_posting_async_tasks + ADD COLUMN IF NOT EXISTS estimated_remaining_seconds INTEGER; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java index 1bc8e3b0..8874e67e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java @@ -139,6 +139,7 @@ void nlgJudgeRunnerIsPublicScannableComponent() { .run(context -> { assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); assertThat(context).hasSingleBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)) .containsExactly("nlgEvaluationRunner"); }); @@ -156,6 +157,7 @@ void analysisRunnerIsNotCreatedWhenAnalysisFlagIsFalse() { .run(context -> { assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); assertThat(context).doesNotHaveBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)).isEmpty(); }); } @@ -172,6 +174,7 @@ void analysisRunnerIsCreatedWhenAnalysisFlagIsTrue() { .run(context -> { assertThat(context).hasSingleBean(EvaluationAnalysisRunner.class); assertThat(context).doesNotHaveBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)) .containsExactly("evaluationAnalysisRunner"); }); @@ -189,6 +192,7 @@ void nlgRunnerIsCreatedWhenNlgJudgeFlagIsTrue() { .run(context -> { assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); assertThat(context).hasSingleBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)) .containsExactly("nlgEvaluationRunner"); verifyNoInteractions(context.getBean(EvaluationExitCoordinator.class)); @@ -207,10 +211,30 @@ void noRunnerIsCreatedWhenBothFlagsAreFalse() { .run(context -> { assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); assertThat(context).doesNotHaveBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)).isEmpty(); }); } + @Test + @DisplayName("analysis-eval + hybrid-merge.enabled=true이면 Hybrid merge Runner만 생성된다") + void hybridMergeRunnerIsCreatedWhenHybridMergeFlagIsTrue() { + scannedRunnerContext() + .withPropertyValues( + "spring.profiles.active=analysis-eval", + "evaluation.analysis.enabled=false", + "evaluation.nlg-judge.enabled=false", + "evaluation.hybrid-merge.enabled=true" + ) + .run(context -> { + assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); + assertThat(context).doesNotHaveBean(NlgEvaluationRunner.class); + assertThat(context).hasSingleBean(HybridExactMergeRunner.class); + assertThat(context.getBeanNamesForType(ApplicationRunner.class)) + .containsExactly("hybridExactMergeRunner"); + }); + } + @Test @DisplayName("두 플래그가 모두 true이면 설정 오류로 fail-fast 한다") void bothFlagsTrueFailsFast() { @@ -218,11 +242,54 @@ void bothFlagsTrueFailsFast() { .withPropertyValues( "spring.profiles.active=analysis-eval", "evaluation.analysis.enabled=true", - "evaluation.nlg-judge.enabled=true" + "evaluation.nlg-judge.enabled=true", + "evaluation.hybrid-merge.enabled=false" + ) + .run(context -> assertThat(context.getStartupFailure()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mutually exclusive")); + } + + @Test + @DisplayName("hybrid-merge와 다른 평가 Runner가 동시에 true이면 설정 오류로 fail-fast 한다") + void hybridMergeAndOtherRunnerFlagsTrueFailsFast() { + runnerContext() + .withPropertyValues( + "spring.profiles.active=analysis-eval", + "evaluation.analysis.enabled=false", + "evaluation.nlg-judge.enabled=true", + "evaluation.hybrid-merge.enabled=true" ) .run(context -> assertThat(context.getStartupFailure()) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("동시에 true")); + .hasMessageContaining("mutually exclusive")); + } + + @Test + @DisplayName("Hybrid merge Runner는 실행 실패 시 실패 종료를 요청하고 원래 예외를 전파한다") + void hybridMergeRunRequestsFailureExitAndRethrowsOriginalException() throws Exception { + HybridExactMergeService mergeService = mock(HybridExactMergeService.class); + EvaluationExitCoordinator exitCoordinator = mock(EvaluationExitCoordinator.class); + Environment environment = mock(Environment.class); + when(environment.getActiveProfiles()).thenReturn(new String[]{"analysis-eval"}); + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + Files.writeString(single, "caseId\nEV-01\n"); + Files.writeString(twoPass, "caseId\nEV-01\n"); + RuntimeException failure = new RuntimeException("merge failed"); + when(mergeService.merge(single, twoPass, output)).thenThrow(failure); + HybridExactMergeRunner runner = new HybridExactMergeRunner(mergeService, exitCoordinator, environment); + ReflectionTestUtils.setField(runner, "singlePassInputPath", single.toString()); + ReflectionTestUtils.setField(runner, "twoPassInputPath", twoPass.toString()); + ReflectionTestUtils.setField(runner, "outputPath", output.toString()); + ReflectionTestUtils.setField(runner, "analysisEvaluationEnabled", false); + ReflectionTestUtils.setField(runner, "nlgJudgeEnabled", false); + + assertThatThrownBy(() -> runner.run(new DefaultApplicationArguments())) + .isSameAs(failure); + + verify(exitCoordinator).exit("hybrid-exact-merge", 1); } @Test @@ -453,6 +520,7 @@ void noRunnerIsCreatedOutsideAnalysisEvalProfile() { .run(context -> { assertThat(context).doesNotHaveBean(EvaluationAnalysisRunner.class); assertThat(context).doesNotHaveBean(NlgEvaluationRunner.class); + assertThat(context).doesNotHaveBean(HybridExactMergeRunner.class); assertThat(context.getBeanNamesForType(ApplicationRunner.class)).isEmpty(); }); } @@ -486,6 +554,7 @@ void analysisEvalYamlDisablesDatabaseSideEffects() { assertThat(properties.getProperty("app.corpus.embedding.sync-on-startup")).isEqualTo("false"); assertThat(properties.getProperty("evaluation.analysis.enabled")).isEqualTo("false"); assertThat(properties.getProperty("evaluation.nlg-judge.enabled")).isEqualTo("false"); + assertThat(properties.getProperty("evaluation.hybrid-merge.enabled")).isEqualTo("false"); assertThat(properties.getProperty("payment.toss.client-key")).contains("dummy-evaluation-client-key"); } @@ -581,7 +650,12 @@ private ApplicationContextRunner scannedRunnerContext() { } @Configuration - @Import({EvaluationAnalysisRunner.class, NlgEvaluationRunner.class, EvaluationRunnerFlagValidator.class}) + @Import({ + EvaluationAnalysisRunner.class, + NlgEvaluationRunner.class, + HybridExactMergeRunner.class, + EvaluationRunnerFlagValidator.class + }) static class RunnerConditionTestConfig { @Bean EvaluationAnalysisBatchService evaluationAnalysisBatchService() { @@ -593,6 +667,11 @@ NlgEvaluationBatchService nlgEvaluationBatchService() { return mock(NlgEvaluationBatchService.class); } + @Bean + HybridExactMergeService hybridExactMergeService() { + return mock(HybridExactMergeService.class); + } + @Bean EvaluationExitCoordinator evaluationExitCoordinator() { return mock(EvaluationExitCoordinator.class); @@ -608,6 +687,7 @@ EvaluationExitCoordinator evaluationExitCoordinator() { classes = { EvaluationAnalysisRunner.class, NlgEvaluationRunner.class, + HybridExactMergeRunner.class, EvaluationRunnerFlagValidator.class } ) @@ -623,6 +703,11 @@ NlgEvaluationBatchService nlgEvaluationBatchService() { return mock(NlgEvaluationBatchService.class); } + @Bean + HybridExactMergeService hybridExactMergeService() { + return mock(HybridExactMergeService.class); + } + @Bean EvaluationExitCoordinator evaluationExitCoordinator() { return mock(EvaluationExitCoordinator.class); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java new file mode 100644 index 00000000..e824d952 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java @@ -0,0 +1,257 @@ +package com.jobdri.jobdri_api.domain.analysis.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HybridExactMergeServiceTest { + + @TempDir + Path tempDir; + + private final HybridExactMergeService service = new HybridExactMergeService(new ObjectMapper()); + + @Test + @DisplayName("caseId 기준으로 v5-A questionAnalyses와 two-pass missingKeywords를 exact merge한다") + void mergeUsesSinglePassFieldsAndTwoPassMissingKeywords() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(single, List.of(row( + "EV-01", + "90", + "[{\"keyword\":\"single\",\"source\":\"mainTask\"}]", + "[{\"questionId\":1,\"sentence\":\"문장, 쉼표\",\"status\":\"MENTIONED\",\"reason\":\"이유\",\"improvement\":null}]", + "{\"jobFit\":90,\"impact\":80,\"completeness\":70,\"feedback\":\"single\",\"keyStrengths\":[{\"title\":\"강점\",\"quote\":\"문장, 쉼표\"}],\"keyWeaknesses\":[],\"missingKeywords\":[],\"questionAnalyses\":[]}", + "" + ))); + writeTwoPassCsv(twoPass, List.of(row( + "EV-01", + "10", + "[{\"keyword\":\"장애 대응 경험\",\"source\":\"qualification\"}]", + "[]", + "{\"jobFit\":10,\"impact\":20,\"completeness\":30,\"feedback\":\"two-pass\",\"keyStrengths\":[],\"keyWeaknesses\":[],\"missingKeywords\":[{\"keyword\":\"장애 대응 경험\",\"source\":\"qualification\"}],\"questionAnalyses\":[]}", + "" + ))); + + HybridExactMergeService.HybridExactMergeSummary summary = service.merge(single, twoPass, output); + + assertThat(summary.singlePassCases()).isEqualTo(1); + assertThat(summary.twoPassCases()).isEqualTo(1); + assertThat(summary.mergedCases()).isEqualTo(1); + List> rows = EvaluationCsvSupport.read(output); + assertThat(rows).hasSize(1); + Map merged = rows.getFirst(); + assertThat(merged.get("aiScore")).isEqualTo("90"); + assertThat(merged.get("aiFeedback")).isEqualTo("single feedback"); + assertThat(merged.get("aiQuestionAnalysesJson")).contains("문장, 쉼표"); + assertThat(merged.get("aiMissingKeywordsJson")).contains("장애 대응 경험"); + assertThat(merged.get("aiMissingKeywordsJson")).doesNotContain("single"); + } + + @Test + @DisplayName("UTF-8 BOM과 CSV quote/newline을 포함한 입력도 병합한다") + void mergeSupportsBomAndQuotedFields() throws Exception { + Path single = tempDir.resolve("single-bom.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + Files.writeString( + single, + "\uFEFF" + singleHeader() + "\n" + + csv("EV-01") + "," + + csv("AI·개발·데이터") + "," + + csv("백엔드") + "," + + csv("70") + "," + + csv("70") + "," + + csv("60") + "," + + csv("80") + "," + + csv("single\nfeedback") + "," + + csv("[]") + "," + + csv("[]") + "," + + csv("{\"jobFit\":70,\"impact\":60,\"completeness\":80,\"feedback\":\"single\\nfeedback\",\"keyStrengths\":[],\"keyWeaknesses\":[],\"missingKeywords\":[],\"questionAnalyses\":[]}") + "," + + csv("") + "," + + csv("2026-07-26T10:00:00") + "\n", + StandardCharsets.UTF_8 + ); + writeTwoPassCsv(twoPass, List.of(row( + "EV-01", + "70", + "[{\"keyword\":\"장애 대응\\n경험\",\"source\":\"qualification\"}]", + "[]", + "{\"jobFit\":70,\"impact\":60,\"completeness\":80,\"feedback\":\"two-pass\",\"keyStrengths\":[],\"keyWeaknesses\":[],\"missingKeywords\":[],\"questionAnalyses\":[]}", + "" + ))); + + service.merge(single, twoPass, output); + + Map merged = EvaluationCsvSupport.read(output).getFirst(); + assertThat(merged.get("caseId")).isEqualTo("EV-01"); + assertThat(merged.get("aiFeedback")).isEqualTo("single\nfeedback"); + assertThat(merged.get("aiMissingKeywordsJson")).contains("장애 대응\\n경험"); + } + + @Test + @DisplayName("single-pass 또는 two-pass에 caseId가 누락되면 실패한다") + void mergeFailsOnCaseIdMismatch() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(single, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + writeTwoPassCsv(twoPass, List.of(row("EV-02", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("caseId mismatch"); + } + + @Test + @DisplayName("중복 caseId가 있으면 실패한다") + void mergeFailsOnDuplicateCaseId() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(single, List.of( + row("EV-01", "90", "[]", "[]", validRawJson(), ""), + row("EV-01", "80", "[]", "[]", validRawJson(), "") + )); + writeTwoPassCsv(twoPass, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate caseId"); + } + + @Test + @DisplayName("필수 JSON 필드가 파싱되지 않으면 실패한다") + void mergeFailsOnInvalidJson() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(single, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + writeTwoPassCsv(twoPass, List.of(row("EV-01", "90", "not-json", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("two-pass aiMissingKeywordsJson is not valid JSON"); + } + + @Test + @DisplayName("입력 row에 errorMessage가 있으면 병합하지 않는다") + void mergeFailsOnErrorRow() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(single, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), "single failed"))); + writeTwoPassCsv(twoPass, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("single-pass row has errorMessage") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("output이 single-pass 입력과 같으면 거부한다") + void mergeFailsWhenOutputOverwritesSinglePassInput() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + writeSinglePassCsv(single, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + writeTwoPassCsv(twoPass, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, single)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Hybrid exact output must not overwrite single-pass input"); + } + + @Test + @DisplayName("output이 two-pass 입력과 같으면 거부한다") + void mergeFailsWhenOutputOverwritesTwoPassInput() throws Exception { + Path single = tempDir.resolve("single.csv"); + Path twoPass = tempDir.resolve("two-pass.csv"); + writeSinglePassCsv(single, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + writeTwoPassCsv(twoPass, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(single, twoPass, twoPass)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Hybrid exact output must not overwrite two-pass input"); + } + + @Test + @DisplayName("single-pass 입력과 two-pass 입력이 같으면 거부한다") + void mergeFailsWhenInputFilesAreSame() throws Exception { + Path input = tempDir.resolve("same.csv"); + Path output = tempDir.resolve("hybrid.csv"); + writeSinglePassCsv(input, List.of(row("EV-01", "90", "[]", "[]", validRawJson(), ""))); + + assertThatThrownBy(() -> service.merge(input, input, output)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Hybrid exact input paths must be different") + .hasMessageContaining("single-pass input and two-pass input"); + } + + private void writeSinglePassCsv(Path path, List> rows) throws Exception { + EvaluationCsvSupport.writeRows(path, List.of(singleHeader().split(",")), rows); + } + + private void writeTwoPassCsv(Path path, List> rows) throws Exception { + EvaluationCsvSupport.writeRows(path, List.of(twoPassHeader().split(",")), rows); + } + + private String singleHeader() { + return "caseId,jobCategoryMiddle,jobCategorySmall,aiScore,aiJobFit,aiImpact,aiCompleteness,aiFeedback,aiMissingKeywordsJson,aiQuestionAnalysesJson,rawLlmResponseJson,errorMessage,createdAt"; + } + + private String twoPassHeader() { + return "caseId,jobCategoryMiddle,jobCategorySmall,aiScore,aiJobFit,aiImpact,aiCompleteness,aiFeedback,aiMissingKeywordsJson,aiQuestionAnalysesJson,rawLlmResponseJson,rawCandidateResponseJson,sanitizedCandidateResponseJson,candidateReviewResponseJson,errorMessage,createdAt"; + } + + private Map row( + String caseId, + String score, + String missingKeywordsJson, + String questionAnalysesJson, + String rawLlmResponseJson, + String errorMessage + ) { + Map row = new java.util.LinkedHashMap<>(); + row.put("caseId", caseId); + row.put("jobCategoryMiddle", "AI·개발·데이터"); + row.put("jobCategorySmall", "백엔드"); + row.put("aiScore", score); + row.put("aiJobFit", score); + row.put("aiImpact", "60"); + row.put("aiCompleteness", "70"); + row.put("aiFeedback", "single feedback"); + row.put("aiMissingKeywordsJson", missingKeywordsJson); + row.put("aiQuestionAnalysesJson", questionAnalysesJson); + row.put("rawLlmResponseJson", rawLlmResponseJson); + row.put("rawCandidateResponseJson", "{}"); + row.put("sanitizedCandidateResponseJson", "{}"); + row.put("candidateReviewResponseJson", "{}"); + row.put("errorMessage", errorMessage); + row.put("createdAt", "2026-07-26T10:00:00"); + return row; + } + + private String validRawJson() { + return "{\"jobFit\":70,\"impact\":60,\"completeness\":80,\"feedback\":\"ok\",\"keyStrengths\":[],\"keyWeaknesses\":[],\"missingKeywords\":[],\"questionAnalyses\":[]}"; + } + + private String csv(String value) { + String safeValue = value == null ? "" : value; + if (safeValue.contains(",") || safeValue.contains("\"") || safeValue.contains("\n") || safeValue.contains("\r")) { + return "\"" + safeValue.replace("\"", "\"\"") + "\""; + } + return safeValue; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java index 7b3421c4..13d75e00 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClientTest.java @@ -18,11 +18,13 @@ import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -37,6 +39,127 @@ class AnalysisAiClientTest { new ObjectMapper() ); + @Test + @DisplayName("analysis.mode가 없으면 기존 two-pass boolean으로 분석 모드를 해석한다") + void resolveAnalysisModeFallsBackToTwoPassBoolean() { + ReflectionTestUtils.setField(analysisAiClient, "analysisMode", ""); + ReflectionTestUtils.setField(analysisAiClient, "twoPassEnabled", false); + assertThat(analysisAiClient.resolveAnalysisMode()).isEqualTo(AnalysisAiClient.AnalysisMode.SINGLE_PASS); + + ReflectionTestUtils.setField(analysisAiClient, "twoPassEnabled", true); + assertThat(analysisAiClient.resolveAnalysisMode()).isEqualTo(AnalysisAiClient.AnalysisMode.TWO_PASS); + } + + @Test + @DisplayName("analysis.mode가 기존 two-pass boolean보다 우선한다") + void analysisModePropertyTakesPrecedenceOverTwoPassBoolean() { + ReflectionTestUtils.setField(analysisAiClient, "twoPassEnabled", true); + ReflectionTestUtils.setField(analysisAiClient, "analysisMode", "single-pass"); + assertThat(analysisAiClient.resolveAnalysisMode()).isEqualTo(AnalysisAiClient.AnalysisMode.SINGLE_PASS); + + ReflectionTestUtils.setField(analysisAiClient, "analysisMode", "hybrid-exact"); + assertThat(analysisAiClient.resolveAnalysisMode()).isEqualTo(AnalysisAiClient.AnalysisMode.HYBRID_EXACT); + } + + @Test + @DisplayName("지원하지 않는 analysis.mode는 명확한 예외를 던진다") + void unsupportedAnalysisModeFailsFast() { + ReflectionTestUtils.setField(analysisAiClient, "analysisMode", "unknown"); + + assertThatThrownBy(analysisAiClient::resolveAnalysisMode) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsupported analysis mode: unknown") + .cause() + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Hybrid Exact merge는 questionAnalyses와 점수는 single-pass, missingKeywords는 two-pass 전체 결과를 사용한다") + void mergeHybridExactUsesExplicitSources() { + AnalysisLlmResponse.QuestionAnalysisItem singleQuestionAnalysis = + new AnalysisLlmResponse.QuestionAnalysisItem(1L, "Spring Boot API를 개발했습니다.", "MENTIONED", "결과가 부족합니다.", null); + AnalysisLlmResponse.QuestionAnalysisItem twoPassQuestionAnalysis = + new AnalysisLlmResponse.QuestionAnalysisItem(1L, "장애 대응 경험이 있습니다.", "MENTIONED", "역할이 부족합니다.", null); + AnalysisLlmResponse.MissingKeywordItem singleMissingKeyword = + new AnalysisLlmResponse.MissingKeywordItem("single 누락", "mainTask"); + AnalysisLlmResponse.MissingKeywordItem twoPassMissingKeyword = + new AnalysisLlmResponse.MissingKeywordItem("장애 대응 경험", "qualification"); + AnalysisLlmResponse singlePassResponse = new AnalysisLlmResponse( + 80, + 70, + 60, + "single feedback", + List.of(new AnalysisLlmResponse.HighlightItem("강점", "Spring Boot API")), + List.of(new AnalysisLlmResponse.HighlightItem("약점", "결과")), + List.of(singleMissingKeyword), + List.of(singleQuestionAnalysis) + ); + AnalysisLlmResponse twoPassResponse = new AnalysisLlmResponse( + 10, + 20, + 30, + "two-pass feedback", + List.of(new AnalysisLlmResponse.HighlightItem("two-pass 강점", "장애 대응")), + List.of(), + List.of(twoPassMissingKeyword), + List.of(twoPassQuestionAnalysis) + ); + + AnalysisLlmResponse merged = analysisAiClient.mergeHybridExact( + singlePassResponse, + twoPassResponse + ); + + assertThat(merged.jobFit()).isEqualTo(80); + assertThat(merged.impact()).isEqualTo(70); + assertThat(merged.completeness()).isEqualTo(60); + assertThat(merged.feedback()).isEqualTo("single feedback"); + assertThat(merged.keyStrengths()).isEqualTo(singlePassResponse.keyStrengths()); + assertThat(merged.questionAnalyses()).containsExactly(singleQuestionAnalysis); + assertThat(merged.missingKeywords()).containsExactly(twoPassMissingKeyword); + assertThat(merged.missingKeywords()).doesNotContain(singleMissingKeyword); + assertThat(merged.questionAnalyses()).doesNotContain(twoPassQuestionAnalysis); + } + + @Test + @DisplayName("Hybrid Exact merge는 source 응답 리스트를 방어적으로 복사한다") + void mergeHybridExactCopiesLists() { + AnalysisLlmResponse.QuestionAnalysisItem singleQuestionAnalysis = + new AnalysisLlmResponse.QuestionAnalysisItem(1L, "Spring Boot API를 개발했습니다.", "MENTIONED", "결과가 부족합니다.", null); + AnalysisLlmResponse.MissingKeywordItem twoPassMissingKeyword = + new AnalysisLlmResponse.MissingKeywordItem("장애 대응 경험", "qualification"); + AnalysisLlmResponse singlePassResponse = new AnalysisLlmResponse( + 80, + 70, + 60, + "single feedback", + new java.util.ArrayList<>(List.of(new AnalysisLlmResponse.HighlightItem("강점", "Spring Boot API"))), + new java.util.ArrayList<>(), + new java.util.ArrayList<>(), + new java.util.ArrayList<>(List.of(singleQuestionAnalysis)) + ); + AnalysisLlmResponse twoPassResponse = new AnalysisLlmResponse( + 10, + 20, + 30, + "two-pass feedback", + new java.util.ArrayList<>(), + new java.util.ArrayList<>(), + new java.util.ArrayList<>(List.of(twoPassMissingKeyword)), + new java.util.ArrayList<>() + ); + + AnalysisLlmResponse merged = analysisAiClient.mergeHybridExact(singlePassResponse, twoPassResponse); + + assertThat(merged.keyStrengths()).containsExactlyElementsOf(singlePassResponse.keyStrengths()); + assertThat(merged.questionAnalyses()).containsExactly(singleQuestionAnalysis); + assertThat(merged.missingKeywords()).containsExactly(twoPassMissingKeyword); + assertThatThrownBy(() -> merged.questionAnalyses().add(singleQuestionAnalysis)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> merged.missingKeywords().add(twoPassMissingKeyword)) + .isInstanceOf(UnsupportedOperationException.class); + } + @Test @DisplayName("직무 중분류 기준이 있으면 프롬프트에 보조 평가 기준 섹션을 포함한다") void buildPromptIncludesJobCategoryCriteriaWhenPresent() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java index aeb56eba..686d59a5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisWorkerBridgeServiceTest.java @@ -56,6 +56,63 @@ class AnalysisWorkerBridgeServiceTest { @InjectMocks private AnalysisWorkerBridgeService analysisWorkerBridgeService; + @Test + @DisplayName("취소된 task의 complete 요청은 결과 저장과 성공 처리를 하지 않는다") + void completeTaskRejectsCancelledTaskWithoutSideEffects() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.requestCancel(); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, + 10L, + mock(AnalysisLlmResponse.class), + "worker-1", + 10L + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request)) + .isInstanceOf(GeneralException.class); + + verify(workerTaskResultService, never()).upsertGenerated(eq(TaskType.ANALYSIS_COMPLETE), eq(task.getTaskId()), any()); + verify(analysisService, never()).finalizeAnalysis(any(), eq(10L), any(), any()); + verify(analysisAsyncTaskService, never()).markSuccess(eq(task.getTaskId()), any()); + } + + @Test + @DisplayName("취소된 task의 결과 선저장은 결과 저장을 하지 않는다") + void storeGeneratedResultRejectsCancelledTaskWithoutUpsert() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.requestCancel(); + AnalysisWorkerResultStoreRequest request = new AnalysisWorkerResultStoreRequest( + 1L, + 10L, + mock(AnalysisLlmResponse.class) + ); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> analysisWorkerBridgeService.storeGeneratedResult(task.getTaskId(), request)) + .isInstanceOf(GeneralException.class); + + verify(workerTaskResultService, never()).upsertGenerated(eq(TaskType.ANALYSIS_COMPLETE), eq(task.getTaskId()), any()); + } + + @Test + @DisplayName("취소된 task는 worker 컨텍스트 조회 시 크레딧을 예약하지 않는다") + void getContextRejectsCancelledTaskWithoutCreditReservation() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.requestCancel(); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L)) + .isInstanceOf(GeneralException.class); + + verify(analysisService, never()).deductAnalysisCredit(any(), anyString()); + verify(analysisAsyncTaskService, never()).markCreditReserved(anyString(), anyString()); + } + @Test @DisplayName("worker가 컨텍스트를 조회할 때 처음 한 번만 크레딧을 예약한다") void getContextReservesCreditBeforePreparingExecution() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 275c0778..91831090 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -266,6 +266,7 @@ void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() { JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request); assertThat(response.companyName()).isEqualTo("테스트 기업"); + assertThat(response.postingName()).isEqualTo("백엔드 개발자"); assertThat(response.jobTitle()).isEqualTo("백엔드 개발자"); verify(llmConcurrencyLimiter).execute(eq("job-posting-generate"), any()); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java index 63e3f84e..7ec8b412 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncFacadeServiceTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.jobposting.service; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; @@ -9,18 +10,25 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.util.ArrayList; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -37,15 +45,38 @@ class JobPostingAsyncFacadeServiceTest { @Mock private UserService userService; + @Mock + private JobPostingImageStorageService jobPostingImageStorageService; + @InjectMocks private JobPostingAsyncFacadeService jobPostingAsyncFacadeService; + @BeforeEach + void setUp() { + lenient().when(jobPostingImageStorageService.normalizeImageObjectKeys(any(), any())) + .thenAnswer(invocation -> { + String imageObjectKey = invocation.getArgument(0); + List imageObjectKeys = invocation.getArgument(1); + List normalized = new ArrayList<>(); + if (imageObjectKey != null && !imageObjectKey.isBlank()) { + normalized.add(imageObjectKey.trim()); + } + if (imageObjectKeys != null) { + imageObjectKeys.stream() + .filter(key -> key != null && !key.isBlank()) + .map(String::trim) + .forEach(normalized::add); + } + return normalized; + }); + } + @Test @DisplayName("채용공고 비동기 작업 생성 시 작업 소유자 userId를 함께 저장한다") void submitCreatesTaskWithUserId() { User user = User.signup("테스트 사용자", "job-posting-submit@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); - JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingIngestRequest request = new JobPostingIngestRequest("백엔드 개발자 채용 공고 원문입니다.", null); JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(userService.validateUser(user)).thenReturn(user); @@ -55,7 +86,33 @@ void submitCreatesTaskWithUserId() { assertThat(response.getTaskId()).isEqualTo(task.getTaskId()); verify(jobPostingAsyncTaskService).createPendingTask(7L); - verify(jobPostingAsyncProcessor).process(eq(task.getTaskId()), any(), eq(3)); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(JobPostingIngestCommand.class); + verify(jobPostingAsyncProcessor).process(eq(task.getTaskId()), commandCaptor.capture(), eq(3)); + assertThat(commandCaptor.getValue().getImageObjectKeys()).isEmpty(); + } + + @Test + @DisplayName("비동기 작업 메시지에 최대 2개의 이미지 object key를 포함한다") + void submitPassesMultipleImageObjectKeys() { + User user = User.signup("테스트 사용자", "job-posting-submit-images@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest( + "백엔드 개발자 채용 공고 원문입니다.", + null, + List.of("job-postings/tmp/7/first.png", "job-postings/tmp/7/second.jpg") + ); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(userService.validateUser(user)).thenReturn(user); + when(jobPostingAsyncTaskService.createPendingTask(7L)).thenReturn(task); + + jobPostingAsyncFacadeService.submit(user, request); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(JobPostingIngestCommand.class); + verify(jobPostingAsyncProcessor).process(eq(task.getTaskId()), commandCaptor.capture(), eq(3)); + assertThat(commandCaptor.getValue().getImageObjectKey()).isEqualTo("job-postings/tmp/7/first.png"); + assertThat(commandCaptor.getValue().getImageObjectKeys()) + .containsExactly("job-postings/tmp/7/first.png", "job-postings/tmp/7/second.jpg"); } @Test @@ -100,7 +157,7 @@ void getTaskInternalReadsByTaskId() { void submitDeletesTaskWhenPublishFails() { User user = User.signup("테스트 사용자", "job-posting-submit-fail@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); - JobPostingIngestRequest request = new JobPostingIngestRequest("공고 원문", null); + JobPostingIngestRequest request = new JobPostingIngestRequest("백엔드 개발자 채용 공고 원문입니다.", null); JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); when(userService.validateUser(user)).thenReturn(user); @@ -116,4 +173,22 @@ void submitDeletesTaskWhenPublishFails() { verify(jobPostingAsyncTaskService, times(1)).deleteTask(task.getTaskId()); } + + @Test + @DisplayName("입력 검증 실패 시 비동기 task를 생성하지 않는다") + void submitDoesNotCreateTaskWhenInputInvalid() { + User user = User.signup("테스트 사용자", "job-posting-submit-invalid@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingIngestRequest request = new JobPostingIngestRequest("짧음", null); + + when(userService.validateUser(user)).thenReturn(user); + + assertThatThrownBy(() -> jobPostingAsyncFacadeService.submit(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + + verify(jobPostingAsyncTaskService, times(0)).createPendingTask(7L); + verify(jobPostingAsyncProcessor, times(0)).process(any(), any(), anyInt()); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index 3eb73bb2..fe14a183 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -11,6 +11,7 @@ import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -26,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -57,7 +59,8 @@ void setUp() { jobPostingAsyncSseService, notificationService, asyncMetricsRecorder, - jobPostingQueueProperties + jobPostingQueueProperties, + new AsyncProgressCalculator() ); } @@ -260,6 +263,109 @@ void markFailedCreatesSanitizedNotification() { ); } + @Test + @DisplayName("본인 소유 task 취소 시 CANCELLED 상태와 진행 정보를 발행한다") + void cancelTaskMarksCancelledAndPublishesStatus() { + User user = User.signup("테스트 사용자", "job-posting-cancel@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + + assertThat(response.status()).isEqualTo("CANCELLED"); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.CANCELLED); + assertThat(task.isCancelRequested()).isTrue(); + verify(jobPostingAsyncSseService).publish(any()); + verify(notificationService, never()).createNotification(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("본인 소유가 아닌 task 취소는 거부한다") + void cancelTaskRejectsNonOwner() { + User user = User.signup("테스트 사용자", "job-posting-cancel-forbidden@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId("task-1", 7L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> jobPostingAsyncTaskService.cancelTask(user, "task-1")) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("이미 성공한 task 취소는 상태와 메시지를 유지하고 SSE를 발행하지 않는다") + void cancelTaskKeepsSucceededTaskWithoutPublish() throws Exception { + User user = User.signup("테스트 사용자", "job-posting-cancel-succeeded@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markSuccess(new ObjectMapper().writeValueAsString(new JobPostingIngestResponse(true, "done", null, null, null, null, null))); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + + assertThat(response.status()).isEqualTo("SUCCEEDED"); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.SUCCEEDED); + assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 성공했습니다."); + verify(jobPostingAsyncSseService, never()).publish(any()); + } + + @Test + @DisplayName("이미 실패한 task 취소는 상태와 메시지를 유지하고 SSE를 발행하지 않는다") + void cancelTaskKeepsFailedTaskWithoutPublish() { + User user = User.signup("테스트 사용자", "job-posting-cancel-failed@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + + assertThat(response.status()).isEqualTo("FAILED"); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); + assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 실패했습니다."); + verify(jobPostingAsyncSseService, never()).publish(any()); + } + + @Test + @DisplayName("반복 취소는 기존 cancelledAt을 유지한다") + void cancelTaskKeepsOriginalCancelledAtOnRepeat() { + User user = User.signup("테스트 사용자", "job-posting-cancel-repeat@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + var firstCancelledAt = task.getCancelledAt(); + clearInvocations(jobPostingAsyncSseService); + + jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + + assertThat(task.getCancelledAt()).isEqualTo(firstCancelledAt); + verify(jobPostingAsyncSseService, never()).publish(any()); + } + + @Test + @DisplayName("RUNNING task 취소는 진행률을 0으로 초기화하고 종료 시각을 기록한다") + void cancelTaskResetsRunningProgressAndSetsTerminalTimestamps() { + User user = User.signup("테스트 사용자", "job-posting-cancel-running@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 7L); + JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); + task.markRunning("worker-1", 0, java.time.Instant.now()); + + when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); + + var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); + + assertThat(response.status()).isEqualTo("CANCELLED"); + assertThat(task.getProgressPercent()).isZero(); + assertThat(task.getCompletedAt()).isNotNull(); + assertThat(task.getCancelledAt()).isNotNull(); + } + @Test @DisplayName("재시도 예약 중에는 알림을 생성하지 않는다") void markRetryScheduledDoesNotCreateNotification() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageServiceTest.java new file mode 100644 index 00000000..6d180dfd --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageServiceTest.java @@ -0,0 +1,74 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.config.s3.S3ObjectUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +class JobPostingImageStorageServiceTest { + + @Mock + private S3ObjectUrlService s3ObjectUrlService; + + @Mock + private S3Client s3Client; + + @Test + @DisplayName("구버전 단일 이미지와 신규 이미지 목록을 순서대로 병합한다") + void normalizeImageObjectKeysMergesLegacyAndList() { + JobPostingImageStorageService service = createService(); + + List result = service.normalizeImageObjectKeys( + " job-postings/tmp/1/first.png ", + List.of("job-postings/tmp/1/second.jpg") + ); + + assertThat(result).containsExactly( + "job-postings/tmp/1/first.png", + "job-postings/tmp/1/second.jpg" + ); + } + + @Test + @DisplayName("이미지는 최대 2개까지만 허용한다") + void normalizeImageObjectKeysRejectsMoreThanTwoImages() { + JobPostingImageStorageService service = createService(); + + assertThatThrownBy(() -> service.normalizeImageObjectKeys( + "job-postings/tmp/1/first.png", + List.of("job-postings/tmp/1/second.jpg", "job-postings/tmp/1/third.png") + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("동일 objectKey 중복 전달은 차단한다") + void normalizeImageObjectKeysRejectsDuplicateObjectKeys() { + JobPostingImageStorageService service = createService(); + + assertThatThrownBy(() -> service.normalizeImageObjectKeys( + "job-postings/tmp/1/first.png", + List.of(" job-postings/tmp/1/first.png ") + )) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + private JobPostingImageStorageService createService() { + return new JobPostingImageStorageService(s3ObjectUrlService, s3Client, 5, 5_242_880); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidatorTest.java new file mode 100644 index 00000000..fe6b49e9 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestInputValidatorTest.java @@ -0,0 +1,83 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JobPostingIngestInputValidatorTest { + + @Test + @DisplayName("공백만 입력하고 이미지가 없으면 실패한다") + void validateRejectsBlankRawTextWithoutImage() { + assertInvalidParameter(() -> JobPostingIngestInputValidator.validate(" ", List.of())); + } + + @Test + @DisplayName("이미지 없이 공백 제외 9자 입력이면 실패한다") + void validateRejectsNineNonWhitespaceCharactersWithoutImage() { + assertInvalidParameter(() -> JobPostingIngestInputValidator.validate("123 456 789", List.of())); + } + + @Test + @DisplayName("이미지 없이 공백 제외 10자 입력이면 통과한다") + void validateAcceptsTenNonWhitespaceCharactersWithoutImage() { + assertThatCode(() -> JobPostingIngestInputValidator.validate("123 456 7890", List.of())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("rawText는 10000자까지 허용한다") + void validateAcceptsMaxLengthRawText() { + assertThatCode(() -> JobPostingIngestInputValidator.validate("가".repeat(10_000), List.of())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("rawText가 10000자를 초과하면 실패한다") + void validateRejectsRawTextOverMaxLength() { + assertInvalidParameter(() -> JobPostingIngestInputValidator.validate("가".repeat(10_001), List.of())); + } + + @Test + @DisplayName("이미지가 있으면 rawText가 10자 미만이어도 허용한다") + void validateAcceptsShortRawTextWithImage() { + assertThatCode(() -> JobPostingIngestInputValidator.validate( + "짧음", + List.of("job-postings/tmp/1/posting.png") + )) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("이미지가 있어도 rawText가 10000자를 초과하면 실패한다") + void validateRejectsBlankRawTextOverMaxLengthWithImage() { + assertInvalidParameter(() -> JobPostingIngestInputValidator.validate( + " ".repeat(10_001), + List.of("job-postings/tmp/1/posting.png") + )); + } + + @Test + @DisplayName("rawText와 이미지가 모두 없으면 실패한다") + void validateRejectsMissingRawTextAndImage() { + assertInvalidParameter(() -> JobPostingIngestInputValidator.validate(null, null)); + } + + private void assertInvalidParameter(ThrowingCallable callable) { + assertThatThrownBy(callable::call) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @FunctionalInterface + private interface ThrowingCallable { + void call(); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java index 86f323ea..b6f70dfb 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java @@ -6,10 +6,12 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -24,13 +26,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -50,6 +55,9 @@ class JobPostingIngestServiceTest { @Mock private UserService userService; + @Mock + private JobPostingImageStorageService jobPostingImageStorageService; + @InjectMocks private JobPostingIngestService jobPostingIngestService; @@ -57,7 +65,6 @@ class JobPostingIngestServiceTest { private static Stream invalidConfidenceValues() { return Stream.of( - Arguments.of(0.299), Arguments.of(-0.1), Arguments.of(1.001), Arguments.of(Double.NaN), @@ -68,27 +75,23 @@ private static Stream invalidConfidenceValues() { private static Stream invalidExtractedFieldValues() { return Stream.of( - Arguments.of(null, "백엔드 개발자", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡", "백엔드 개발자", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", null, "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", null, "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", "업무1", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", "Spring 기반 API 개발", null), - Arguments.of("잡드리", "백엔드 개발자", "Spring 기반 API 개발", "요건1") + Arguments.of(null, "백엔드 개발자"), + Arguments.of("잡", "백엔드 개발자"), + Arguments.of("미분류 회사", "백엔드 개발자"), + Arguments.of("잡드리", null), + Arguments.of("잡드리", "백"), + Arguments.of("잡드리", "string") ); } private static Stream invalidGeneratedFieldValues() { return Stream.of( - Arguments.of(null, "백엔드 개발자", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡", "백엔드 개발자", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", null, "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", "백", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", null, "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", "업무1", "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", "정제된 주요 업무", null), - Arguments.of("잡드리", "백엔드 개발자", "정제된 주요 업무", "요건1") + Arguments.of(null, "백엔드 개발자"), + Arguments.of("잡", "백엔드 개발자"), + Arguments.of("미분류 회사", "백엔드 개발자"), + Arguments.of("잡드리", null), + Arguments.of("잡드리", "백"), + Arguments.of("잡드리", "string") ); } @@ -97,6 +100,22 @@ void setUp() { user = User.signup("테스트 사용자", "ingest@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); ReflectionTestUtils.setField(jobPostingIngestService, "classificationConfidenceThreshold", 0.65); + lenient().when(jobPostingImageStorageService.normalizeImageObjectKeys(any(), any())) + .thenAnswer(invocation -> { + String imageObjectKey = invocation.getArgument(0); + List imageObjectKeys = invocation.getArgument(1); + List normalized = new ArrayList<>(); + if (imageObjectKey != null && !imageObjectKey.isBlank()) { + normalized.add(imageObjectKey.trim()); + } + if (imageObjectKeys != null) { + imageObjectKeys.stream() + .filter(key -> key != null && !key.isBlank()) + .map(String::trim) + .forEach(normalized::add); + } + return normalized; + }); } @Test @@ -132,6 +151,7 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "클라우드 엔지니어 채용", "해커스 교육그룹", "클라우드 엔지니어", "정제된 주요 업무", @@ -151,7 +171,7 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { .preferred("정제된 우대 사항") .build(); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -169,13 +189,32 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { verify(jobPostingAiService).extractJobPosting( eq(1L), eq("채용 공고 원문"), - imageObjectKeyCaptor.capture() + imageObjectKeyCaptor.capture(), + eq(List.of("job-postings/1/posting.png")) ); + ArgumentCaptor createRequestCaptor = + ArgumentCaptor.forClass(JobPostingCreateRequest.class); + verify(jobPostingService).createJobPosting(eq(user), createRequestCaptor.capture()); assertThat(imageObjectKeyCaptor.getValue()).isEqualTo("job-postings/1/posting.png"); + assertThat(createRequestCaptor.getValue().postingName()).isEqualTo("클라우드 엔지니어 채용"); + assertThat(createRequestCaptor.getValue().jobTitle()).isEqualTo("클라우드 엔지니어"); assertThat(response.isSavedToDatabase()).isTrue(); } + @Test + @DisplayName("동기 ingest는 이미지 없는 10자 미만 입력이면 AI 추출을 시작하지 않는다") + void ingestAndCreateRejectsShortRawTextBeforeExtract() { + JobPostingIngestRequest request = new JobPostingIngestRequest("짧음", null); + + assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + + verifyNoInteractions(jobPostingAiService, jobPostingClassificationService, jobPostingService, userService); + } + @Test @DisplayName("추출 confidence 경계값과 필드 최소 길이를 만족하면 저장한다") void ingestAndCreateAcceptsBoundaryValidExtractedResult() { @@ -193,6 +232,7 @@ void ingestAndCreateAcceptsBoundaryValidExtractedResult() { 0.3 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔 채용", "잡드", "백엔", "업무내용1", @@ -220,7 +260,7 @@ void ingestAndCreateAcceptsBoundaryValidExtractedResult() { @DisplayName("공고로 인식할 수 없는 추출 결과는 저장하지 않고 오류 처리한다") void ingestAndCreateRejectsInvalidExtractedResult() { JobPostingIngestRequest request = new JobPostingIngestRequest( - "양식에 맞지 않는 입력", + "양식에 맞지 않는 입력입니다", null ); JobPostingExtractResponse extracted = new JobPostingExtractResponse( @@ -229,28 +269,28 @@ void ingestAndCreateRejectsInvalidExtractedResult() { "string", "string", "", - "양식에 맞지 않는 입력", + "양식에 맞지 않는 입력입니다", 0.9 ); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); } @ParameterizedTest @MethodSource("invalidConfidenceValues") - @DisplayName("추출 confidence가 0.3 미만이거나 0~1 범위를 벗어나면 저장하지 않는다") + @DisplayName("추출 confidence가 0~1 범위를 벗어나거나 숫자가 아니면 저장하지 않는다") void ingestAndCreateRejectsInvalidExtractedConfidence(double confidence) { - JobPostingIngestRequest request = new JobPostingIngestRequest("공고 입력", null); + JobPostingIngestRequest request = new JobPostingIngestRequest("백엔드 개발자 채용 공고 입력입니다.", null); JobPostingExtractResponse extracted = validExtracted(confidence); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) @@ -265,33 +305,72 @@ void ingestAndCreateRejectsInvalidExtractedConfidence(double confidence) { @DisplayName("추출 필수 필드가 null이거나 최소 길이 미만이면 저장하지 않는다") void ingestAndCreateRejectsInvalidExtractedField( String companyName, - String jobTitle, - String task, - String requirements + String jobTitle ) { - JobPostingIngestRequest request = new JobPostingIngestRequest("공고 입력", null); + JobPostingIngestRequest request = new JobPostingIngestRequest("백엔드 개발자 채용 공고 입력입니다.", null); JobPostingExtractResponse extracted = new JobPostingExtractResponse( companyName, jobTitle, - task, - requirements, + "S", + "R", "", request.rawText(), 0.9 ); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); } @Test - @DisplayName("공고 생성 결과가 placeholder이면 DB 저장 전에 오류 처리한다") + @DisplayName("공고 생성 결과의 필수 3개 필드가 유효하면 업무와 자격 요건이 짧아도 저장한다") + void ingestAndCreateAllowsInvalidDescriptionsWhenRequiredFieldsAreValid() { + JobPostingIngestRequest request = new JobPostingIngestRequest( + "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", + null + ); + JobPostingExtractResponse extracted = new JobPostingExtractResponse( + "잡드리", + "백엔드 개발자", + "S", + "R", + "", + request.rawText(), + 0.9 + ); + JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", + "잡드리", + "백엔드 개발자", + "string", + "string", + "", + "" + ); + JobPostingResponse saved = JobPostingResponse.builder() + .jobPostingId(10L) + .userId(1L) + .companyName("잡드리") + .task("string") + .requirement("string") + .build(); + + stubSuccessfulPipeline(extracted, generated, saved); + + JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request); + + assertThat(response.isSavedToDatabase()).isTrue(); + verify(jobPostingService).createJobPosting(eq(user), any(JobPostingCreateRequest.class)); + } + + @Test + @DisplayName("공고 생성 결과의 직무가 placeholder여도 공고명 invalid로 함께 보고하지 않는다") void ingestAndCreateRejectsInvalidGeneratedResult() { JobPostingIngestRequest request = new JobPostingIngestRequest( "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", @@ -322,15 +401,16 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", "잡드리", - "백엔드 개발자", + "string", "string", "string", "", "" ); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -339,9 +419,12 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { when(jobPostingAiService.generateJobPosting(any())) .thenReturn(generated); - assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "jobTitle"); verifyNoInteractions(jobPostingService, userService); } @@ -351,9 +434,7 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { @DisplayName("생성 필수 필드가 null이거나 최소 길이 미만이면 저장하지 않는다") void ingestAndCreateRejectsInvalidGeneratedField( String companyName, - String jobTitle, - String task, - String requirements + String jobTitle ) { JobPostingIngestRequest request = new JobPostingIngestRequest( "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", @@ -361,10 +442,11 @@ void ingestAndCreateRejectsInvalidGeneratedField( ); JobPostingExtractResponse extracted = validExtracted(0.9); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", companyName, jobTitle, - task, - requirements, + "T", + "R", "", "" ); @@ -373,11 +455,68 @@ void ingestAndCreateRejectsInvalidGeneratedField( assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + + verifyNoInteractions(jobPostingService, userService); + } + + @Test + @DisplayName("공고 생성 결과의 공고명이 유효하지 않으면 postingName만 invalid field로 내려준다") + void ingestAndCreateRejectsInvalidGeneratedPostingNameOnly() { + JobPostingIngestRequest request = new JobPostingIngestRequest( + "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", + null + ); + JobPostingExtractResponse extracted = validExtracted(0.9); + JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "string", + "잡드리", + "백엔드 개발자", + "T", + "R", + "", + "" + ); + + stubUntilGenerated(extracted, generated); + + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) + .isInstanceOf(GeneralException.class) + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "postingName"); verifyNoInteractions(jobPostingService, userService); } + @Test + @DisplayName("공고 추출 결과가 유효하지 않으면 invalid field 목록을 내려준다") + void ingestAndCreateReturnsInvalidExtractedFieldNames() { + JobPostingIngestRequest request = new JobPostingIngestRequest("양식에 맞지 않는 입력입니다", null); + JobPostingExtractResponse extracted = new JobPostingExtractResponse( + "미분류 회사", + "string", + "Spring 기반 API 개발", + "Spring Boot 개발 경험", + "", + request.rawText(), + 0.9 + ); + + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) + .thenReturn(extracted); + + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) + .isInstanceOf(GeneralException.class) + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "companyName", "jobTitle"); + + verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); + } + private void stubSuccessfulPipeline( JobPostingExtractResponse extracted, JobPostingGenerateResponse generated, @@ -406,7 +545,7 @@ private void stubUntilGenerated(JobPostingExtractResponse extracted, JobPostingG 0.9 ); - when(jobPostingAiService.extractJobPosting(any(), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -427,4 +566,14 @@ private JobPostingExtractResponse validExtracted(double confidence) { confidence ); } + + private void assertInvalidFields(GeneralException exception, String... fields) { + assertThat(exception.getError()) + .isInstanceOf(JobPostingIngestValidationErrorResponse.class); + JobPostingIngestValidationErrorResponse error = + (JobPostingIngestValidationErrorResponse) exception.getError(); + assertThat(error.invalidFields()) + .extracting(JobPostingIngestValidationErrorResponse.InvalidField::field) + .containsExactly(fields); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java index 848bef88..918667d7 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java @@ -60,6 +60,74 @@ class JobPostingWorkerBridgeServiceTest { @InjectMocks private JobPostingWorkerBridgeService jobPostingWorkerBridgeService; + @Test + @DisplayName("취소된 task의 legacy complete는 결과 저장과 성공 처리를 하지 않는다") + void completeTaskRejectsCancelledTaskWithoutSideEffects() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(1L, 3); + task.requestCancel(); + JobPostingIngestResponse result = mock(JobPostingIngestResponse.class); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> jobPostingWorkerBridgeService.completeTask(task.getTaskId(), result)) + .isInstanceOf(GeneralException.class); + + verify(workerTaskResultService, never()).upsertGenerated(eq(TaskType.JOB_POSTING_COMPLETE), eq(task.getTaskId()), any()); + verify(jobPostingAsyncTaskService, never()).markSuccess(eq(task.getTaskId()), any(JobPostingIngestResponse.class)); + } + + @Test + @DisplayName("취소된 task의 finalize는 결과 저장과 공고 생성과 성공 처리를 하지 않는다") + void finalizeAndCompleteRejectsCancelledTaskWithoutSideEffects() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(1L, 3); + task.requestCancel(); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> jobPostingWorkerBridgeService.finalizeAndComplete( + task.getTaskId(), + 1L, + mock(JobPostingExtractResponse.class), + List.of(mock(JobPostingClassificationCandidateResponse.class)), + mock(JobPostingClassificationResultResponse.class), + mock(JobPostingGenerateResponse.class) + )) + .isInstanceOf(GeneralException.class); + + verify(workerTaskResultService, never()).upsertGenerated( + eq(TaskType.JOB_POSTING_FINALIZE), + eq(task.getTaskId()), + any(JobPostingWorkerFinalizeRequest.class) + ); + verify(jobPostingService, never()).createJobPosting(any(), any()); + verify(jobPostingAsyncTaskService, never()).markSuccess(eq(task.getTaskId()), any(JobPostingIngestResponse.class)); + } + + @Test + @DisplayName("취소된 task의 finalize 결과 선저장은 결과 저장을 하지 않는다") + void storeFinalizeResultRejectsCancelledTaskWithoutUpsert() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(1L, 3); + task.requestCancel(); + JobPostingWorkerFinalizeRequest result = new JobPostingWorkerFinalizeRequest( + task.getTaskId(), + 1L, + mock(JobPostingExtractResponse.class), + List.of(mock(JobPostingClassificationCandidateResponse.class)), + mock(JobPostingClassificationResultResponse.class), + mock(JobPostingGenerateResponse.class) + ); + + when(jobPostingAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + + assertThatThrownBy(() -> jobPostingWorkerBridgeService.storeFinalizeResult( + task.getTaskId(), + new JobPostingWorkerResultStoreRequest(1L, result) + )) + .isInstanceOf(GeneralException.class); + + verify(workerTaskResultService, never()).upsertGenerated(eq(TaskType.JOB_POSTING_FINALIZE), eq(task.getTaskId()), any()); + } + @Test @DisplayName("채용 공고 finalize 결과를 durable storage에 선저장할 수 있다") void storeFinalizeResultPersistsPayload() { @@ -86,7 +154,9 @@ void storeFinalizeResultPersistsPayload() { @Test @DisplayName("채용 공고 complete 성공 시 저장 결과를 DELIVERED로 마킹한다") void completeTaskMarksDelivered() { + JobPostingAsyncTask task = JobPostingAsyncTask.pending(1L, 3); JobPostingIngestResponse result = mock(JobPostingIngestResponse.class); + when(jobPostingAsyncTaskRepository.findById("task-1")).thenReturn(Optional.of(task)); when(jobPostingAsyncTaskService.markSuccess("task-1", result)).thenReturn(result); jobPostingWorkerBridgeService.completeTask("task-1", result); @@ -148,6 +218,7 @@ void finalizeAndCompleteRejectsInvalidInputBeforePersistingWorkerResult() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", "잡드리", "백엔드 개발자", "정제된 주요 업무", @@ -165,7 +236,7 @@ void finalizeAndCompleteRejectsInvalidInputBeforePersistingWorkerResult() { generated )) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verify(workerTaskResultService, never()).upsertGenerated( eq(TaskType.JOB_POSTING_FINALIZE), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 4c46ac08..920e0472 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -26,6 +26,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyUpdateNameResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; @@ -335,6 +336,7 @@ void getMyMockApplies() { MockApply inProgress = mockApplyRepository.save(MockApply.create(user, backendPosting, ApplyType.ACTUAL)); inProgress.updateStatus(MockApplyStatus.ANSWER_WRITE); + inProgress.updateDisplayName("카카오 백엔드 지원 연습"); MockApply completedFirst = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.MOCK)); completedFirst.updateStatus(MockApplyStatus.COMPLETED); MockApply completedSecond = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.ACTUAL)); @@ -359,6 +361,7 @@ void getMyMockApplies() { assertThat(response.completed().getContent()).hasSize(2); assertThat(response.inProgress().get(0).mockApplyId()).isEqualTo(inProgress.getId()); assertThat(response.inProgress().get(0).jobPostingId()).isEqualTo(backendPosting.getId()); + assertThat(response.inProgress().get(0).displayName()).isEqualTo("카카오 백엔드 지원 연습"); assertThat(response.inProgress().get(0).sequence()).isEqualTo(1); assertThat(response.inProgress().get(0).status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); assertThat(response.inProgress().get(0).companyName()).isEqualTo("테스트 기업"); @@ -377,6 +380,7 @@ void getMyMockApplies() { assertThat(response.completed().getNumber()).isEqualTo(0); assertThat(response.completed().getContent().get(0).createdAt()).isEqualTo(baseTime.plusMinutes(2)); assertThat(response.completed().getContent().get(0).profileColor()).isEqualTo(JobPostingProfileColor.GREEN); + assertThat(response.completed().getContent().get(0).displayName()).isNull(); assertThat(response.completed().getContent().get(0).score()).isEqualTo(81); assertThat(response.completed().getContent().get(0).applyType()).isEqualTo(ApplyType.ACTUAL); assertThat(response.completed().getContent().get(0).resumePath()).isEqualTo("/mock-applies/" + completedSecond.getId() + "/analysis"); @@ -426,6 +430,93 @@ void getMyMockAppliesCompletedResultsPaged() { assertThat(secondPage.completed().hasNext()).isFalse(); } + @Test + @DisplayName("모의 서류 지원 이름을 변경한다") + void updateMockApplyName() { + User user = saveUser("mock-apply-name@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + MockApply mockApply = saveMockApply(user, jobPosting, ApplyType.MOCK, 1); + + MockApplyUpdateNameResponse response = mockApplyService.updateMockApplyName( + user, + mockApply.getId(), + " 카카오 백엔드 지원 연습 " + ); + mockApplyRepository.flush(); + + MockApply updated = mockApplyRepository.findById(mockApply.getId()).orElseThrow(); + assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); + assertThat(response.name()).isEqualTo("카카오 백엔드 지원 연습"); + assertThat(response.updatedAt()).isNotNull(); + assertThat(updated.getDisplayName()).isEqualTo("카카오 백엔드 지원 연습"); + } + + @Test + @DisplayName("모의 서류 지원 이름은 여러 번 변경할 수 있다") + void updateMockApplyNameAgain() { + User user = saveUser("mock-apply-name-again@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + MockApply mockApply = saveMockApply(user, jobPosting, ApplyType.MOCK, 1); + + mockApplyService.updateMockApplyName(user, mockApply.getId(), "첫 번째 이름"); + MockApplyUpdateNameResponse response = mockApplyService.updateMockApplyName(user, mockApply.getId(), "두 번째 이름"); + + assertThat(response.name()).isEqualTo("두 번째 이름"); + assertThat(mockApplyRepository.findById(mockApply.getId()).orElseThrow().getDisplayName()) + .isEqualTo("두 번째 이름"); + } + + @Test + @DisplayName("빈 이름으로 모의 서류 지원 이름을 변경할 수 없다") + void updateMockApplyNameRejectsBlankName() { + User user = saveUser("mock-apply-name-blank@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + MockApply mockApply = saveMockApply(user, jobPosting, ApplyType.MOCK, 1); + + assertThatThrownBy(() -> mockApplyService.updateMockApplyName(user, mockApply.getId(), " ")) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("100자를 초과한 이름으로 모의 서류 지원 이름을 변경할 수 없다") + void updateMockApplyNameRejectsTooLongName() { + User user = saveUser("mock-apply-name-too-long@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + MockApply mockApply = saveMockApply(user, jobPosting, ApplyType.MOCK, 1); + + assertThatThrownBy(() -> mockApplyService.updateMockApplyName(user, mockApply.getId(), "가".repeat(101))) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("다른 사용자의 모의 서류 지원 이름은 변경할 수 없다") + void updateMockApplyNameRejectsOtherUserMockApply() { + User user = saveUser("mock-apply-name-owner@example.com"); + User otherUser = saveUser("mock-apply-name-other@example.com"); + JobPosting jobPosting = saveJobPosting(otherUser, "백엔드 개발"); + MockApply mockApply = saveMockApply(otherUser, jobPosting, ApplyType.MOCK, 1); + + assertThatThrownBy(() -> mockApplyService.updateMockApplyName(user, mockApply.getId(), "변경 이름")) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("없는 모의 서류 지원 이름은 변경할 수 없다") + void updateMockApplyNameRejectsMissingMockApply() { + User user = saveUser("mock-apply-name-missing@example.com"); + + assertThatThrownBy(() -> mockApplyService.updateMockApplyName(user, 999_999L, "변경 이름")) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.MOCK_APPLY_NOT_FOUND); + } + @Test @DisplayName("모의 서류 지원을 삭제하면 해당 지원의 문항과 분석만 삭제한다") void deleteMockApplyDeletesOnlyTargetMockApplyResults() { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java new file mode 100644 index 00000000..f07e4efe --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java @@ -0,0 +1,172 @@ +package com.jobdri.jobdri_api.domain.payment.service; + +import com.jobdri.jobdri_api.domain.payment.dto.request.CouponRedeemRequest; +import com.jobdri.jobdri_api.domain.payment.dto.response.CouponRedeemResponse; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.repository.CouponRedemptionRepository; +import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.fail; + +@SpringBootTest +@ActiveProfiles("test") +class CouponServiceTest { + + private static final long CONCURRENCY_TIMEOUT_SECONDS = 5L; + + @Autowired + private CouponService couponService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CreditTransactionRepository creditTransactionRepository; + + @Autowired + private CouponRedemptionRepository couponRedemptionRepository; + + @Test + @DisplayName("유효한 쿠폰 번호를 등록하면 크레딧 1회가 충전되고 사용 이력이 저장된다") + void redeem() { + User user = saveUser("coupon-redeem@example.com"); + + CouponRedeemResponse response = couponService.redeem(user, new CouponRedeemRequest(" test-coup-2026 ")); + + assertThat(response.couponCode()).isEqualTo("TEST-COUP-2026"); + assertThat(response.creditAmount()).isEqualTo(1); + assertThat(response.creditBalance()).isEqualTo(2); + assertThat(response.redeemedAt()).isNotNull(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(couponRedemptionRepository.findByUserIdAndCouponCode(user.getId(), "TEST-COUP-2026")).isPresent(); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.COUPON + )).hasSize(1); + } + + @Test + @DisplayName("설정된 쿠폰 번호와 다르면 예외를 던진다") + void redeemThrowsWhenCouponCodeIsInvalid() { + User user = saveUser("coupon-invalid@example.com"); + + assertThatThrownBy(() -> couponService.redeem(user, new CouponRedeemRequest("ABCD-EFGH-IJKL"))) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.COUPON_INVALID); + } + + @Test + @DisplayName("같은 사용자가 동일 쿠폰을 다시 등록하면 중복 사용을 막는다") + void redeemThrowsWhenCouponAlreadyRedeemed() { + User user = saveUser("coupon-duplicate@example.com"); + couponService.redeem(user, new CouponRedeemRequest("TEST-COUP-2026")); + + assertThatThrownBy(() -> couponService.redeem(user, new CouponRedeemRequest("TEST-COUP-2026"))) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.COUPON_ALREADY_REDEEMED); + + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.COUPON + )).hasSize(1); + } + + @Test + @DisplayName("동일 쿠폰 등록 요청이 동시에 들어와도 한 번만 충전한다") + void redeemConcurrentlyChargesOnlyOnce() throws Exception { + User user = saveUser("coupon-concurrent@example.com"); + CouponRedeemRequest request = new CouponRedeemRequest("TEST-COUP-2026"); + + List results = runConcurrently(2, () -> { + try { + couponService.redeem(user, request); + return Result.ok(); + } catch (Exception e) { + return Result.failure(e); + } + }); + + assertThat(results).filteredOn(Result::success).hasSize(1); + assertThat(results) + .filteredOn(result -> !result.success()) + .allSatisfy(result -> assertThat(result.exception()) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.COUPON_ALREADY_REDEEMED)); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(couponRedemptionRepository.findByUserIdAndCouponCode(user.getId(), "TEST-COUP-2026")).isPresent(); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.COUPON + )).hasSize(1); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + var ready = new CountDownLatch(threadCount); + var start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = java.util.stream.IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) () -> { + ready.countDown(); + start.await(); + return task.call(); + }) + .toList(); + var futures = tasks.stream() + .map(executor::submit) + .toList(); + if (!ready.await(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + fail("Concurrent test setup timed out while waiting for worker threads to be ready."); + } + start.countDown(); + + List results = new java.util.ArrayList<>(); + for (var future : futures) { + try { + results.add(future.get(CONCURRENCY_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } catch (TimeoutException e) { + fail("Concurrent test timed out while waiting for worker result.", e); + } + } + return results; + } finally { + executor.shutdownNow(); + } + } + + private record Result(boolean success, Exception exception) { + static Result ok() { + return new Result(true, null); + } + + static Result failure(Exception exception) { + return new Result(false, exception); + } + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 1534e202..5abcd84d 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -78,6 +78,8 @@ job-posting: max-size-bytes: 5242880 payment: + coupon: + code: TEST-COUP-2026 toss: client-key: test-toss-client-key secret-key: test-toss-secret-key