diff --git a/build.gradle b/build.gradle index 80620c7..45f7bea 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,12 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.4.2' + } +} + dependencies { //redis implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -49,6 +55,9 @@ dependencies { //openai implementation 'com.openai:openai-java:4.35.0' + //S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/ops/s3/README.md b/ops/s3/README.md new file mode 100644 index 0000000..6df4841 --- /dev/null +++ b/ops/s3/README.md @@ -0,0 +1,17 @@ +# S3 setup for job posting images + +## 1. Apply bucket CORS + +```bash +aws s3api put-bucket-cors \ + --bucket "$S3_BUCKET" \ + --cors-configuration file://ops/s3/job-posting-image-cors.json +``` + +## 2. Apply lifecycle policy + +```bash +aws s3api put-bucket-lifecycle-configuration \ + --bucket "$S3_BUCKET" \ + --lifecycle-configuration file://ops/s3/job-posting-image-lifecycle.json +``` diff --git a/ops/s3/job-posting-image-cors.json b/ops/s3/job-posting-image-cors.json new file mode 100644 index 0000000..582d34f --- /dev/null +++ b/ops/s3/job-posting-image-cors.json @@ -0,0 +1,25 @@ +[ + { + "AllowedHeaders": [ + "*" + ], + "AllowedMethods": [ + "PUT", + "GET", + "HEAD" + ], + "AllowedOrigins": [ + "https://jobdri.site", + "https://www.jobdri.site", + "https://job-dri.vercel.app", + "http://localhost:3000", + "http://localhost:5173" + ], + "ExposeHeaders": [ + "ETag", + "x-amz-request-id", + "x-amz-id-2" + ], + "MaxAgeSeconds": 3000 + } +] diff --git a/ops/s3/job-posting-image-lifecycle.json b/ops/s3/job-posting-image-lifecycle.json new file mode 100644 index 0000000..606b5ba --- /dev/null +++ b/ops/s3/job-posting-image-lifecycle.json @@ -0,0 +1,17 @@ +{ + "Rules": [ + { + "ID": "DeleteTemporaryJobPostingImages", + "Status": "Enabled", + "Filter": { + "Prefix": "job-postings/tmp/" + }, + "Expiration": { + "Days": 1 + }, + "AbortIncompleteMultipartUpload": { + "DaysAfterInitiation": 1 + } + } + ] +} 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 new file mode 100644 index 0000000..d5e552d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisController.java @@ -0,0 +1,57 @@ +package com.jobdri.jobdri_api.domain.analysis.controller; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.service.AnalysisService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/mock-applies/{mockApplyId}/analysis") +@Tag(name = "Analysis", description = "자소서 분석 API") +public class AnalysisController { + + private final AnalysisService analysisService; + + @Operation(summary = "자소서 분석 실행", description = "저장된 문항 답변과 공고 정보를 기반으로 자소서를 분석하고 결과를 저장합니다.") + @PostMapping + public ApiResponse analyze( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석이 완료되었습니다.", + analysisService.analyze(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + @Operation(summary = "자소서 분석 결과 조회", description = "저장된 자소서 분석 결과를 조회합니다.") + @GetMapping + public ApiResponse getAnalysis( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "자소서 분석 결과 조회에 성공했습니다.", + analysisService.getAnalysis(getAuthenticatedUser(userDetails), mockApplyId) + ); + } + + private com.jobdri.jobdri_api.domain.user.entity.User getAuthenticatedUser(UserDetailsImpl userDetails) { + if (userDetails == null || userDetails.getUser() == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다."); + } + return userDetails.getUser(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java new file mode 100644 index 0000000..d8a77d8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java @@ -0,0 +1,21 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.llm; + +import java.util.List; + +public record AnalysisLlmResponse( + Integer score, + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, + List questionAnalyses +) { + public record QuestionAnalysisItem( + Long questionId, + String sentence, + String status, + String reason, + String improvement + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java new file mode 100644 index 0000000..00a477f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisQuestionResponse.java @@ -0,0 +1,24 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Question; + +import java.util.List; + +public record AnalysisQuestionResponse( + Long questionId, + String questionContent, + String answer, + List analyses +) { + public static AnalysisQuestionResponse of( + Question question, + List analyses + ) { + return new AnalysisQuestionResponse( + question.getId(), + question.getContent(), + question.getAnswer(), + analyses + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java new file mode 100644 index 0000000..077fcdb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisResponse.java @@ -0,0 +1,36 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +import java.util.List; + +public record AnalysisResponse( + Long mockApplyId, + Long analysisId, + MockApplyStatus status, + int score, + int jobFit, + int impact, + int completeness, + String feedback, + List questions +) { + public static AnalysisResponse of( + Analysis analysis, + MockApplyStatus status, + List questions + ) { + return new AnalysisResponse( + analysis.getMockApply().getId(), + analysis.getId(), + status, + analysis.getScore(), + analysis.getJobFit(), + analysis.getImpact(), + analysis.getCompleteness(), + analysis.getFeedback(), + questions + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java new file mode 100644 index 0000000..92a9e92 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.analysis.dto.response; + +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; + +public record QuestionAnalysisResponse( + Long questionAnalysisId, + String sentence, + String status, + String reason, + String improvement, + int start, + int end +) { + public static QuestionAnalysisResponse from(QuestionAnalysis questionAnalysis) { + return new QuestionAnalysisResponse( + questionAnalysis.getId(), + questionAnalysis.getSentence(), + statusValue(questionAnalysis.getStatus()), + questionAnalysis.getReason(), + questionAnalysis.getImprovement(), + questionAnalysis.getStart(), + questionAnalysis.getEnd() + ); + } + + private static String statusValue(QuestionAnalysisStatus status) { + if (status == null) { + return QuestionAnalysisStatus.MENTIONED.name().toLowerCase(); + } + return status.name().toLowerCase(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java index 644b113..3c024e2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.response; public record QuestionCandidateResponse( + Long questionId, String content, int charLimit, boolean selected diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java index 4e0d0e6..ba6852a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/Analysis.java @@ -50,7 +50,7 @@ public static Analysis create( int completeness, String feedback ) { - Analysis analysis = Analysis.builder() + return Analysis.builder() .mockApply(mockApply) .score(score) .jobFit(jobFit) @@ -58,7 +58,5 @@ public static Analysis create( .completeness(completeness) .feedback(feedback) .build(); - mockApply.assignAnalysis(analysis); - return analysis; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java index 5641065..ab393b8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java @@ -32,6 +32,11 @@ public class QuestionAnalysis { @Column(nullable = false, columnDefinition = "TEXT") private String improvement; + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "varchar(255) default 'MENTIONED'") + @Builder.Default + private QuestionAnalysisStatus status = QuestionAnalysisStatus.MENTIONED; + @Column(name = "start_index", nullable = false) private int start; @@ -44,6 +49,7 @@ public static QuestionAnalysis create( String sentence, String reason, String improvement, + QuestionAnalysisStatus status, int start, int end ) { @@ -53,6 +59,7 @@ public static QuestionAnalysis create( .sentence(sentence) .reason(reason) .improvement(improvement) + .status(status) .start(start) .end(end) .build(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java new file mode 100644 index 0000000..2c99de5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.entity; + +public enum QuestionAnalysisStatus { + PROVEN, + MENTIONED, + MISSING, + FABRICATED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java index 695c72f..b32013f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/QuestionAnalysisRepository.java @@ -8,4 +8,6 @@ public interface QuestionAnalysisRepository extends JpaRepository { List findAllByQuestionId(Long questionId); List findAllByAnalysisId(Long analysisId); + List findAllByAnalysisIdOrderByQuestionIdAscIdAsc(Long analysisId); + void deleteAllByAnalysisId(Long analysisId); } 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 new file mode 100644 index 0000000..3318d87 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisAiClient.java @@ -0,0 +1,165 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseOutputMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AnalysisAiClient { + + private final OpenAIClient openAIClient; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel; + + public AnalysisLlmResponse analyze(JobPosting jobPosting, List questions) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(buildPrompt(jobPosting, questions)) + .temperature(0.2) + .text(AnalysisLlmResponse.class) + .build(); + + try { + StructuredResponse response = openAIClient.responses().create(params); + return extractStructuredContent(response); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + log.error("자소서 분석 OpenAI API 호출 오류: {}", e.getMessage(), e); + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 호출에 실패했습니다." + ); + } + } + + private String buildPrompt(JobPosting jobPosting, List questions) { + String questionText = questions.stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.getId(), + defaultString(question.getContent()), + defaultString(question.getAnswer()) + )) + .reduce("", (left, right) -> left + "\n" + right); + + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 JSON만 출력한다. + 자소서 원문에 없는 sentence를 만들지 않는다. + sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. + + [출력 형식] + { + "score": 64, + "jobFit": 70, + "impact": 55, + "completeness": 67, + "feedback": "한 줄 피드백", + "questionAnalyses": [ + { + "questionId": 1, + "sentence": "자소서 답변 안에 실제 존재하는 정확한 부분 문자열", + "status": "mentioned", + "reason": "문제 이유", + "improvement": "개선 예시 문장" + } + ] + } + + [평가 절차] + 1. JD의 주요 업무, 자격 요건, 우대 사항을 읽고 핵심 역량을 정리한다. + 2. 각 문항 답변이 JD와 얼마나 연결되는지 평가한다. + 3. 주장, 경험, 성과가 구체적 근거로 입증되는지 평가한다. + 4. 질문에 맞게 답했는지, 문장 흐름과 완성도가 충분한지 평가한다. + 5. 보완이 필요한 원문 문장을 최대 2~3개만 추출한다. + + [점수 기준] + - 85~100: 매우 우수 + - 70~84: 양호 + - 55~69: 개선 필요 + - 40~54: 대폭 수정 필요 + - 40 미만: 직무/JD와 거의 무관 + + [세부 기준] + - jobFit: JD와 직무 역량 매칭 + - impact: 성과 구체성, 수치, 결과 + - completeness: 문장 완성도, 논리 흐름, 질문 적합성 + + [상태 라벨 참고] + - proven: 구체적 경험/수치로 충분히 입증됨 + - mentioned: 관련 내용을 언급은 했지만 구체 근거가 부족함 + - missing: 자소서에서 아예 다루지 않음 + - fabricated: 주장은 하지만 신뢰할 수 있는 근거가 부족함 + + [약점 유형 참고] + unsupported_claim, vague_evidence, exaggeration, missing_outcome + + [채용 공고] + 회사명: %s + 직무명: %s + 주요 업무: + %s + + 자격 요건: + %s + + 우대 사항: + %s + + [자소서 문항과 답변] + %s + + [중요 규칙] + - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. + - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - questionAnalyses의 status는 proven, mentioned, missing, fabricated 중 하나만 사용한다. + - sentence는 answer에 포함된 정확한 substring만 사용한다. + - start/end index는 출력하지 않는다. 서버가 Java에서 계산한다. + - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. + """.formatted( + defaultString(jobPosting.getCompany().getName()), + defaultString(jobPosting.getDetailClassification().getDetailName()), + defaultString(jobPosting.getTask()), + defaultString(jobPosting.getRequirement()), + defaultString(jobPosting.getPreferred()), + questionText + ); + } + + private AnalysisLlmResponse extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(StructuredResponseOutputMessage.Content::asOutputText) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + )); + } + + private String defaultString(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java new file mode 100644 index 0000000..a787cda --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java @@ -0,0 +1,216 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final AnalysisRepository analysisRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final AnalysisAiClient analysisAiClient; + + @Transactional + public AnalysisResponse analyze(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + + AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions); + replaceExistingAnalysis(mockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + mockApply, + clampScore(llmResponse.score()), + clampScore(llmResponse.jobFit()), + clampScore(llmResponse.impact()), + clampScore(llmResponse.completeness()), + normalizeFeedback(llmResponse.feedback()) + )); + + List questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse); + questionAnalysisRepository.saveAll(questionAnalyses); + mockApply.updateStatus(MockApplyStatus.COMPLETED); + + return getAnalysis(user, mockApplyId); + } + + public AnalysisResponse getAnalysis(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_NOT_FOUND, + "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse(mockApply, analysis, questions, questionAnalyses); + } + + private void replaceExistingAnalysis(MockApply mockApply) { + Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); + if (existingAnalysis.isEmpty()) { + return; + } + + Analysis analysis = existingAnalysis.get(); + mockApply.clearAnalysis(); + questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); + analysisRepository.delete(analysis); + analysisRepository.flush(); + } + + private List buildQuestionAnalyses( + Analysis analysis, + List questions, + AnalysisLlmResponse llmResponse + ) { + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + List result = new ArrayList<>(); + + if (llmResponse.questionAnalyses() == null) { + return result; + } + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + + Question question = questionMap.get(item.questionId()); + if (question == null) { + continue; + } + + String answer = question.getAnswer(); + String sentence = item.sentence(); + int start = answer.indexOf(sentence); + if (start < 0) { + continue; + } + + result.add(QuestionAnalysis.create( + question, + analysis, + sentence, + defaultString(item.reason()), + defaultString(item.improvement()), + normalizeStatus(item.status()), + start, + start + sentence.length() + )); + } + + return result; + } + + private AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses + ) { + Map> analysesByQuestionId = questionAnalyses.stream() + .collect(Collectors.groupingBy( + questionAnalysis -> questionAnalysis.getQuestion().getId(), + Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) + )); + + List questionResponses = questions.stream() + .sorted(Comparator.comparing(Question::getId)) + .map(question -> AnalysisQuestionResponse.of( + question, + analysesByQuestionId.getOrDefault(question.getId(), List.of()) + )) + .toList(); + + return AnalysisResponse.of(analysis, mockApply.getStatus(), questionResponses); + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + private int clampScore(Integer score) { + if (score == null) { + return 0; + } + return Math.max(0, Math.min(100, score)); + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private QuestionAnalysisStatus normalizeStatus(String status) { + if (!StringUtils.hasText(status)) { + return QuestionAnalysisStatus.MENTIONED; + } + + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return QuestionAnalysisStatus.MENTIONED; + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java index fd6ef28..e0ebba9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionService.java @@ -35,11 +35,11 @@ public class QuestionService { private static final int DEFAULT_CHAR_LIMIT = 1000; private static final List DEFAULT_CANDIDATES = List.of( - new QuestionCandidate("지원 동기와 입사 후 목표를 작성해주세요.", 700), - new QuestionCandidate("지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), - new QuestionCandidate("문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), - new QuestionCandidate("협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), - new QuestionCandidate("가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) + new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), + new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), + new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), + new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), + new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) ); private final MockApplyRepository mockApplyRepository; @@ -53,6 +53,7 @@ public List getQuestionCandidates(User user, Long moc return DEFAULT_CANDIDATES.stream() .map(candidate -> new QuestionCandidateResponse( + candidate.id(), candidate.content(), candidate.charLimit(), selectedContents.contains(candidate.content()) @@ -165,6 +166,6 @@ private String normalizeAnswer(String answer) { return ""; } - private record QuestionCandidate(String content, int charLimit) { + private record QuestionCandidate(Long id, String content, int charLimit) { } } 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 3cad853..b89683a 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 @@ -1,8 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.controller; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -23,7 +22,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.GetMapping; @@ -41,33 +39,17 @@ public class JobPostingAiController { @Operation( summary = "채용 공고 정보 추출", - description = "채용 공고 원문 텍스트를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다." + description = "채용 공고 원문 텍스트 또는 업로드된 이미지 object key를 기반으로 회사명, 직무명, 주요 업무, 자격 요건, 우대 사항을 AI로 추출합니다." ) @PostMapping(value = "/extract", consumes = MediaType.APPLICATION_JSON_VALUE) - public ApiResponse extractJobPostingFromText( + public ApiResponse extractJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, @Valid @RequestBody JobPostingExtractRequest request ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "채용 공고 추출에 성공했습니다.", - jobPostingAiService.extractJobPosting(request.rawText()) - ); - } - - @Operation( - summary = "채용 공고 정보 추출(이미지 또는 텍스트)", - description = "프론트에서 캡처한 채용 공고 이미지 파일과 선택적 텍스트, 원본 URL을 함께 보내면 AI가 구조화된 채용 공고 정보를 추출합니다." - ) - @PostMapping(value = "/extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse extractJobPostingFromMultipart( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @ModelAttribute JobPostingExtractMultipartRequest request - ) { - validateAuthenticatedUser(userDetails); + var user = validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( "채용 공고 추출에 성공했습니다.", - jobPostingAiService.extractJobPosting(request) + jobPostingAiService.extractJobPosting(user.getId(), request) ); } @@ -98,10 +80,10 @@ public ApiResponse extractJobPostingFromMultipart( ) ) }) - @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/ingest", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse ingestJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, - @ModelAttribute JobPostingIngestMultipartRequest request + @Valid @RequestBody JobPostingIngestRequest request ) { var user = validateAuthenticatedUser(userDetails); return ApiResponse.onSuccess( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java new file mode 100644 index 0000000..083d0dd --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingUploadController.java @@ -0,0 +1,43 @@ +package com.jobdri.jobdri_api.domain.jobposting.controller; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingImageUploadPresignRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingImageUploadPresignResponse; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/job-postings/images") +@Tag(name = "JobPosting Upload", description = "채용 공고 이미지 업로드 API") +public class JobPostingUploadController { + + private final JobPostingImageStorageService jobPostingImageStorageService; + private final UserService userService; + + @Operation( + summary = "채용 공고 이미지 업로드용 presigned PUT URL 발급", + description = "로그인 사용자가 S3에 직접 이미지를 업로드할 수 있도록 presigned PUT URL과 object key를 발급합니다." + ) + @PostMapping("/presign-upload") + public ApiResponse createPresignedUploadUrl( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody JobPostingImageUploadPresignRequest request + ) { + var user = userService.validateUser(userDetails == null ? null : userDetails.getUser()); + return ApiResponse.onSuccess( + "채용 공고 이미지 업로드 URL 발급에 성공했습니다.", + jobPostingImageStorageService.createUploadPresignUrl(user.getId(), request) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java deleted file mode 100644 index 04fc410..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingExtractMultipartRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.dto.request; - -import org.springframework.web.multipart.MultipartFile; - -public record JobPostingExtractMultipartRequest(String rawText, String sourceUrl, MultipartFile image) { - -} 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 1bbe645..662f19a 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 @@ -1,9 +1,18 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.request; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.AssertTrue; public record JobPostingExtractRequest( - @NotBlank(message = "채용 공고 원문은 필수입니다.") - String rawText + String rawText, + String imageObjectKey ) { + + @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public boolean hasInput() { + return hasText(rawText) || hasText(imageObjectKey); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java new file mode 100644 index 0000000..6fd6236 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingImageUploadPresignRequest.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record JobPostingImageUploadPresignRequest( + @NotBlank(message = "파일명은 필수입니다.") + String fileName, + + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType +) { +} 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 f697afb..41dacfb 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 @@ -9,7 +9,5 @@ public class JobPostingIngestCommand { private Long userId; private String rawText; - private String sourceUrl; - private byte[] imageBytes; - private String imageContentType; + private String imageObjectKey; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java deleted file mode 100644 index d9c067a..0000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestMultipartRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.dto.request; - -import org.springframework.web.multipart.MultipartFile; - -public record JobPostingIngestMultipartRequest(String rawText, String sourceUrl, MultipartFile image) { - -} 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 new file mode 100644 index 0000000..e33bd53 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/request/JobPostingIngestRequest.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.request; + +import jakarta.validation.constraints.AssertTrue; + +public record JobPostingIngestRequest( + String rawText, + String imageObjectKey +) { + + @AssertTrue(message = "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다.") + public boolean hasInput() { + return hasText(rawText) || hasText(imageObjectKey); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java new file mode 100644 index 0000000..517fd20 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingImageUploadPresignResponse.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +public record JobPostingImageUploadPresignResponse( + String objectKey, + String uploadUrl, + long expiresInMinutes +) { +} 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 8a00ba8..38071cc 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 @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingExtractRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; @@ -22,13 +22,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Set; -import java.util.Base64; import java.util.stream.Collectors; @Service @@ -41,20 +37,17 @@ public class JobPostingAiService { private final OpenAIClient openAIClient; private final DetailClassificationRepository detailClassificationRepository; private final JobPostingRepository jobPostingRepository; + private final JobPostingImageStorageService jobPostingImageStorageService; @Value("${openai.model.job-posting-extractor:gpt-4o-mini}") private String extractionModel; - private static final Set SUPPORTED_IMAGE_TYPES = Set.of( - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif" - ); - public JobPostingExtractResponse extractJobPosting(String rawText) { - return extractJobPosting(rawText, null, null); + return extractJobPosting(null, rawText, null); + } + + public JobPostingExtractResponse extractJobPosting(Long userId, JobPostingExtractRequest request) { + return extractJobPosting(userId, request.rawText(), request.imageObjectKey()); } public JobPostingGenerateResponse generateJobPosting(JobPostingGenerateRequest request) { @@ -156,22 +149,21 @@ public JobPostingClassificationResultResponse classifyDetailClassification( } } - public JobPostingExtractResponse extractJobPosting(JobPostingExtractMultipartRequest request) { - return extractJobPosting(request.rawText(), request.image(), request.sourceUrl()); - } - - public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageBytes, String imageContentType, String sourceUrl) { - validateInput(rawText, imageBytes); + public JobPostingExtractResponse extractJobPosting(Long userId, String rawText, String imageObjectKey) { + validateInput(rawText, imageObjectKey); + String imageUrl = hasText(imageObjectKey) + ? jobPostingImageStorageService.createReadableImageUrl(userId, imageObjectKey) + : null; List contents = new ArrayList<>(); contents.add(ResponseInputContent.ofInputText( com.openai.models.responses.ResponseInputText.builder() - .text(buildPrompt(rawText, sourceUrl, imageBytes != null && imageBytes.length > 0)) + .text(buildPrompt(rawText, imageUrl != null)) .build() )); - if (imageBytes != null && imageBytes.length > 0) { - contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageBytes, imageContentType))); + if (imageUrl != null) { + contents.add(ResponseInputContent.ofInputImage(buildImageContent(imageUrl))); } var params = ResponseCreateParams.builder() @@ -198,18 +190,8 @@ public JobPostingExtractResponse extractJobPosting(String rawText, byte[] imageB } } - public JobPostingExtractResponse extractJobPosting(String rawText, MultipartFile imageFile, String sourceUrl) { - return extractJobPosting( - rawText, - imageFile == null || imageFile.isEmpty() ? null : readImageBytes(imageFile), - imageFile == null || imageFile.isEmpty() ? null : imageFile.getContentType(), - sourceUrl - ); - } - - private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { + private String buildPrompt(String rawText, boolean hasImage) { String normalizedRawText = rawText == null ? "" : rawText; - String normalizedSourceUrl = sourceUrl == null ? "" : sourceUrl; return """ 이 %s는 채용 공고입니다. @@ -236,12 +218,9 @@ private String buildPrompt(String rawText, String sourceUrl, boolean hasImage) { 5. confidence는 추출 결과 전체에 대한 신뢰도를 0~1 사이 실수로 반환하세요. 6. JSON 외의 다른 텍스트는 절대 출력하지 마세요. - [원본 URL] - %s - [채용 공고 텍스트] %s - """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedSourceUrl, normalizedRawText); + """.formatted(hasImage ? "이미지 또는 텍스트" : "텍스트", normalizedRawText); } private String buildClassificationPrompt( @@ -304,17 +283,9 @@ private String buildClassificationPrompt( ); } - private ResponseInputImage buildImageContent(MultipartFile imageFile) { - return buildImageContent(readImageBytes(imageFile), imageFile.getContentType()); - } - - private ResponseInputImage buildImageContent(byte[] imageBytes, String imageContentType) { - validateImage(imageContentType); - String base64 = Base64.getEncoder().encodeToString(imageBytes); - String dataUrl = "data:%s;base64,%s".formatted(imageContentType, base64); - + private ResponseInputImage buildImageContent(String imageUrl) { return ResponseInputImage.builder() - .imageUrl(dataUrl) + .imageUrl(imageUrl) .detail(ResponseInputImage.Detail.HIGH) .build(); } @@ -332,53 +303,18 @@ private T extractStructuredContent(StructuredResponse response, Class )); } - private void validateInput(String rawText, MultipartFile imageFile) { - boolean hasRawText = rawText != null && !rawText.isBlank(); - boolean hasImage = imageFile != null && !imageFile.isEmpty(); - - if (!hasRawText && !hasImage) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "rawText 또는 image 중 하나는 반드시 포함되어야 합니다." - ); - } - } - - private void validateInput(String rawText, byte[] imageBytes) { - boolean hasRawText = rawText != null && !rawText.isBlank(); - boolean hasImage = imageBytes != null && imageBytes.length > 0; + 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 또는 image 중 하나는 반드시 포함되어야 합니다." - ); - } - } - - private void validateImage(MultipartFile imageFile) { - validateImage(imageFile.getContentType()); - } - - private void validateImage(String contentType) { - if (contentType == null || !SUPPORTED_IMAGE_TYPES.contains(contentType.toLowerCase())) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + "rawText 또는 imageObjectKey 중 하나는 반드시 포함되어야 합니다." ); } } - private byte[] readImageBytes(MultipartFile imageFile) { - validateImage(imageFile); - - try { - return imageFile.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - private JobPostingExtractResponse normalizeResponse(JobPostingExtractResponse response, String rawText) { if (response == null) { throw new GeneralException( @@ -831,6 +767,10 @@ private String defaultString(String value) { return value == null ? "" : value; } + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private List defaultStringList(List values) { return values == null ? List.of() : values; } 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 f252864..55d00e5 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 @@ -1,7 +1,7 @@ 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.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -11,9 +11,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.core.task.TaskRejectedException; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; @Service @RequiredArgsConstructor @@ -23,7 +20,7 @@ public class JobPostingAsyncFacadeService { private final JobPostingAsyncProcessor jobPostingAsyncProcessor; private final UserService userService; - public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestMultipartRequest request) { + public JobPostingAsyncSubmitResponse submit(User user, JobPostingIngestRequest request) { User validatedUser = userService.validateUser(user); String taskId = jobPostingAsyncTaskService.createPendingTask(); JobPostingIngestCommand command = snapshot(validatedUser, request); @@ -47,32 +44,11 @@ public JobPostingAsyncStatusResponse getTask(String taskId) { return jobPostingAsyncTaskService.getTask(taskId); } - private JobPostingIngestCommand snapshot(User user, JobPostingIngestMultipartRequest request) { + private JobPostingIngestCommand snapshot(User user, JobPostingIngestRequest request) { return JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .sourceUrl(request.sourceUrl()) - .imageBytes(readBytes(request.image())) - .imageContentType(readContentType(request.image())) + .imageObjectKey(request.imageObjectKey()) .build(); } - - private byte[] readBytes(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - - try { - return image.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - - private String readContentType(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - return image.getContentType(); - } } 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 new file mode 100644 index 0000000..9cb1941 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingImageStorageService.java @@ -0,0 +1,159 @@ +package com.jobdri.jobdri_api.domain.jobposting.service; + +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingImageUploadPresignRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingImageUploadPresignResponse; +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.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Service +public class JobPostingImageStorageService { + + private static final String BASE_DIR = "job-postings/tmp"; + 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" + ); + + private final S3ObjectUrlService s3ObjectUrlService; + private final S3Client s3Client; + private final long presignPutExpirationMinutes; + private final long maxImageSizeBytes; + + public JobPostingImageStorageService( + S3ObjectUrlService s3ObjectUrlService, + S3Client s3Client, + @Value("${spring.cloud.aws.s3.presign-put-expiration-minutes:5}") long presignPutExpirationMinutes, + @Value("${job-posting.image-upload.max-size-bytes:5242880}") long maxImageSizeBytes + ) { + this.s3ObjectUrlService = s3ObjectUrlService; + this.s3Client = s3Client; + this.presignPutExpirationMinutes = presignPutExpirationMinutes; + this.maxImageSizeBytes = maxImageSizeBytes; + } + + public JobPostingImageUploadPresignResponse createUploadPresignUrl( + Long userId, + JobPostingImageUploadPresignRequest request + ) { + String normalizedContentType = normalizeContentType(request.contentType()); + String extension = CONTENT_TYPE_TO_EXTENSION.get(normalizedContentType); + + if (extension == null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + ); + } + + String objectKey = buildObjectKey(userId, extension); + String uploadUrl = s3ObjectUrlService.createPresignedPutUrl( + objectKey, + normalizedContentType, + presignPutExpirationMinutes + ); + + return new JobPostingImageUploadPresignResponse( + objectKey, + uploadUrl, + presignPutExpirationMinutes + ); + } + + public String createReadableImageUrl(Long userId, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + validateOwnership(userId, objectKey); + validateUploadedObject(objectKey); + return s3ObjectUrlService.createPresignedGetUrl(objectKey); + } + + public void validateOwnership(Long userId, String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return; + } + + String expectedPrefix = buildUserPrefix(userId); + if (!objectKey.startsWith(expectedPrefix)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "본인이 업로드한 채용 공고 이미지만 사용할 수 있습니다." + ); + } + } + + private String buildObjectKey(Long userId, String extension) { + return buildUserPrefix(userId) + UUID.randomUUID() + "." + extension; + } + + private String buildUserPrefix(Long userId) { + return BASE_DIR + "/" + userId + "/"; + } + + private String normalizeContentType(String contentType) { + return contentType == null ? "" : contentType.trim().toLowerCase(Locale.ROOT); + } + + private void validateUploadedObject(String objectKey) { + if (objectKey == null || objectKey.isBlank()) { + return; + } + + HeadObjectResponse headObject; + try { + headObject = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(s3ObjectUrlService.getBucket()) + .key(objectKey) + .build() + ); + } catch (S3Exception e) { + if (e.statusCode() == 404) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "업로드된 이미지를 찾을 수 없습니다. objectKey=" + objectKey + ); + } + if (e.statusCode() == 403) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "업로드된 이미지에 접근할 수 없습니다. objectKey=" + objectKey + ); + } + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "업로드된 이미지 검증 중 오류가 발생했습니다. objectKey=" + objectKey + ); + } + + String normalizedContentType = normalizeContentType(headObject.contentType()); + if (!CONTENT_TYPE_TO_EXTENSION.containsKey(normalizedContentType)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "지원하는 이미지 형식은 png, jpg, jpeg, webp, gif 입니다." + ); + } + + Long contentLength = headObject.contentLength(); + if (contentLength != null && contentLength > maxImageSizeBytes) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미지 파일 크기가 허용 범위를 초과했습니다." + ); + } + } +} 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 f4f7a82..a215cae 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 @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingGenerateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestCommand; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -17,9 +17,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.IOException; import java.util.List; @Service @@ -36,13 +33,11 @@ public class JobPostingIngestService { private final JobPostingService jobPostingService; private final UserService userService; - public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMultipartRequest request) { + public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestRequest request) { JobPostingIngestCommand command = JobPostingIngestCommand.builder() .userId(user.getId()) .rawText(request.rawText()) - .sourceUrl(request.sourceUrl()) - .imageBytes(readBytes(request.image())) - .imageContentType(readContentType(request.image())) + .imageObjectKey(request.imageObjectKey()) .build(); return ingestAndCreate(command); } @@ -50,10 +45,9 @@ public JobPostingIngestResponse ingestAndCreate(User user, JobPostingIngestMulti public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) { JobPostingExtractResponse extracted = jobPostingAiService.extractJobPosting( + command.getUserId(), command.getRawText(), - command.getImageBytes(), - command.getImageContentType(), - command.getSourceUrl() + command.getImageObjectKey() ); List candidates = @@ -133,22 +127,4 @@ private User resolveUser(JobPostingIngestCommand command) { return userService.getUser(command.getUserId()); } - private byte[] readBytes(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - - try { - return image.getBytes(); - } catch (IOException e) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미지 파일을 읽을 수 없습니다."); - } - } - - private String readContentType(MultipartFile image) { - if (image == null || image.isEmpty()) { - return null; - } - return image.getContentType(); - } } 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 9a8eab9..fe1e4b5 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 @@ -72,4 +72,8 @@ public Question addQuestion(String content, int limit, String answer) { public void assignAnalysis(Analysis analysis) { this.analysis = analysis; } + + public void clearAnalysis() { + this.analysis = null; + } } 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 76f87d1..9a5cda2 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 @@ -42,6 +42,7 @@ public enum GeneralErrorCode implements BaseErrorCode { // 모의 서류 지원 에러 MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."), QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."), + ANALYSIS_NOT_FOUND(HttpStatus.NOT_FOUND, "ANALYSIS_4041", "자소서 분석 결과를 찾을 수 없습니다."), // 유저 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다."); diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java new file mode 100644 index 0000000..a762988 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Config.java @@ -0,0 +1,32 @@ +package com.jobdri.jobdri_api.global.config.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + + +@Configuration +public class S3Config { + @Value("${spring.cloud.aws.region.static:ap-northeast-2}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java new file mode 100644 index 0000000..59fc323 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3ObjectUrlService.java @@ -0,0 +1,71 @@ +package com.jobdri.jobdri_api.global.config.s3; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class S3ObjectUrlService { + + private final S3Presigner s3Presigner; + + @Value("${spring.cloud.aws.s3.bucket:dummy-bucket}") + private String bucket; + + @Value("${spring.cloud.aws.s3.presign-get-expiration-minutes:10}") + private long presignGetExpirationMinutes; + + public String createPresignedGetUrl(String objectKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(validateExpirationMinutes( + presignGetExpirationMinutes, + "spring.cloud.aws.s3.presign-get-expiration-minutes" + ))) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + public String createPresignedPutUrl(String objectKey, String contentType, long expiresInMinutes) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(validateExpirationMinutes( + expiresInMinutes, + "presigned PUT expiration minutes" + ))) + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } + + public String getBucket() { + return bucket; + } + + private long validateExpirationMinutes(long expirationMinutes, String propertyName) { + if (expirationMinutes < 1 || expirationMinutes > 10080) { + throw new IllegalArgumentException(propertyName + " must be between 1 and 10080 minutes."); + } + return expirationMinutes; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java new file mode 100644 index 0000000..1b898f6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/s3/S3Uploader.java @@ -0,0 +1,117 @@ +package com.jobdri.jobdri_api.global.config.s3; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Uploader { + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket:dummy-bucket}") + private String bucket; + + // MultipartFile을 전달받아 S3에 업로드 + public String upload(MultipartFile multipartFile, String dirName) throws IOException { + File uploadFile = convert(multipartFile) + .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패")); + return upload(uploadFile, dirName); + } + + // S3에 업로드할 때도 고유한 파일 이름을 사용하도록 수정합니다. + private String upload(File uploadFile, String dirName) { + // convert 메소드에서 생성한 고유한 이름을 그대로 사용합니다. + String fileName = dirName + "/" + uploadFile.getName(); + try { + return putS3(uploadFile, fileName); + } finally { + removeNewFile(uploadFile); + } + } + + // AWS SDK v2에 맞게 PutObjectRequest를 builder 패턴으로 변경 + private String putS3(File uploadFile, String fileName) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .build(); + + try { + s3Client.putObject(putObjectRequest, RequestBody.fromFile(uploadFile)); + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } catch (Exception e) { + throw new RuntimeException("Failed to upload file to S3", e); + } + } + + // AWS SDK v2에 맞게 DeleteObjectRequest를 builder 패턴으로 변경 + public void deleteFile(String fileUrl) { + try { + // S3 URL에서 파일 이름(키)만 추출 + java.net.URL url = new java.net.URL(fileUrl); + String fileName = url.getPath().substring(1); // 맨 앞의 '/' 제거 + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucket) + .key(fileName) // 예: 'images/abc.jpg' + .build(); + + s3Client.deleteObject(deleteObjectRequest); + } catch (Exception e) { + log.error("S3 파일 삭제에 실패했습니다. URL: {}", fileUrl, e); + } + } + + private void removeNewFile(File targetFile) { + if (targetFile.delete()) { + log.info("파일이 삭제되었습니다."); + } else { + log.info("파일이 삭제되지 못했습니다."); + } + } + + // MultipartFile을 File로 변환하고, 파일명에 UUID를 적용 + private Optional convert(MultipartFile file) throws IOException { + if (file.isEmpty()) { + return Optional.empty(); + } + + String originalFilename = file.getOriginalFilename(); + String storedFileName = UUID.randomUUID() + "." + extractExt(originalFilename); + File convertFile = new File(System.getProperty("java.io.tmpdir"), storedFileName); + + if (convertFile.createNewFile()) { + try (FileOutputStream fos = new FileOutputStream(convertFile)) { + fos.write(file.getBytes()); + } + return Optional.of(convertFile); + } + return Optional.empty(); + } + + // 파일의 확장자를 추출하는 헬퍼 메소드 + private String extractExt(String originalFilename) { + if (originalFilename != null) { + int pos = originalFilename.lastIndexOf("."); + if (pos != -1 && pos < originalFilename.length() - 1) { + return originalFilename.substring(pos + 1); + } + } + return ""; + } + +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 3a0975f..170de28 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -49,6 +49,17 @@ spring: - email - profile redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID:dummy-access-key} + secret-key: ${AWS_SECRET_ACCESS_KEY:dummy-secret-key} + region: + static: ${AWS_REGION:ap-northeast-2} + s3: + bucket: ${S3_BUCKET:dummy-bucket} + presign-get-expiration-minutes: ${S3_PRESIGN_GET_EXPIRATION_MINUTES:10} + presign-put-expiration-minutes: ${S3_PRESIGN_PUT_EXPIRATION_MINUTES:5} mail: from: ${MAIL_FROM:${MAIL_USERNAME:}} @@ -62,7 +73,7 @@ server: jwt: secret: - key: ${JWT_SECRET_KEY} + key: ${JWT_SECRET_KEY:am9iZHJpLWxvY2FsLXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LTIwMjYtam9iZHJp} expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} @@ -76,6 +87,8 @@ openai: job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + image-upload: + max-size-bytes: ${JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES:5242880} async: job-posting: diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 48e5ca1..b20ffc4 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -49,6 +49,17 @@ spring: - email - profile redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} + s3: + bucket: ${S3_BUCKET} + presign-get-expiration-minutes: ${S3_PRESIGN_GET_EXPIRATION_MINUTES:10} + presign-put-expiration-minutes: ${S3_PRESIGN_PUT_EXPIRATION_MINUTES:5} mail: from: ${MAIL_FROM:${MAIL_USERNAME:}} @@ -88,6 +99,8 @@ openai: job-posting: ingest: classification-confidence-threshold: ${JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD:0.65} + image-upload: + max-size-bytes: ${JOB_POSTING_IMAGE_UPLOAD_MAX_SIZE_BYTES:5242880} async: job-posting: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f9333d9..d2c0e0a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,3 @@ spring: profiles: - active: prod # 본인이 테스트할 환경에 따라서 바꾸기 \ No newline at end of file + active: ${SPRING_PROFILES_ACTIVE:dev} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java new file mode 100644 index 0000000..fb9852b --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisServiceTest.java @@ -0,0 +1,289 @@ +package com.jobdri.jobdri_api.domain.analysis.service; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.classification.entity.Classification; +import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; +import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; +import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; +import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; +import com.jobdri.jobdri_api.domain.company.entity.Company; +import com.jobdri.jobdri_api.domain.company.entity.CompanySize; +import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AnalysisServiceTest { + + @Autowired + private AnalysisService analysisService; + + @Autowired + private AnalysisRepository analysisRepository; + + @Autowired + private QuestionAnalysisRepository questionAnalysisRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private MockApplyRepository mockApplyRepository; + + @Autowired + private JobPostingRepository jobPostingRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private ClassificationRepository classificationRepository; + + @Autowired + private DetailClassificationRepository detailClassificationRepository; + + @Autowired + private UserRepository userRepository; + + @MockBean + private AnalysisAiClient analysisAiClient; + + @Test + @DisplayName("자소서 분석을 실행하고 결과와 문항 분석을 저장한다") + void analyzeSavesAnalysis() { + User user = saveUser("analysis-save@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 직무 경험을 작성해주세요.", "Spring Boot API를 개발했습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 120, + 82, + 71, + 80, + "직무 경험은 좋지만 성과 근거 보완이 필요합니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "Spring Boot API를 개발했습니다.", + "mentioned", + "성과 지표가 없어 구체성이 약합니다.", + "Spring Boot API를 개발해 응답 시간을 20% 개선했습니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.status()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(response.score()).isEqualTo(100); + assertThat(response.jobFit()).isEqualTo(82); + assertThat(response.impact()).isEqualTo(71); + assertThat(response.completeness()).isEqualTo(80); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + assertThat(response.questions().get(0).analyses().get(0).status()).isEqualTo("mentioned"); + assertThat(response.questions().get(0).analyses().get(0).start()).isEqualTo(0); + assertThat(response.questions().get(0).analyses().get(0).end()) + .isEqualTo("Spring Boot API를 개발했습니다.".length()); + assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.COMPLETED); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId())).isPresent(); + } + + @Test + @DisplayName("답변이 없는 경우 분석을 실행할 수 없다") + void analyzeThrowsWhenNoAnswers() { + User user = saveUser("analysis-empty-answer@example.com"); + MockApply mockApply = saveMockApply(user); + saveQuestion(mockApply, "지원 동기", ""); + + assertThatThrownBy(() -> analysisService.analyze(user, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); + } + + @Test + @DisplayName("다른 사용자의 지원서는 분석할 수 없다") + void analyzeThrowsWhenUserDoesNotOwnMockApply() { + User owner = saveUser("analysis-owner@example.com"); + User other = saveUser("analysis-other@example.com"); + MockApply mockApply = saveMockApply(owner); + saveQuestion(mockApply, "지원 동기", "백엔드 개발 경험이 있습니다."); + + assertThatThrownBy(() -> analysisService.analyze(other, mockApply.getId())) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + } + + @Test + @DisplayName("LLM sentence가 원문에 없으면 문항 분석 저장에서 제외한다") + void analyzeSkipsSentenceNotInAnswer() { + User user = saveUser("analysis-skip-sentence@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "문제 해결 경험", "장애 로그를 분석해 원인을 찾았습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 64, + 70, + 55, + 67, + "원문 매칭 실패 문장은 제외됩니다.", + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "답변에 없는 문장입니다.", + "fabricated", + "원문에 없습니다.", + "원문 기반 문장으로 개선해야 합니다." + ) + ) + )); + + AnalysisResponse response = analysisService.analyze(user, mockApply.getId()); + + assertThat(response.questions().get(0).analyses()).isEmpty(); + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow(); + assertThat(questionAnalysisRepository.findAllByAnalysisId(analysis.getId())).isEmpty(); + } + + @Test + @DisplayName("재분석 시 기존 분석과 문항 분석을 새 결과로 교체한다") + void analyzeReplacesExistingAnalysis() { + User user = saveUser("analysis-replace@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "성과 경험", "가입 완료율을 개선했습니다. API 응답 속도를 개선했습니다."); + when(analysisAiClient.analyze(any(), any())) + .thenReturn(new AnalysisLlmResponse( + 60, + 61, + 62, + 63, + "첫 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "가입 완료율을 개선했습니다.", + "mentioned", + "수치가 부족합니다.", + "가입 완료율을 12% 개선했습니다." + )) + )) + .thenReturn(new AnalysisLlmResponse( + 88, + 89, + 90, + 91, + "두 번째 분석", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "API 응답 속도를 개선했습니다.", + "proven", + "성과 기준이 더 필요합니다.", + "API 응답 속도를 300ms 단축했습니다." + )) + )); + + AnalysisResponse first = analysisService.analyze(user, mockApply.getId()); + AnalysisResponse second = analysisService.analyze(user, mockApply.getId()); + + assertThat(second.analysisId()).isNotEqualTo(first.analysisId()); + assertThat(second.score()).isEqualTo(88); + assertThat(second.feedback()).isEqualTo("두 번째 분석"); + assertThat(second.questions().get(0).analyses().get(0).status()).isEqualTo("proven"); + assertThat(analysisRepository.findByMockApplyId(mockApply.getId()).orElseThrow().getScore()).isEqualTo(88); + assertThat(questionAnalysisRepository.findAllByAnalysisId(second.analysisId())).hasSize(1); + assertThat(questionAnalysisRepository.findAllByAnalysisId(first.analysisId())).isEmpty(); + } + + @Test + @DisplayName("저장된 분석 결과를 조회한다") + void getAnalysis() { + User user = saveUser("analysis-get@example.com"); + MockApply mockApply = saveMockApply(user); + Question question = saveQuestion(mockApply, "지원 동기", "서비스 개선 경험이 있습니다."); + when(analysisAiClient.analyze(any(), any())).thenReturn(new AnalysisLlmResponse( + 75, + 76, + 77, + 78, + "저장된 분석 결과입니다.", + List.of(new AnalysisLlmResponse.QuestionAnalysisItem( + question.getId(), + "서비스 개선 경험이 있습니다.", + "mentioned", + "구체성이 조금 부족합니다.", + "서비스 개선 경험으로 전환율을 10% 높였습니다." + )) + )); + AnalysisResponse saved = analysisService.analyze(user, mockApply.getId()); + + AnalysisResponse response = analysisService.getAnalysis(user, mockApply.getId()); + + assertThat(response.analysisId()).isEqualTo(saved.analysisId()); + assertThat(response.score()).isEqualTo(75); + assertThat(response.questions()).hasSize(1); + assertThat(response.questions().get(0).analyses()).hasSize(1); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } + + private MockApply saveMockApply(User user) { + JobPosting jobPosting = saveJobPosting(user); + return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); + } + + private Question saveQuestion(MockApply mockApply, String content, String answer) { + return questionRepository.save(Question.create(mockApply, content, 1000, answer)); + } + + private JobPosting saveJobPosting(User user) { + Company company = companyRepository.save(Company.create("분석 테스트 기업", CompanySize.MEDIUM)); + DetailClassification detailClassification = saveDetailClassification(); + return jobPostingRepository.save(JobPosting.create( + user, + company, + detailClassification, + "주요 업무", + "자격 요건", + "우대 사항" + )); + } + + private DetailClassification saveDetailClassification() { + Classification classification = Classification.create("분석 테스트 대분류 " + System.nanoTime()); + MiddleClassification middleClassification = classification.addMiddleClassification("분석 테스트 중분류"); + DetailClassification detailClassification = middleClassification.addDetailClassification("분석 테스트 소분류"); + classificationRepository.save(classification); + return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java index 23d758b..3275f83 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/QuestionServiceTest.java @@ -122,6 +122,9 @@ void getQuestionCandidatesMarksSelectedQuestion() { List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); assertThat(candidates).hasSize(5); + assertThat(candidates) + .extracting(QuestionCandidateResponse::questionId) + .containsExactly(1L, 2L, 3L, 4L, 5L); assertThat(candidates.get(0).selected()).isTrue(); assertThat(candidates.get(1).selected()).isFalse(); } 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 71eb4d7..7de1202 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 @@ -13,6 +13,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; +import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingImageStorageService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.client.OpenAIClient; @@ -47,6 +48,9 @@ class JobPostingAiServiceTest { @Mock private JobPostingRepository jobPostingRepository; + @Mock + private JobPostingImageStorageService jobPostingImageStorageService; + private JobPostingAiService jobPostingAiService; @BeforeEach @@ -54,7 +58,8 @@ void setUp() { jobPostingAiService = new JobPostingAiService( openAIClient, detailClassificationRepository, - jobPostingRepository + jobPostingRepository, + jobPostingImageStorageService ); ReflectionTestUtils.setField(TEST_COMPANY, "id", 1L); ReflectionTestUtils.setField(TEST_USER, "id", 1L); 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 8a594b1..121894b 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 @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestMultipartRequest; +import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationCandidateResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; @@ -18,7 +18,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -57,18 +56,11 @@ void setUp() { } @Test - @DisplayName("동기 ingest는 multipart 이미지와 content type을 추출 단계로 전달한다") - void ingestAndCreatePassesMultipartImageToExtract() { - MockMultipartFile image = new MockMultipartFile( - "image", - "posting.png", - "image/png", - new byte[]{1, 2, 3} - ); - JobPostingIngestMultipartRequest request = new JobPostingIngestMultipartRequest( + @DisplayName("동기 ingest는 image object key를 추출 단계로 전달한다") + void ingestAndCreatePassesImageObjectKeyToExtract() { + JobPostingIngestRequest request = new JobPostingIngestRequest( "채용 공고 원문", - "https://example.com/job-posting", - image + "job-postings/1/posting.png" ); JobPostingExtractResponse extracted = new JobPostingExtractResponse( @@ -115,7 +107,7 @@ void ingestAndCreatePassesMultipartImageToExtract() { .preferred("정제된 우대 사항") .build(); - when(jobPostingAiService.extractJobPosting(any(), any(byte[].class), any(), any())) + when(jobPostingAiService.extractJobPosting(any(), any(), any())) .thenReturn(extracted); when(jobPostingClassificationService.findCandidates(extracted, 5)) .thenReturn(List.of(candidate)); @@ -129,17 +121,14 @@ void ingestAndCreatePassesMultipartImageToExtract() { JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request); - ArgumentCaptor imageBytesCaptor = ArgumentCaptor.forClass(byte[].class); - ArgumentCaptor contentTypeCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor imageObjectKeyCaptor = ArgumentCaptor.forClass(String.class); verify(jobPostingAiService).extractJobPosting( + eq(1L), eq("채용 공고 원문"), - imageBytesCaptor.capture(), - contentTypeCaptor.capture(), - eq("https://example.com/job-posting") + imageObjectKeyCaptor.capture() ); - assertThat(imageBytesCaptor.getValue()).containsExactly(1, 2, 3); - assertThat(contentTypeCaptor.getValue()).isEqualTo("image/png"); + assertThat(imageObjectKeyCaptor.getValue()).isEqualTo("job-postings/1/posting.png"); assertThat(response.isSavedToDatabase()).isTrue(); } } diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 999e156..ea6145c 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -2,6 +2,17 @@ spring: sql: init: mode: never + cloud: + aws: + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + s3: + bucket: test-bucket + presign-get-expiration-minutes: 10 + presign-put-expiration-minutes: 5 datasource: url: jdbc:h2:mem:jobdri-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa @@ -61,3 +72,7 @@ jwt: expiration: access-token: 3600000 refresh-token: 1209600000 + +job-posting: + image-upload: + max-size-bytes: 5242880