diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 19dbac2f..7a50eb59 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: Bug report about: Create a report to help us improve -title: '' +title: '[FIX] ' labels: '' assignees: '' diff --git a/.github/ISSUE_TEMPLATE/default-request.md b/.github/ISSUE_TEMPLATE/feat-request.md similarity index 60% rename from .github/ISSUE_TEMPLATE/default-request.md rename to .github/ISSUE_TEMPLATE/feat-request.md index e5562724..256b6ecc 100644 --- a/.github/ISSUE_TEMPLATE/default-request.md +++ b/.github/ISSUE_TEMPLATE/feat-request.md @@ -1,15 +1,12 @@ --- -name: Default request +name: Feat request about: Suggest an idea for this project -title: '' +title: '[FEAT] ' labels: '' assignees: '' --- # 투두 리스트 -- [ ] To-do 1 -- [ ] To-do 2 -- [ ] To-do 3 # 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/refactor-report.md b/.github/ISSUE_TEMPLATE/refactor-report.md new file mode 100644 index 00000000..0a65dc9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-report.md @@ -0,0 +1,12 @@ +--- +name: Refactor request +about: Suggest an idea for this project +title: '[REFACTOR] ' +labels: '' +assignees: '' + +--- + +# 투두 리스트 + +# 참고 사항 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 27a5af3c..90d141eb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,6 @@ +closed # + # 작업 내용 -- 작업 내용 1 -- 작업 내용 2 -- 작업 내용 3 # 스크린샷 diff --git a/api/build.gradle b/api/build.gradle index 5c48d3be..4b0cb7c9 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -27,6 +27,9 @@ dependencies { implementation 'org.springframework.kafka:spring-kafka' + // PDF 텍스트 추출 + implementation 'org.apache.pdfbox:pdfbox:3.0.3' + testImplementation 'com.h2database:h2:2.2.224' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index f8b23212..f76e930d 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -575,3 +575,71 @@ include::{snippetsDir}/resume-evaluation/http-response.adoc[] include::{snippetsDir}/resume-evaluation/response-body.adoc[] include::{snippetsDir}/resume-evaluation/response-fields.adoc[] include::{snippetsDir}/resume-evaluation/curl-request.adoc[] + +=== 이력서 평가 비동기 제출 (파일 업로드) + +include::{snippetsDir}/resume-evaluation-async-submit/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/request-headers.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/request-parts.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/curl-request.adoc[] + +=== 저장된 이력서 기반 평가 비동기 제출 + +include::{snippetsDir}/resume-evaluation-saved-async-submit/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/request-headers.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/request-parts.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/curl-request.adoc[] + +=== 저장된 이력서 기반 평가 포트폴리오 없이 제출 + +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-headers.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-parts.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/curl-request.adoc[] + +=== 이력서 평가 상태 조회 (대기중) + +include::{snippetsDir}/resume-evaluation-state-pending/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-state-pending/path-parameters.adoc[] +include::{snippetsDir}/resume-evaluation-state-pending/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-state-pending/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-state-pending/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-state-pending/curl-request.adoc[] + +=== 이력서 평가 상태 조회 (완료) + +include::{snippetsDir}/resume-evaluation-state-completed/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-state-completed/path-parameters.adoc[] +include::{snippetsDir}/resume-evaluation-state-completed/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-state-completed/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-state-completed/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-state-completed/curl-request.adoc[] + +=== 이력서 평가 히스토리 조회 + +include::{snippetsDir}/resume-evaluation-history/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-history/request-headers.adoc[] +include::{snippetsDir}/resume-evaluation-history/query-parameters.adoc[] +include::{snippetsDir}/resume-evaluation-history/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-history/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-history/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-history/curl-request.adoc[] + +=== 이력서 평가 상세 조회 + +include::{snippetsDir}/resume-evaluation-detail/http-request.adoc[] +include::{snippetsDir}/resume-evaluation-detail/request-headers.adoc[] +include::{snippetsDir}/resume-evaluation-detail/path-parameters.adoc[] +include::{snippetsDir}/resume-evaluation-detail/http-response.adoc[] +include::{snippetsDir}/resume-evaluation-detail/response-body.adoc[] +include::{snippetsDir}/resume-evaluation-detail/response-fields.adoc[] +include::{snippetsDir}/resume-evaluation-detail/curl-request.adoc[] diff --git a/api/src/main/java/com/samhap/kokomen/global/config/AsyncConfig.java b/api/src/main/java/com/samhap/kokomen/global/config/AsyncConfig.java index 066901cf..9108b8ce 100644 --- a/api/src/main/java/com/samhap/kokomen/global/config/AsyncConfig.java +++ b/api/src/main/java/com/samhap/kokomen/global/config/AsyncConfig.java @@ -57,6 +57,20 @@ public ThreadPoolTaskExecutor gptCallbackExecutor() { return executor; } + @Bean("resumeEvaluationExecutor") + public ThreadPoolTaskExecutor resumeEvaluationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(100); + executor.setMaxPoolSize(100); + executor.setQueueCapacity(1000); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.setThreadNamePrefix("Async-Resume-Eval-"); + executor.initialize(); + executor.getThreadPoolExecutor().prestartAllCoreThreads(); + return executor; + } + @Override public Executor getAsyncExecutor() { return taskExecutor(); diff --git a/api/src/main/java/com/samhap/kokomen/global/service/S3Service.java b/api/src/main/java/com/samhap/kokomen/global/service/S3Service.java index 45788281..bb830cdc 100644 --- a/api/src/main/java/com/samhap/kokomen/global/service/S3Service.java +++ b/api/src/main/java/com/samhap/kokomen/global/service/S3Service.java @@ -1,10 +1,14 @@ package com.samhap.kokomen.global.service; import com.samhap.kokomen.global.annotation.ExecutionTimer; +import com.samhap.kokomen.global.constant.AwsConstant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.S3Exception; @@ -14,7 +18,7 @@ @Service public class S3Service { - private static final String S3_BUCKET_NAME = "kokomen"; + private static final String S3_BUCKET_NAME = "kokomen-new"; private final S3Client s3Client; @@ -45,4 +49,26 @@ public boolean exists(String key) { throw e; } } + + public byte[] downloadFile(String key) { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(S3_BUCKET_NAME) + .key(key) + .build(); + + ResponseBytes response = s3Client.getObjectAsBytes(request); + return response.asByteArray(); + } + + public byte[] downloadFileFromUrl(String cdnUrl) { + String key = extractKeyFromCdnUrl(cdnUrl); + return downloadFile(key); + } + + private String extractKeyFromCdnUrl(String cdnUrl) { + if (cdnUrl.startsWith(AwsConstant.CLOUD_FRONT_DOMAIN_URL)) { + return cdnUrl.substring(AwsConstant.CLOUD_FRONT_DOMAIN_URL.length()); + } + throw new IllegalArgumentException("Invalid CDN URL: " + cdnUrl); + } } diff --git a/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java b/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java index fb49a15f..37bc8a0e 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java +++ b/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java @@ -2,19 +2,31 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.resume.domain.CareerMaterialsType; import com.samhap.kokomen.resume.service.CareerMaterialsFacadeService; import com.samhap.kokomen.resume.service.dto.CareerMaterialsResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationAsyncRequest; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationDetailResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationHistoryResponses; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationStateResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationSubmitResponse; import com.samhap.kokomen.resume.service.dto.ResumeSaveRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +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.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor @RequestMapping("/api/v1/resumes") @@ -39,4 +51,58 @@ public ResponseEntity getCareerMaterials( ) { return ResponseEntity.ok(careerMaterialsFacadeService.getCareerMaterials(type, memberAuth)); } + + @PostMapping(value = "/evaluations", consumes = {"multipart/form-data"}) + public ResponseEntity submitResumeEvaluationAsync( + @RequestPart(value = "resume", required = false) MultipartFile resume, + @RequestPart(value = "portfolio", required = false) MultipartFile portfolio, + @RequestPart(value = "resume_id", required = false) String resumeIdStr, + @RequestPart(value = "portfolio_id", required = false) String portfolioIdStr, + @RequestPart(value = "job_position") String jobPosition, + @RequestPart(value = "job_description", required = false) String jobDescription, + @RequestPart(value = "job_career") String jobCareer, + @Authentication(required = false) MemberAuth memberAuth + ) { + Long resumeId = parseIdOrNull(resumeIdStr); + Long portfolioId = parseIdOrNull(portfolioIdStr); + ResumeEvaluationAsyncRequest request = new ResumeEvaluationAsyncRequest( + resume, portfolio, resumeId, portfolioId, jobPosition, jobDescription, jobCareer); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth)); + } + + private Long parseIdOrNull(String fileId) { + if (fileId == null || fileId.isBlank()) { + return null; + } + try { + return Long.parseLong(fileId.trim()); + } catch (NumberFormatException e) { + throw new BadRequestException("잘못된 파일 id 형식입니다: " + fileId); + } + } + + @GetMapping("/evaluations/{evaluationId}/state") + public ResponseEntity findResumeEvaluationState( + @PathVariable String evaluationId, + @Authentication(required = false) MemberAuth memberAuth + ) { + return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationState(evaluationId, memberAuth)); + } + + @GetMapping("/evaluations") + public ResponseEntity findResumeEvaluationHistory( + @Authentication MemberAuth memberAuth, + @PageableDefault(size = 20) Pageable pageable + ) { + return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationHistory(memberAuth, pageable)); + } + + @GetMapping("/evaluations/{evaluationId}") + public ResponseEntity findResumeEvaluationDetail( + @PathVariable Long evaluationId, + @Authentication MemberAuth memberAuth + ) { + return ResponseEntity.ok(careerMaterialsFacadeService.findResumeEvaluationDetail(evaluationId, memberAuth)); + } } diff --git a/api/src/main/java/com/samhap/kokomen/resume/domain/CareerMaterialsPathResolver.java b/api/src/main/java/com/samhap/kokomen/resume/domain/CareerMaterialsPathResolver.java index a7e6617d..54e45016 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/domain/CareerMaterialsPathResolver.java +++ b/api/src/main/java/com/samhap/kokomen/resume/domain/CareerMaterialsPathResolver.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.resume.domain; import com.samhap.kokomen.global.constant.AwsConstant; +import java.util.UUID; import lombok.Getter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -22,21 +23,21 @@ public CareerMaterialsPathResolver( this.portfolioS3Path = portfolioS3Path; } - public String resolveResumeCdnPath(Long memberId, String title) { - return AwsConstant.CLOUD_FRONT_DOMAIN_URL + resumeS3Path + memberId + FOLDER_DELIMITER + title + public String resolveResumeCdnPath(Long memberId, String s3Key) { + return AwsConstant.CLOUD_FRONT_DOMAIN_URL + resumeS3Path + memberId + FOLDER_DELIMITER + s3Key + PDF_FILE_EXTENSION; } public String resolveResumeS3Key(Long memberId, String title) { - return resumeS3Path + memberId + FOLDER_DELIMITER + title + PDF_FILE_EXTENSION; + return resumeS3Path + memberId + FOLDER_DELIMITER + title + "-" + UUID.randomUUID() + PDF_FILE_EXTENSION; } - public String resolvePortfolioCdnPath(Long memberId, String title) { - return AwsConstant.CLOUD_FRONT_DOMAIN_URL + portfolioS3Path + memberId + FOLDER_DELIMITER + title + public String resolvePortfolioCdnPath(Long memberId, String s3Key) { + return AwsConstant.CLOUD_FRONT_DOMAIN_URL + portfolioS3Path + memberId + FOLDER_DELIMITER + s3Key + PDF_FILE_EXTENSION; } public String resolvePortfolioS3Key(Long memberId, String title) { - return portfolioS3Path + memberId + FOLDER_DELIMITER + title + PDF_FILE_EXTENSION; + return portfolioS3Path + memberId + FOLDER_DELIMITER + title + "-" + UUID.randomUUID() + PDF_FILE_EXTENSION; } } diff --git a/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java b/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java new file mode 100644 index 00000000..cd62392a --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java @@ -0,0 +1,84 @@ +package com.samhap.kokomen.resume.domain; + +import com.samhap.kokomen.global.exception.BadRequestException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.io.RandomAccessReadBufferedFile; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Component +public class PdfTextExtractor { + + private static final long MEMORY_THRESHOLD = 5L * 1024 * 1024; + + public String extractText(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + + try { + if (file.getSize() <= MEMORY_THRESHOLD) { + return extractTextFromMemory(file); + } + return extractTextFromStream(file); + } catch (IOException e) { + log.error("PDF 텍스트 추출 중 오류 발생", e); + throw new BadRequestException("PDF 파일에서 텍스트를 추출하는 데 실패했습니다."); + } + } + + private String extractTextFromMemory(MultipartFile file) throws IOException { + try (PDDocument document = Loader.loadPDF(file.getBytes())) { + return extractText(document); + } + } + + private String extractTextFromStream(MultipartFile file) throws IOException { + Path tempFile = null; + try { + tempFile = Files.createTempFile("pdf-", ".pdf"); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + + try ( + RandomAccessReadBufferedFile readBuffer = new RandomAccessReadBufferedFile(tempFile); + PDDocument document = Loader.loadPDF(readBuffer) + ) { + return extractText(document); + } + } finally { + if (tempFile != null) { + Files.deleteIfExists(tempFile); + } + } + } + + private String extractText(PDDocument document) throws IOException { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setSortByPosition(true); + return stripper.getText(document).trim(); + } + + public String extractText(byte[] pdfData) { + if (pdfData == null || pdfData.length == 0) { + return null; + } + + try (PDDocument document = Loader.loadPDF(pdfData)) { + return extractText(document); + } catch (IOException e) { + log.error("PDF 텍스트 추출 중 오류 발생", e); + throw new BadRequestException("PDF 파일에서 텍스트를 추출하는 데 실패했습니다."); + } + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsFacadeService.java b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsFacadeService.java index 5183fa50..50f52523 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsFacadeService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsFacadeService.java @@ -1,55 +1,294 @@ package com.samhap.kokomen.resume.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.service.RedisService; import com.samhap.kokomen.member.domain.Member; import com.samhap.kokomen.member.service.MemberService; import com.samhap.kokomen.resume.domain.CareerMaterialsType; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.PdfValidator; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; import com.samhap.kokomen.resume.service.dto.CareerMaterialsResponse; +import com.samhap.kokomen.resume.service.dto.NonMemberResumeEvaluationData; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationAsyncRequest; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationDetailResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationHistoryResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationHistoryResponses; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationRequest; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationStateResponse; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationSubmitResponse; +import com.samhap.kokomen.resume.service.dto.ResumeFileData; import com.samhap.kokomen.resume.service.dto.ResumeSaveRequest; +import java.io.IOException; import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor @Service public class CareerMaterialsFacadeService { - private final ResumeService resumeService; - private final PortfolioService portfolioService; + private static final String UUID_PREFIX = "uuid-"; + + private final CareerMaterialsService careerMaterialsService; private final MemberService memberService; private final ResumeEvaluationService resumeEvaluationService; + private final ResumeEvaluationAsyncService resumeEvaluationAsyncService; + private final RedisService redisService; + private final PdfValidator pdfValidator; + private final PdfUploadService pdfUploadService; + private final ObjectMapper objectMapper; + @Transactional(readOnly = true) public CareerMaterialsResponse getCareerMaterials(CareerMaterialsType type, MemberAuth memberAuth) { - return switch (type) { - case ALL: - yield new CareerMaterialsResponse( - resumeService.getResumesByMemberId(memberAuth.memberId()), - portfolioService.getPortfoliosByMemberId(memberAuth.memberId()) - ); - case RESUME: - yield new CareerMaterialsResponse( - resumeService.getResumesByMemberId(memberAuth.memberId()), - List.of() - ); - case PORTFOLIO: - yield new CareerMaterialsResponse( - List.of(), - portfolioService.getPortfoliosByMemberId(memberAuth.memberId()) - ); - }; + return careerMaterialsService.getCareerMaterials(type, memberAuth); } - public void saveCareerMaterials(ResumeSaveRequest request, MemberAuth memberAuth) { + @Transactional + public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync( + ResumeEvaluationAsyncRequest request, + MemberAuth memberAuth + ) { + if (memberAuth.isAuthenticated()) { + return submitMemberEvaluation(request, memberAuth); + } + return submitNonMemberEvaluation(request); + } + + private ResumeEvaluationSubmitResponse submitMemberEvaluation( + ResumeEvaluationAsyncRequest request, + MemberAuth memberAuth + ) { + validatePdfFiles(request); Member member = memberService.readById(memberAuth.memberId()); - resumeService.saveResume(request.resume(), member); - if (request.portfolio() != null) { - portfolioService.savePortfolio(request.portfolio(), member); + + ResumeFileData resumeFileData = getResumeFileData(request); + MemberResume memberResume = getMemberResume(request, memberAuth); + ResumeFileData portfolioFileData = getPortfolioFileData(request); + MemberPortfolio memberPortfolio = getMemberPortfolio(request, memberAuth); + + ResumeEvaluation savedEvaluation = saveEvaluation(member, memberResume, memberPortfolio, request); + startMemberAsyncEvaluation(savedEvaluation, member, resumeFileData, portfolioFileData, + memberResume, memberPortfolio, request); + return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); + } + + private ResumeEvaluationSubmitResponse submitNonMemberEvaluation(ResumeEvaluationAsyncRequest request) { + validatePdfFiles(request); + String uuid = UUID.randomUUID().toString(); + ResumeFileData resumeFileData = createResumeFileData(request.resume()); + ResumeFileData portfolioFileData = createResumeFileData(request.portfolio()); + + startNonMemberAsyncEvaluation(uuid, resumeFileData, portfolioFileData, request); + return ResumeEvaluationSubmitResponse.fromUuid(uuid); + } + + private void validatePdfFiles(ResumeEvaluationAsyncRequest request) { + if (hasFile(request.resume())) { + pdfValidator.validate(request.resume()); + } + if (hasFile(request.portfolio())) { + pdfValidator.validate(request.portfolio()); + } + } + + private ResumeFileData getResumeFileData(ResumeEvaluationAsyncRequest request) { + if (hasFile(request.resume())) { + return createResumeFileData(request.resume()); + } + return null; + } + + private MemberResume getMemberResume(ResumeEvaluationAsyncRequest request, MemberAuth memberAuth) { + if (hasFile(request.resume())) { + return null; + } + if (request.resumeId() == null) { + return null; + } + return careerMaterialsService.getResumeByIdAndMemberId(request.resumeId(), memberAuth.memberId()); + } + + private ResumeFileData getPortfolioFileData(ResumeEvaluationAsyncRequest request) { + if (hasFile(request.portfolio())) { + return createResumeFileData(request.portfolio()); + } + return null; + } + + private MemberPortfolio getMemberPortfolio(ResumeEvaluationAsyncRequest request, MemberAuth memberAuth) { + if (hasFile(request.portfolio())) { + return null; + } + if (request.portfolioId() == null) { + return null; } + return careerMaterialsService.getPortfolioByIdAndMemberId(request.portfolioId(), memberAuth.memberId()); } + private boolean hasFile(MultipartFile file) { + return file != null && !file.isEmpty(); + } + + private ResumeEvaluation saveEvaluation( + Member member, + MemberResume memberResume, + MemberPortfolio memberPortfolio, + ResumeEvaluationAsyncRequest request + ) { + ResumeEvaluation evaluation = new ResumeEvaluation( + member, memberResume, memberPortfolio, + request.jobPosition(), request.jobDescription(), request.jobCareer() + ); + return resumeEvaluationService.saveEvaluation(evaluation); + } + + private void startMemberAsyncEvaluation( + ResumeEvaluation evaluation, + Member member, + ResumeFileData resumeFileData, + ResumeFileData portfolioFileData, + MemberResume memberResume, + MemberPortfolio memberPortfolio, + ResumeEvaluationAsyncRequest request + ) { + resumeEvaluationAsyncService.processAndEvaluateMixedAsync( + evaluation.getId(), member, + resumeFileData, portfolioFileData, + memberResume, memberPortfolio, + request.jobPosition(), request.jobDescription(), request.jobCareer() + ); + } + + private void startNonMemberAsyncEvaluation( + String uuid, + ResumeFileData resumeFileData, + ResumeFileData portfolioFileData, + ResumeEvaluationAsyncRequest request + ) { + resumeEvaluationAsyncService.processAndEvaluateNonMemberAsync( + uuid, resumeFileData, portfolioFileData, + request.jobPosition(), request.jobDescription(), request.jobCareer() + ); + } + + private ResumeFileData createResumeFileData(MultipartFile file) { + try { + return ResumeFileData.from(file); + } catch (IOException e) { + throw new BadRequestException("파일을 읽는 중 오류가 발생했습니다."); + } + } + + @Transactional(readOnly = true) + public ResumeEvaluationStateResponse findResumeEvaluationState(String evaluationId, MemberAuth memberAuth) { + if (isNonMemberEvaluationId(evaluationId)) { + return findNonMemberResumeEvaluationState(evaluationId); + } + return findMemberResumeEvaluationState(evaluationId, memberAuth); + } + + private boolean isNonMemberEvaluationId(String evaluationId) { + return evaluationId != null && evaluationId.startsWith(UUID_PREFIX); + } + + private ResumeEvaluationStateResponse findNonMemberResumeEvaluationState(String evaluationId) { + String uuid = extractUuid(evaluationId); + String redisKey = ResumeEvaluationAsyncService.createRedisKey(uuid); + + return redisService.get(redisKey, String.class) + .map(this::parseNonMemberEvaluationData) + .map(this::convertToStateResponse) + .orElseThrow(() -> new BadRequestException("이력서 평가 결과를 찾을 수 없습니다. 만료되었거나 존재하지 않는 ID입니다.")); + } + + private String extractUuid(String evaluationId) { + return evaluationId.substring(UUID_PREFIX.length()); + } + + private NonMemberResumeEvaluationData parseNonMemberEvaluationData(String jsonData) { + try { + return objectMapper.readValue(jsonData, NonMemberResumeEvaluationData.class); + } catch (JsonProcessingException e) { + throw new BadRequestException("비회원 평가 데이터 파싱에 실패했습니다."); + } + } + + private ResumeEvaluationStateResponse convertToStateResponse(NonMemberResumeEvaluationData data) { + return switch (data.state()) { + case PENDING -> ResumeEvaluationStateResponse.pending(); + case COMPLETED -> ResumeEvaluationStateResponse.completed(data.result()); + case FAILED -> ResumeEvaluationStateResponse.failed(); + }; + } + + private ResumeEvaluationStateResponse findMemberResumeEvaluationState(String evaluationId, MemberAuth memberAuth) { + Long id = parseMemberEvaluationId(evaluationId); + ResumeEvaluation evaluation = resumeEvaluationService.readById(id); + validateEvaluationOwner(evaluation, memberAuth.memberId()); + return ResumeEvaluationStateResponse.from(evaluation); + } + + private Long parseMemberEvaluationId(String evaluationId) { + try { + return Long.parseLong(evaluationId); + } catch (NumberFormatException e) { + throw new BadRequestException("잘못된 평가 ID 형식입니다: " + evaluationId); + } + } + + @Transactional(readOnly = true) + public ResumeEvaluationHistoryResponses findResumeEvaluationHistory(MemberAuth memberAuth, Pageable pageable) { + Page evaluationPage = resumeEvaluationService + .findByMemberId(memberAuth.memberId(), pageable); + + List evaluations = evaluationPage.stream() + .map(ResumeEvaluationHistoryResponse::from) + .toList(); + return ResumeEvaluationHistoryResponses.of( + evaluations, + evaluationPage.getNumber(), + evaluationPage.getSize(), + evaluationPage.getTotalElements() + ); + } + + @Transactional(readOnly = true) + public ResumeEvaluationDetailResponse findResumeEvaluationDetail(Long evaluationId, MemberAuth memberAuth) { + ResumeEvaluation evaluation = resumeEvaluationService.readById(evaluationId); + validateEvaluationOwner(evaluation, memberAuth.memberId()); + return ResumeEvaluationDetailResponse.from(evaluation); + } + + private void validateEvaluationOwner(ResumeEvaluation evaluation, Long memberId) { + if (!evaluation.isOwner(memberId)) { + throw new BadRequestException("본인의 이력서 평가만 조회할 수 있습니다."); + } + } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 + @Transactional public ResumeEvaluationResponse evaluateResume(ResumeEvaluationRequest request) { return resumeEvaluationService.evaluate(request); } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 + @Transactional + public void saveCareerMaterials(ResumeSaveRequest request, MemberAuth memberAuth) { + Member member = memberService.readById(memberAuth.memberId()); + pdfUploadService.saveResume(request.resume(), member); + if (request.portfolio() != null) { + pdfUploadService.savePortfolio(request.portfolio(), member); + } + } } diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java new file mode 100644 index 00000000..36abf1d0 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java @@ -0,0 +1,75 @@ +package com.samhap.kokomen.resume.service; + +import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.resume.domain.CareerMaterialsType; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.repository.MemberPortfolioRepository; +import com.samhap.kokomen.resume.repository.MemberResumeRepository; +import com.samhap.kokomen.resume.service.dto.CareerMaterialsResponse; +import com.samhap.kokomen.resume.service.dto.PortfolioResponse; +import com.samhap.kokomen.resume.service.dto.ResumeResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CareerMaterialsService { + + private final MemberPortfolioRepository memberPortfolioRepository; + private final MemberResumeRepository memberResumeRepository; + + public CareerMaterialsResponse getCareerMaterials(CareerMaterialsType type, MemberAuth memberAuth) { + return switch (type) { + case ALL: + yield new CareerMaterialsResponse( + getResumesByMemberId(memberAuth.memberId()), + getPortfoliosByMemberId(memberAuth.memberId()) + ); + case RESUME: + yield new CareerMaterialsResponse( + getResumesByMemberId(memberAuth.memberId()), + List.of() + ); + case PORTFOLIO: + yield new CareerMaterialsResponse( + List.of(), + getPortfoliosByMemberId(memberAuth.memberId()) + ); + }; + } + + private List getResumesByMemberId(Long memberId) { + return memberResumeRepository.findByMemberId(memberId).stream() + .map(resume -> new ResumeResponse( + resume.getId(), + resume.getTitle(), + resume.getResumeUrl(), + resume.getCreatedAt() + )) + .toList(); + } + + private List getPortfoliosByMemberId(Long memberId) { + return memberPortfolioRepository.findByMemberId(memberId).stream() + .map(portfolio -> new PortfolioResponse( + portfolio.getId(), + portfolio.getTitle(), + portfolio.getPortfolioUrl(), + portfolio.getCreatedAt() + )) + .toList(); + } + + public MemberResume getResumeByIdAndMemberId(Long resumeId, Long memberId) { + return memberResumeRepository.findByIdAndMemberId(resumeId, memberId) + .orElseThrow(() -> new BadRequestException("존재하지 않는 이력서입니다.")); + } + + public MemberPortfolio getPortfolioByIdAndMemberId(Long portfolioId, Long memberId) { + return memberPortfolioRepository.findByIdAndMemberId(portfolioId, memberId) + .orElseThrow(() -> new BadRequestException("존재하지 않는 포트폴리오입니다.")); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java new file mode 100644 index 00000000..5db7c37b --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java @@ -0,0 +1,134 @@ +package com.samhap.kokomen.resume.service; + +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.service.S3Service; +import com.samhap.kokomen.member.domain.Member; +import com.samhap.kokomen.resume.domain.CareerMaterialsPathResolver; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.PdfTextExtractor; +import com.samhap.kokomen.resume.domain.PdfValidator; +import com.samhap.kokomen.resume.repository.MemberPortfolioRepository; +import com.samhap.kokomen.resume.repository.MemberResumeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RequiredArgsConstructor +@Service +public class PdfUploadService { + + private final CareerMaterialsPathResolver careerMaterialsPathResolver; + private final PdfValidator pdfValidator; + private final PdfTextExtractor pdfTextExtractor; + private final MemberPortfolioRepository memberPortfolioRepository; + private final MemberResumeRepository memberResumeRepository; + private final S3Service s3Service; + + @Transactional + public MemberPortfolio savePortfolio(MultipartFile portfolio, Member member, String content) { + pdfValidator.validate(portfolio); + String filename = portfolio.getOriginalFilename(); + String s3Key = careerMaterialsPathResolver.resolvePortfolioS3Key(member.getId(), filename); + String cdnPath = careerMaterialsPathResolver.resolvePortfolioCdnPath(member.getId(), s3Key); + + MemberPortfolio memberPortfolio = new MemberPortfolio(member, filename, cdnPath, content); + MemberPortfolio savedPortfolio = memberPortfolioRepository.save(memberPortfolio); + + uploadToS3IfNotExists(s3Key, portfolio); + return savedPortfolio; + } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 + @Async + @Transactional + public void savePortfolio(MultipartFile portfolio, Member member) { + String content = extractTextSafely(portfolio); + savePortfolio(portfolio, member, content); + } + + @Transactional + public MemberResume saveResume(MultipartFile resume, Member member, String content) { + pdfValidator.validate(resume); + String filename = resume.getOriginalFilename(); + String s3Key = careerMaterialsPathResolver.resolveResumeS3Key(member.getId(), filename); + String cdnPath = careerMaterialsPathResolver.resolveResumeCdnPath(member.getId(), s3Key); + + MemberResume memberResume = new MemberResume(member, filename, cdnPath, content); + MemberResume savedResume = memberResumeRepository.save(memberResume); + + uploadToS3IfNotExists(s3Key, resume); + return savedResume; + } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 + @Async + @Transactional + public void saveResume(MultipartFile resume, Member member) { + String content = extractTextSafely(resume); + saveResume(resume, member, content); + } + + private String extractTextSafely(MultipartFile file) { + try { + return pdfTextExtractor.extractText(file); + } catch (Exception e) { + log.warn("PDF 텍스트 추출 실패 (업로드는 계속 진행): {}", e.getMessage()); + return null; + } + } + + private void uploadToS3IfNotExists(String s3Key, MultipartFile file) { + if (s3Service.exists(s3Key)) { + return; + } + try { + s3Service.uploadS3File(s3Key, file.getBytes(), "application/pdf"); + } catch (Exception e) { + throw new BadRequestException("파일 업로드에 실패했습니다."); + } + } + + @Transactional + public MemberResume saveResume(byte[] resumeData, String filename, Member member, String content) { + validateByteArray(resumeData); + String s3Key = careerMaterialsPathResolver.resolveResumeS3Key(member.getId(), filename); + String cdnPath = careerMaterialsPathResolver.resolveResumeCdnPath(member.getId(), s3Key); + + MemberResume memberResume = new MemberResume(member, filename, cdnPath, content); + MemberResume savedResume = memberResumeRepository.save(memberResume); + + uploadToS3IfNotExists(s3Key, resumeData); + return savedResume; + } + + @Transactional + public MemberPortfolio savePortfolio(byte[] portfolioData, String filename, Member member, String content) { + validateByteArray(portfolioData); + String s3Key = careerMaterialsPathResolver.resolvePortfolioS3Key(member.getId(), filename); + String cdnPath = careerMaterialsPathResolver.resolvePortfolioCdnPath(member.getId(), s3Key); + + MemberPortfolio memberPortfolio = new MemberPortfolio(member, filename, cdnPath, content); + MemberPortfolio savedPortfolio = memberPortfolioRepository.save(memberPortfolio); + + uploadToS3IfNotExists(s3Key, portfolioData); + return savedPortfolio; + } + + private void validateByteArray(byte[] data) { + if (data == null || data.length == 0) { + throw new BadRequestException("파일이 비어있습니다."); + } + } + + private void uploadToS3IfNotExists(String s3Key, byte[] data) { + if (s3Service.exists(s3Key)) { + return; + } + s3Service.uploadS3File(s3Key, data, "application/pdf"); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/PortfolioService.java b/api/src/main/java/com/samhap/kokomen/resume/service/PortfolioService.java deleted file mode 100644 index 6d6e6cd9..00000000 --- a/api/src/main/java/com/samhap/kokomen/resume/service/PortfolioService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.samhap.kokomen.resume.service; - -import com.samhap.kokomen.global.exception.BadRequestException; -import com.samhap.kokomen.global.service.S3Service; -import com.samhap.kokomen.member.domain.Member; -import com.samhap.kokomen.resume.domain.CareerMaterialsPathResolver; -import com.samhap.kokomen.resume.domain.MemberPortfolio; -import com.samhap.kokomen.resume.domain.PdfValidator; -import com.samhap.kokomen.resume.repository.MemberPortfolioRepository; -import com.samhap.kokomen.resume.service.dto.PortfolioResponse; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@RequiredArgsConstructor -@Service -public class PortfolioService { - - private final CareerMaterialsPathResolver careerMaterialsPathResolver; - private final PdfValidator pdfValidator; - private final MemberPortfolioRepository memberPortfolioRepository; - private final S3Service s3Service; - - public List getPortfoliosByMemberId(Long memberId) { - return memberPortfolioRepository.findByMemberId(memberId).stream() - .map(portfolio -> new PortfolioResponse( - portfolio.getId(), - portfolio.getTitle(), - portfolio.getPortfolioUrl(), - portfolio.getCreatedAt() - )) - .toList(); - } - - @Async - @Transactional - public void savePortfolio(MultipartFile portfolio, Member member) { - pdfValidator.validate(portfolio); - String filename = portfolio.getOriginalFilename(); - String s3Key = careerMaterialsPathResolver.resolvePortfolioS3Key(member.getId(), filename); - String cdnPath = careerMaterialsPathResolver.resolvePortfolioCdnPath(member.getId(), filename); - - MemberPortfolio memberPortfolio = new MemberPortfolio(member, filename, cdnPath); - memberPortfolioRepository.save(memberPortfolio); - - if (s3Service.exists(s3Key)) { - return; - } - try { - s3Service.uploadS3File(s3Key, portfolio.getBytes(), "application/pdf"); - } catch (Exception e) { - throw new BadRequestException("포트폴리오 파일 업로드에 실패했습니다."); - } - } -} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java new file mode 100644 index 00000000..dcd4b2f3 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -0,0 +1,389 @@ +package com.samhap.kokomen.resume.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.service.RedisService; +import com.samhap.kokomen.global.service.S3Service; +import com.samhap.kokomen.member.domain.Member; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.PdfTextExtractor; +import com.samhap.kokomen.resume.external.ResumeGptClient; +import com.samhap.kokomen.resume.external.ResumeInvokeFlowRequestFactory; +import com.samhap.kokomen.resume.service.dto.NonMemberResumeEvaluationData; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationRequest; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; +import com.samhap.kokomen.resume.service.dto.ResumeFileData; +import com.samhap.kokomen.resume.service.dto.TextExtractionResult; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient; +import software.amazon.awssdk.services.bedrockagentruntime.model.FlowOutputEvent; +import software.amazon.awssdk.services.bedrockagentruntime.model.FlowResponseStream; +import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeFlowRequest; +import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeFlowResponseHandler; + +@Slf4j +@Service +public class ResumeEvaluationAsyncService { + + private static final String REDIS_KEY_PREFIX = "resume:evaluation:nonmember:"; + private static final Duration REDIS_TTL = Duration.ofMinutes(5); + + private final ResumeEvaluationService resumeEvaluationService; + private final PdfUploadService pdfUploadService; + private final RedisService redisService; + private final S3Service s3Service; + private final BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient; + private final ResumeGptClient resumeGptClient; + private final PdfTextExtractor pdfTextExtractor; + private final ObjectMapper objectMapper; + private final ThreadPoolTaskExecutor executor; + + public ResumeEvaluationAsyncService( + ResumeEvaluationService resumeEvaluationService, + PdfUploadService pdfUploadService, + RedisService redisService, + S3Service s3Service, + BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient, + ResumeGptClient resumeGptClient, + PdfTextExtractor pdfTextExtractor, + ObjectMapper objectMapper, + @Qualifier("resumeEvaluationExecutor") + ThreadPoolTaskExecutor executor + ) { + this.resumeEvaluationService = resumeEvaluationService; + this.pdfUploadService = pdfUploadService; + this.redisService = redisService; + this.s3Service = s3Service; + this.bedrockAgentRuntimeAsyncClient = bedrockAgentRuntimeAsyncClient; + this.resumeGptClient = resumeGptClient; + this.pdfTextExtractor = pdfTextExtractor; + this.objectMapper = objectMapper; + this.executor = executor; + } + + public void processAndEvaluateMixedAsync(Long evaluationId, Member member, + ResumeFileData resumeFileData, ResumeFileData portfolioFileData, + MemberResume existingResume, MemberPortfolio existingPortfolio, + String jobPosition, String jobDescription, String jobCareer) { + Map mdcContext = MDC.getCopyOfContextMap(); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + + String resumeText; + String portfolioText; + MemberResume finalResume = existingResume; + MemberPortfolio finalPortfolio = existingPortfolio; + + if (resumeFileData != null && !resumeFileData.isEmpty()) { + resumeText = extractTextSafely(resumeFileData); + if (resumeText == null || resumeText.isBlank()) { + log.error("이력서 텍스트 추출 실패 - evaluationId: {}", evaluationId); + resumeEvaluationService.updateFailed(evaluationId); + return; + } + finalResume = pdfUploadService.saveResume( + resumeFileData.content(), resumeFileData.filename(), member, resumeText); + } else if (existingResume != null) { + resumeText = getOrExtractText(existingResume.getContent(), existingResume.getResumeUrl()); + } else { + log.error("이력서 없음 - evaluationId: {}", evaluationId); + resumeEvaluationService.updateFailed(evaluationId); + return; + } + + if (resumeText == null || resumeText.isBlank()) { + log.error("이력서 텍스트 없음 - evaluationId: {}", evaluationId); + resumeEvaluationService.updateFailed(evaluationId); + return; + } + + if (portfolioFileData != null && !portfolioFileData.isEmpty()) { + portfolioText = extractTextSafely(portfolioFileData); + if (portfolioText != null && !portfolioText.isBlank()) { + finalPortfolio = pdfUploadService.savePortfolio( + portfolioFileData.content(), portfolioFileData.filename(), member, portfolioText); + } + } else if (existingPortfolio != null) { + portfolioText = getOrExtractText(existingPortfolio.getContent(), + existingPortfolio.getPortfolioUrl()); + } else { + portfolioText = null; + } + + resumeEvaluationService.updateMemberResume(evaluationId, finalResume, finalPortfolio); + + ResumeEvaluationRequest evalRequest = new ResumeEvaluationRequest( + resumeText, portfolioText, jobPosition, jobDescription, jobCareer + ); + evaluateMemberAsync(evaluationId, evalRequest); + } catch (Exception e) { + log.error("혼합 이력서 평가 처리 실패 - evaluationId: {}", evaluationId, e); + resumeEvaluationService.updateFailed(evaluationId); + } finally { + MDC.clear(); + } + }); + } + + private String getOrExtractText(String content, String url) { + if (content != null && !content.isBlank()) { + return content; + } + try { + byte[] pdfBytes = s3Service.downloadFileFromUrl(url); + return pdfTextExtractor.extractText(pdfBytes); + } catch (Exception e) { + log.error("S3에서 PDF 다운로드/추출 실패 - url: {}", url, e); + return null; + } + } + + public void processAndEvaluateNonMemberAsync(String uuid, + ResumeFileData resumeFileData, + ResumeFileData portfolioFileData, + String jobPosition, + String jobDescription, + String jobCareer) { + String redisKey = createRedisKey(uuid); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.pending(null)); + + Map mdcContext = MDC.getCopyOfContextMap(); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + TextExtractionResult extraction = extractTexts(resumeFileData, portfolioFileData); + + if (!extraction.hasResumeText()) { + log.error("이력서 텍스트 추출 실패 - uuid: {}", uuid); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.failed(null)); + return; + } + + ResumeEvaluationRequest evaluationRequest = new ResumeEvaluationRequest( + extraction.resumeText(), extraction.portfolioText(), + jobPosition, jobDescription, jobCareer + ); + evaluateNonMemberAsync(uuid, evaluationRequest); + } catch (Exception e) { + log.error("비회원 이력서 평가 처리 실패 - uuid: {}", uuid, e); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.failed(null)); + } finally { + MDC.clear(); + } + }); + } + + private TextExtractionResult extractTexts(ResumeFileData resumeFileData, ResumeFileData portfolioFileData) { + CompletableFuture resumeFuture = CompletableFuture.supplyAsync( + () -> extractTextSafely(resumeFileData), executor); + + CompletableFuture portfolioFuture = CompletableFuture.supplyAsync(() -> { + if (portfolioFileData == null || portfolioFileData.isEmpty()) { + return null; + } + return extractTextSafely(portfolioFileData); + }, executor); + + return resumeFuture.thenCombine(portfolioFuture, TextExtractionResult::of).join(); + } + + private String extractTextSafely(ResumeFileData fileData) { + try { + return pdfTextExtractor.extractText(fileData.content()); + } catch (Exception e) { + log.error("PDF 텍스트 추출 실패", e); + return null; + } + } + + private void evaluateMemberAsync(Long evaluationId, ResumeEvaluationRequest request) { + Map mdcContext = MDC.getCopyOfContextMap(); + InvokeFlowRequest flowRequest = ResumeInvokeFlowRequestFactory.createResumeEvaluationFlowRequest(request); + + bedrockAgentRuntimeAsyncClient.invokeFlow( + flowRequest, + createMemberEvaluationResponseHandler(evaluationId, request, mdcContext) + ); + } + + private InvokeFlowResponseHandler createMemberEvaluationResponseHandler( + Long evaluationId, ResumeEvaluationRequest request, Map mdcContext) { + return InvokeFlowResponseHandler.builder() + .onEventStream(publisher -> publisher.subscribe(event -> + executor.execute(() -> + handleMemberBedrockResponse(event, evaluationId, request, mdcContext)))) + .onError(ex -> + executor.execute(() -> + handleMemberBedrockError(ex, evaluationId, request, mdcContext))) + .build(); + } + + private void handleMemberBedrockResponse(FlowResponseStream event, Long evaluationId, + ResumeEvaluationRequest request, Map mdcContext) { + try { + setMdcContext(mdcContext); + if (event instanceof FlowOutputEvent outputEvent) { + String jsonPayload = outputEvent.content().document().toString(); + ResumeEvaluationResponse response = parseResponse(jsonPayload); + resumeEvaluationService.updateCompleted(evaluationId, response); + } + } catch (Exception e) { + log.error("Bedrock 응답 처리 실패, GPT 폴백 시도 - evaluationId: {}", evaluationId, e); + fallbackToGptForMember(evaluationId, request, mdcContext); + } finally { + MDC.clear(); + } + } + + private void handleMemberBedrockError(Throwable ex, Long evaluationId, + ResumeEvaluationRequest request, Map mdcContext) { + try { + setMdcContext(mdcContext); + log.error("Bedrock 호출 실패, GPT 폴백 시도 - evaluationId: {}", evaluationId, ex); + fallbackToGptForMember(evaluationId, request, mdcContext); + } finally { + MDC.clear(); + } + } + + private void fallbackToGptForMember(Long evaluationId, ResumeEvaluationRequest request, + Map mdcContext) { + executor.execute(() -> { + try { + setMdcContext(mdcContext); + String jsonResponse = resumeGptClient.requestResumeEvaluation(request); + ResumeEvaluationResponse response = parseResponse(jsonResponse); + resumeEvaluationService.updateCompleted(evaluationId, response); + } catch (Exception e) { + log.error("GPT 폴백 실패 - evaluationId: {}", evaluationId, e); + resumeEvaluationService.updateFailed(evaluationId); + } finally { + MDC.clear(); + } + }); + } + + private void evaluateNonMemberAsync(String uuid, ResumeEvaluationRequest request) { + Map mdcContext = MDC.getCopyOfContextMap(); + String redisKey = createRedisKey(uuid); + InvokeFlowRequest flowRequest = ResumeInvokeFlowRequestFactory.createResumeEvaluationFlowRequest(request); + + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.pending(request)); + + bedrockAgentRuntimeAsyncClient.invokeFlow( + flowRequest, + createNonMemberEvaluationResponseHandler(uuid, request, mdcContext) + ); + } + + private InvokeFlowResponseHandler createNonMemberEvaluationResponseHandler( + String uuid, ResumeEvaluationRequest request, Map mdcContext) { + return InvokeFlowResponseHandler.builder() + .onEventStream(publisher -> publisher.subscribe(event -> + executor.execute(() -> + handleNonMemberBedrockResponse(event, uuid, request, mdcContext)))) + .onError(ex -> + executor.execute(() -> + handleNonMemberBedrockError(ex, uuid, request, mdcContext))) + .build(); + } + + private void handleNonMemberBedrockResponse(FlowResponseStream event, String uuid, + ResumeEvaluationRequest request, Map mdcContext) { + String redisKey = createRedisKey(uuid); + try { + setMdcContext(mdcContext); + if (event instanceof FlowOutputEvent outputEvent) { + String jsonPayload = outputEvent.content().document().toString(); + ResumeEvaluationResponse response = parseResponse(jsonPayload); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.completed(request, response)); + } + } catch (Exception e) { + log.error("Bedrock 응답 처리 실패, GPT 폴백 시도 - uuid: {}", uuid, e); + fallbackToGptForNonMember(uuid, request, mdcContext); + } finally { + MDC.clear(); + } + } + + private void handleNonMemberBedrockError(Throwable ex, String uuid, + ResumeEvaluationRequest request, Map mdcContext) { + try { + setMdcContext(mdcContext); + log.error("Bedrock 호출 실패, GPT 폴백 시도 - uuid: {}", uuid, ex); + fallbackToGptForNonMember(uuid, request, mdcContext); + } finally { + MDC.clear(); + } + } + + private void fallbackToGptForNonMember(String uuid, ResumeEvaluationRequest request, + Map mdcContext) { + String redisKey = createRedisKey(uuid); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + String jsonResponse = resumeGptClient.requestResumeEvaluation(request); + ResumeEvaluationResponse response = parseResponse(jsonResponse); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.completed(request, response)); + } catch (Exception e) { + log.error("GPT 폴백 실패 - uuid: {}", uuid, e); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.failed(request)); + } finally { + MDC.clear(); + } + }); + } + + private void saveNonMemberDataToRedis(String redisKey, NonMemberResumeEvaluationData data) { + try { + String jsonData = objectMapper.writeValueAsString(data); + redisService.setValue(redisKey, jsonData, REDIS_TTL); + } catch (JsonProcessingException e) { + log.error("비회원 평가 데이터 직렬화 실패 - redisKey: {}", redisKey, e); + throw new BadRequestException("비회원 평가 데이터 저장에 실패했습니다."); + } + } + + private ResumeEvaluationResponse parseResponse(String jsonResponse) { + try { + String cleanedJson = unwrapJsonString(jsonResponse); + return objectMapper.readValue(cleanedJson, ResumeEvaluationResponse.class); + } catch (JsonProcessingException e) { + log.error("이력서 평가 응답 파싱 실패: {}", jsonResponse, e); + throw new BadRequestException("이력서 평가 응답을 파싱하는데 실패했습니다."); + } + } + + private String unwrapJsonString(String json) { + if (json == null || json.isEmpty()) { + return json; + } + String trimmed = json.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + String unwrapped = trimmed.substring(1, trimmed.length() - 1); + return unwrapped.replace("\\\"", "\""); + } + return json; + } + + private void setMdcContext(Map mdcContext) { + if (mdcContext != null) { + MDC.setContextMap(mdcContext); + } + } + + public static String createRedisKey(String uuid) { + return REDIS_KEY_PREFIX + uuid; + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationService.java index 4e796093..ba8b0f7f 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationService.java @@ -2,15 +2,24 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.global.exception.ExternalApiException; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; import com.samhap.kokomen.resume.external.BedrockFlowClient; import com.samhap.kokomen.resume.external.ResumeGptClient; import com.samhap.kokomen.resume.external.ResumeInvokeFlowRequestFactory; +import com.samhap.kokomen.resume.repository.ResumeEvaluationRepository; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationRequest; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeFlowRequest; @Slf4j @@ -18,10 +27,67 @@ @Service public class ResumeEvaluationService { + private final ResumeEvaluationRepository resumeEvaluationRepository; private final BedrockFlowClient bedrockFlowClient; private final ResumeGptClient resumeGptClient; private final ObjectMapper objectMapper; + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ResumeEvaluation saveEvaluation(ResumeEvaluation evaluation) { + return resumeEvaluationRepository.save(evaluation); + } + + @Transactional(readOnly = true) + public ResumeEvaluation readById(Long id) { + return resumeEvaluationRepository.findById(id) + .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + id)); + } + + @Transactional(readOnly = true) + public Page findByMemberId(Long memberId, Pageable pageable) { + return resumeEvaluationRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); + } + + @Transactional + public void updateCompleted(Long evaluationId, ResumeEvaluationResponse response) { + ResumeEvaluation evaluation = resumeEvaluationRepository.findById(evaluationId) + .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + evaluationId)); + evaluation.complete( + response.technicalSkills().score(), + response.technicalSkills().reason(), + response.technicalSkills().improvements(), + response.projectExperience().score(), + response.projectExperience().reason(), + response.projectExperience().improvements(), + response.problemSolving().score(), + response.problemSolving().reason(), + response.problemSolving().improvements(), + response.careerGrowth().score(), + response.careerGrowth().reason(), + response.careerGrowth().improvements(), + response.documentation().score(), + response.documentation().reason(), + response.documentation().improvements(), + response.totalScore(), + response.totalFeedback() + ); + } + + @Transactional + public void updateMemberResume(Long evaluationId, MemberResume memberResume, MemberPortfolio memberPortfolio) { + ResumeEvaluation evaluation = resumeEvaluationRepository.findById(evaluationId) + .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + evaluationId)); + evaluation.updateMemberResume(memberResume, memberPortfolio); + } + + @Transactional + public void updateFailed(Long evaluationId) { + ResumeEvaluation evaluation = resumeEvaluationRepository.findById(evaluationId) + .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + evaluationId)); + evaluation.fail(); + } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 public ResumeEvaluationResponse evaluate(ResumeEvaluationRequest request) { try { return evaluateByBedrockFlow(request); diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeService.java deleted file mode 100644 index 20e07b52..00000000 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.samhap.kokomen.resume.service; - -import com.samhap.kokomen.global.exception.BadRequestException; -import com.samhap.kokomen.global.service.S3Service; -import com.samhap.kokomen.member.domain.Member; -import com.samhap.kokomen.resume.domain.CareerMaterialsPathResolver; -import com.samhap.kokomen.resume.domain.MemberResume; -import com.samhap.kokomen.resume.domain.PdfValidator; -import com.samhap.kokomen.resume.repository.MemberResumeRepository; -import com.samhap.kokomen.resume.service.dto.ResumeResponse; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@RequiredArgsConstructor -@Service -public class ResumeService { - - private final CareerMaterialsPathResolver careerMaterialsPathResolver; - private final PdfValidator pdfValidator; - private final MemberResumeRepository memberResumeRepository; - private final S3Service s3Service; - - public List getResumesByMemberId(Long memberId) { - return memberResumeRepository.findByMemberId(memberId).stream() - .map(resume -> new ResumeResponse( - resume.getId(), - resume.getTitle(), - resume.getResumeUrl(), - resume.getCreatedAt() - )) - .toList(); - } - - @Async - @Transactional - public void saveResume(MultipartFile resume, Member member) { - pdfValidator.validate(resume); - String filename = resume.getOriginalFilename(); - String s3Key = careerMaterialsPathResolver.resolveResumeS3Key(member.getId(), filename); - String cdnPath = careerMaterialsPathResolver.resolveResumeCdnPath(member.getId(), filename); - - MemberResume memberResume = new MemberResume(member, filename, cdnPath); - memberResumeRepository.save(memberResume); - - if (s3Service.exists(s3Key)) { - return; - } - try { - s3Service.uploadS3File(s3Key, resume.getBytes(), "application/pdf"); - } catch (Exception e) { - throw new BadRequestException("이력서 파일 업로드에 실패했습니다."); - } - } -} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/NonMemberResumeEvaluationData.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/NonMemberResumeEvaluationData.java new file mode 100644 index 00000000..00132241 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/NonMemberResumeEvaluationData.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.resume.service.dto; + +import com.samhap.kokomen.resume.domain.ResumeEvaluationState; + +public record NonMemberResumeEvaluationData( + ResumeEvaluationState state, + ResumeEvaluationRequest request, + ResumeEvaluationResponse result +) { + public static NonMemberResumeEvaluationData pending(ResumeEvaluationRequest request) { + return new NonMemberResumeEvaluationData(ResumeEvaluationState.PENDING, request, null); + } + + public static NonMemberResumeEvaluationData completed(ResumeEvaluationRequest request, + ResumeEvaluationResponse result) { + return new NonMemberResumeEvaluationData(ResumeEvaluationState.COMPLETED, request, result); + } + + public static NonMemberResumeEvaluationData failed(ResumeEvaluationRequest request) { + return new NonMemberResumeEvaluationData(ResumeEvaluationState.FAILED, request, null); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/PortfolioInfo.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/PortfolioInfo.java new file mode 100644 index 00000000..f635530f --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/PortfolioInfo.java @@ -0,0 +1,4 @@ +package com.samhap.kokomen.resume.service.dto; + +public record PortfolioInfo(Long id, String title) { +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java new file mode 100644 index 00000000..2531345a --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java @@ -0,0 +1,27 @@ +package com.samhap.kokomen.resume.service.dto; + +import com.samhap.kokomen.global.exception.BadRequestException; +import org.springframework.web.multipart.MultipartFile; + +public record ResumeEvaluationAsyncRequest( + MultipartFile resume, + MultipartFile portfolio, + Long resumeId, + Long portfolioId, + String jobPosition, + String jobDescription, + String jobCareer +) { + + public ResumeEvaluationAsyncRequest { + if (resume == null && resumeId == null) { + throw new BadRequestException("이력서는 필수 입니다."); + } + if (jobPosition == null || jobPosition.isBlank()) { + throw new BadRequestException("지원 직무는 필수 입니다."); + } + if (jobCareer == null || jobCareer.isBlank()) { + throw new BadRequestException("경력 사항은 필수 입니다."); + } + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java new file mode 100644 index 00000000..a506ccd8 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java @@ -0,0 +1,54 @@ +package com.samhap.kokomen.resume.service.dto; + +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; +import com.samhap.kokomen.resume.domain.ResumeEvaluationState; +import java.time.LocalDateTime; + +public record ResumeEvaluationDetailResponse( + Long id, + ResumeEvaluationState state, + ResumeInfo resume, + PortfolioInfo portfolio, + String jobPosition, + String jobDescription, + String jobCareer, + ResumeEvaluationResponse result, + LocalDateTime createdAt +) { + public static ResumeEvaluationDetailResponse from(ResumeEvaluation evaluation) { + return new ResumeEvaluationDetailResponse( + evaluation.getId(), + evaluation.getState(), + createResumeInfo(evaluation.getMemberResume()), + createPortfolioInfo(evaluation.getMemberPortfolio()), + evaluation.getJobPosition(), + evaluation.getJobDescription(), + evaluation.getJobCareer(), + createResult(evaluation), + evaluation.getCreatedAt() + ); + } + + private static ResumeEvaluationResponse createResult(ResumeEvaluation evaluation) { + if (!evaluation.isCompleted()) { + return null; + } + return ResumeEvaluationResponse.from(evaluation); + } + + private static ResumeInfo createResumeInfo(MemberResume memberResume) { + if (memberResume == null) { + return null; + } + return new ResumeInfo(memberResume.getId(), memberResume.getTitle()); + } + + private static PortfolioInfo createPortfolioInfo(MemberPortfolio memberPortfolio) { + if (memberPortfolio == null) { + return null; + } + return new PortfolioInfo(memberPortfolio.getId(), memberPortfolio.getTitle()); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponse.java new file mode 100644 index 00000000..5c7896e6 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponse.java @@ -0,0 +1,25 @@ +package com.samhap.kokomen.resume.service.dto; + +import com.samhap.kokomen.resume.domain.ResumeEvaluation; +import com.samhap.kokomen.resume.domain.ResumeEvaluationState; +import java.time.LocalDateTime; + +public record ResumeEvaluationHistoryResponse( + Long id, + ResumeEvaluationState state, + String jobPosition, + String jobCareer, + Integer totalScore, + LocalDateTime createdAt +) { + public static ResumeEvaluationHistoryResponse from(ResumeEvaluation evaluation) { + return new ResumeEvaluationHistoryResponse( + evaluation.getId(), + evaluation.getState(), + evaluation.getJobPosition(), + evaluation.getJobCareer(), + evaluation.getTotalScore(), + evaluation.getCreatedAt() + ); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponses.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponses.java new file mode 100644 index 00000000..7f2112ca --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponses.java @@ -0,0 +1,29 @@ +package com.samhap.kokomen.resume.service.dto; + +import java.util.List; + +public record ResumeEvaluationHistoryResponses( + List evaluations, + int currentPage, + long totalResumeEvaluationCount, + int totalPages, + boolean hasNext +) { + public static ResumeEvaluationHistoryResponses of( + List evaluations, + int currentPage, + int pageSize, + long totalResumeEvaluationCount + ) { + int totalPages = (int) Math.ceil((double) totalResumeEvaluationCount / pageSize); + boolean hasNext = (currentPage + 1) < totalPages; + + return new ResumeEvaluationHistoryResponses( + evaluations, + currentPage, + totalResumeEvaluationCount, + totalPages, + hasNext + ); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationResponse.java index a2e51f6e..acaa8729 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationResponse.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationResponse.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.resume.service.dto; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; import com.samhap.kokomen.resume.service.dto.evaluation.CareerGrowthResponse; import com.samhap.kokomen.resume.service.dto.evaluation.DocumentationResponse; import com.samhap.kokomen.resume.service.dto.evaluation.ProblemSolvingResponse; @@ -15,4 +16,39 @@ public record ResumeEvaluationResponse( int totalScore, String totalFeedback ) { + public static ResumeEvaluationResponse from(ResumeEvaluation evaluation) { + return new ResumeEvaluationResponse( + new TechnicalSkillsResponse( + nullToZero(evaluation.getTechnicalSkillsScore()), + evaluation.getTechnicalSkillsReason(), + evaluation.getTechnicalSkillsImprovements() + ), + new ProjectExperienceResponse( + nullToZero(evaluation.getProjectExperienceScore()), + evaluation.getProjectExperienceReason(), + evaluation.getProjectExperienceImprovements() + ), + new ProblemSolvingResponse( + nullToZero(evaluation.getProblemSolvingScore()), + evaluation.getProblemSolvingReason(), + evaluation.getProblemSolvingImprovements() + ), + new CareerGrowthResponse( + nullToZero(evaluation.getCareerGrowthScore()), + evaluation.getCareerGrowthReason(), + evaluation.getCareerGrowthImprovements() + ), + new DocumentationResponse( + nullToZero(evaluation.getDocumentationScore()), + evaluation.getDocumentationReason(), + evaluation.getDocumentationImprovements() + ), + nullToZero(evaluation.getTotalScore()), + evaluation.getTotalFeedback() + ); + } + + private static int nullToZero(Integer value) { + return value != null ? value : 0; + } } diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationStateResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationStateResponse.java new file mode 100644 index 00000000..952f34ed --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationStateResponse.java @@ -0,0 +1,31 @@ +package com.samhap.kokomen.resume.service.dto; + +import com.samhap.kokomen.resume.domain.ResumeEvaluation; +import com.samhap.kokomen.resume.domain.ResumeEvaluationState; + +public record ResumeEvaluationStateResponse( + ResumeEvaluationState state, + ResumeEvaluationResponse result +) { + public static ResumeEvaluationStateResponse pending() { + return new ResumeEvaluationStateResponse(ResumeEvaluationState.PENDING, null); + } + + public static ResumeEvaluationStateResponse failed() { + return new ResumeEvaluationStateResponse(ResumeEvaluationState.FAILED, null); + } + + public static ResumeEvaluationStateResponse completed(ResumeEvaluationResponse result) { + return new ResumeEvaluationStateResponse(ResumeEvaluationState.COMPLETED, result); + } + + public static ResumeEvaluationStateResponse from(ResumeEvaluation evaluation) { + if (evaluation.isPending()) { + return pending(); + } + if (evaluation.isCompleted()) { + return completed(ResumeEvaluationResponse.from(evaluation)); + } + return failed(); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationSubmitResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationSubmitResponse.java new file mode 100644 index 00000000..c5a67b04 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationSubmitResponse.java @@ -0,0 +1,13 @@ +package com.samhap.kokomen.resume.service.dto; + +public record ResumeEvaluationSubmitResponse( + String evaluationId +) { + public static ResumeEvaluationSubmitResponse from(Long id) { + return new ResumeEvaluationSubmitResponse(String.valueOf(id)); + } + + public static ResumeEvaluationSubmitResponse fromUuid(String uuid) { + return new ResumeEvaluationSubmitResponse("uuid-" + uuid); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeFileData.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeFileData.java new file mode 100644 index 00000000..7e707bfa --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeFileData.java @@ -0,0 +1,21 @@ +package com.samhap.kokomen.resume.service.dto; + +import java.io.IOException; +import org.springframework.web.multipart.MultipartFile; + +public record ResumeFileData( + byte[] content, + String filename +) { + + public static ResumeFileData from(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + return null; + } + return new ResumeFileData(file.getBytes(), file.getOriginalFilename()); + } + + public boolean isEmpty() { + return content == null || content.length == 0; + } +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeInfo.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeInfo.java new file mode 100644 index 00000000..5e201838 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeInfo.java @@ -0,0 +1,4 @@ +package com.samhap.kokomen.resume.service.dto; + +public record ResumeInfo(Long id, String title) { +} diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/dto/TextExtractionResult.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/TextExtractionResult.java new file mode 100644 index 00000000..91242252 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/TextExtractionResult.java @@ -0,0 +1,15 @@ +package com.samhap.kokomen.resume.service.dto; + +public record TextExtractionResult( + String resumeText, + String portfolioText +) { + + public static TextExtractionResult of(String resumeText, String portfolioText) { + return new TextExtractionResult(resumeText, portfolioText); + } + + public boolean hasResumeText() { + return resumeText != null && !resumeText.isBlank(); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberPortfolioFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberPortfolioFixtureBuilder.java index 54a9587e..1818ff13 100644 --- a/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberPortfolioFixtureBuilder.java +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberPortfolioFixtureBuilder.java @@ -9,6 +9,7 @@ public class MemberPortfolioFixtureBuilder { private Member member; private String title; private String portfolioUrl; + private String content; public static MemberPortfolioFixtureBuilder builder() { return new MemberPortfolioFixtureBuilder(); @@ -29,17 +30,23 @@ public MemberPortfolioFixtureBuilder title(String title) { return this; } - public MemberPortfolioFixtureBuilder resumeUrl(String portfolioUrl) { + public MemberPortfolioFixtureBuilder portfolioUrl(String portfolioUrl) { this.portfolioUrl = portfolioUrl; return this; } + public MemberPortfolioFixtureBuilder content(String content) { + this.content = content; + return this; + } + public MemberPortfolio build() { return new MemberPortfolio( id, member, title != null ? title : "기본 포트폴리오 제목", - portfolioUrl != null ? portfolioUrl : "https://example.com/resume.pdf" + portfolioUrl != null ? portfolioUrl : "https://example.com/portfolio.pdf", + content ); } } diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberResumeFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberResumeFixtureBuilder.java index 3ea860ff..99227f37 100644 --- a/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberResumeFixtureBuilder.java +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/MemberResumeFixtureBuilder.java @@ -9,6 +9,7 @@ public class MemberResumeFixtureBuilder { private Member member; private String title; private String resumeUrl; + private String content; public static MemberResumeFixtureBuilder builder() { return new MemberResumeFixtureBuilder(); @@ -34,12 +35,18 @@ public MemberResumeFixtureBuilder resumeUrl(String resumeUrl) { return this; } + public MemberResumeFixtureBuilder content(String content) { + this.content = content; + return this; + } + public MemberResume build() { return new MemberResume( id, member, title != null ? title : "기본 이력서 제목", - resumeUrl != null ? resumeUrl : "https://example.com/resume.pdf" + resumeUrl != null ? resumeUrl : "https://example.com/resume.pdf", + content ); } } diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/resume/ResumeEvaluationFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/ResumeEvaluationFixtureBuilder.java new file mode 100644 index 00000000..f22df40b --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/ResumeEvaluationFixtureBuilder.java @@ -0,0 +1,113 @@ +package com.samhap.kokomen.global.fixture.resume; + +import com.samhap.kokomen.member.domain.Member; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; + +public class ResumeEvaluationFixtureBuilder { + + private Member member; + private MemberResume resume; + private MemberPortfolio portfolio; + private String jobPosition; + private String jobDescription; + private String jobCareer; + private boolean completed; + private boolean failed; + + private int technicalSkillsScore = 80; + private String technicalSkillsReason = "기술 역량이 우수합니다."; + private String technicalSkillsImprovements = "최신 기술 트렌드를 더 익히면 좋겠습니다."; + private int projectExperienceScore = 85; + private String projectExperienceReason = "프로젝트 경험이 풍부합니다."; + private String projectExperienceImprovements = "팀 프로젝트 경험을 더 늘리면 좋겠습니다."; + private int problemSolvingScore = 75; + private String problemSolvingReason = "문제 해결 능력이 좋습니다."; + private String problemSolvingImprovements = "알고리즘 학습을 권장합니다."; + private int careerGrowthScore = 80; + private String careerGrowthReason = "성장 가능성이 높습니다."; + private String careerGrowthImprovements = "목표 설정을 더 구체화하면 좋겠습니다."; + private int documentationScore = 85; + private String documentationReason = "이력서 작성이 잘 되어 있습니다."; + private String documentationImprovements = "프로젝트 설명을 더 상세히 작성하면 좋겠습니다."; + private int totalScore = 81; + private String totalFeedback = "전반적으로 우수한 지원자입니다."; + + public static ResumeEvaluationFixtureBuilder builder() { + return new ResumeEvaluationFixtureBuilder(); + } + + public ResumeEvaluationFixtureBuilder member(Member member) { + this.member = member; + return this; + } + + public ResumeEvaluationFixtureBuilder resume(MemberResume resume) { + this.resume = resume; + return this; + } + + public ResumeEvaluationFixtureBuilder portfolio(MemberPortfolio portfolio) { + this.portfolio = portfolio; + return this; + } + + public ResumeEvaluationFixtureBuilder jobPosition(String jobPosition) { + this.jobPosition = jobPosition; + return this; + } + + public ResumeEvaluationFixtureBuilder jobDescription(String jobDescription) { + this.jobDescription = jobDescription; + return this; + } + + public ResumeEvaluationFixtureBuilder jobCareer(String jobCareer) { + this.jobCareer = jobCareer; + return this; + } + + public ResumeEvaluationFixtureBuilder completed() { + this.completed = true; + return this; + } + + public ResumeEvaluationFixtureBuilder failed() { + this.failed = true; + return this; + } + + public ResumeEvaluationFixtureBuilder totalScore(int totalScore) { + this.totalScore = totalScore; + return this; + } + + public ResumeEvaluation build() { + ResumeEvaluation evaluation = new ResumeEvaluation( + member, + resume, + portfolio, + jobPosition != null ? jobPosition : "백엔드 개발자", + jobDescription != null ? jobDescription : "Spring Boot 기반 백엔드 개발", + jobCareer != null ? jobCareer : "신입" + ); + + if (completed) { + evaluation.complete( + technicalSkillsScore, technicalSkillsReason, technicalSkillsImprovements, + projectExperienceScore, projectExperienceReason, projectExperienceImprovements, + problemSolvingScore, problemSolvingReason, problemSolvingImprovements, + careerGrowthScore, careerGrowthReason, careerGrowthImprovements, + documentationScore, documentationReason, documentationImprovements, + totalScore, totalFeedback + ); + } + + if (failed) { + evaluation.fail(); + } + + return evaluation; + } +} diff --git a/api/src/test/java/com/samhap/kokomen/resume/controller/CareerMaterialsControllerTest.java b/api/src/test/java/com/samhap/kokomen/resume/controller/CareerMaterialsControllerTest.java index 85b91cb5..49edff70 100644 --- a/api/src/test/java/com/samhap/kokomen/resume/controller/CareerMaterialsControllerTest.java +++ b/api/src/test/java/com/samhap/kokomen/resume/controller/CareerMaterialsControllerTest.java @@ -1,5 +1,8 @@ package com.samhap.kokomen.resume.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; @@ -7,6 +10,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -18,17 +22,28 @@ import com.samhap.kokomen.global.fixture.member.MemberFixtureBuilder; import com.samhap.kokomen.global.fixture.resume.MemberPortfolioFixtureBuilder; import com.samhap.kokomen.global.fixture.resume.MemberResumeFixtureBuilder; +import com.samhap.kokomen.global.fixture.resume.ResumeEvaluationFixtureBuilder; import com.samhap.kokomen.global.fixture.token.TokenFixtureBuilder; import com.samhap.kokomen.member.domain.Member; import com.samhap.kokomen.member.repository.MemberRepository; +import com.samhap.kokomen.resume.domain.MemberPortfolio; +import com.samhap.kokomen.resume.domain.MemberResume; +import com.samhap.kokomen.resume.domain.PdfTextExtractor; +import com.samhap.kokomen.resume.domain.PdfValidator; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; import com.samhap.kokomen.resume.repository.MemberPortfolioRepository; import com.samhap.kokomen.resume.repository.MemberResumeRepository; +import com.samhap.kokomen.resume.repository.ResumeEvaluationRepository; +import com.samhap.kokomen.resume.service.ResumeEvaluationAsyncService; import com.samhap.kokomen.token.domain.TokenType; import com.samhap.kokomen.token.repository.TokenRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.multipart.MultipartFile; class CareerMaterialsControllerTest extends BaseControllerTest { @@ -40,6 +55,14 @@ class CareerMaterialsControllerTest extends BaseControllerTest { private MemberPortfolioRepository memberPortfolioRepository; @Autowired private MemberResumeRepository memberResumeRepository; + @Autowired + private ResumeEvaluationRepository resumeEvaluationRepository; + @MockitoBean + private ResumeEvaluationAsyncService resumeEvaluationAsyncService; + @MockitoBean + private PdfValidator pdfValidator; + @MockitoBean + private PdfTextExtractor pdfTextExtractor; @Test void 이력서_업로드_성공() throws Exception { @@ -137,4 +160,393 @@ class CareerMaterialsControllerTest extends BaseControllerTest { ) )); } + + @Test + void 회원_이력서_평가_비동기_제출_성공() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + doNothing().when(pdfValidator).validate(any(MultipartFile.class)); + when(pdfTextExtractor.extractText(any(MultipartFile.class))) + .thenReturn("Java, Spring Boot 경험 3년. 백엔드 개발자로서 RESTful API 설계 및 구현 경험이 있습니다.") + .thenReturn("GitHub: https://github.com/example"); + + MockMultipartFile resume = new MockMultipartFile( + "resume", + "resume.pdf", + "application/pdf", + "Java, Spring Boot 경험 3년. 백엔드 개발자로서 RESTful API 설계 및 구현 경험이 있습니다.".getBytes() + ); + MockMultipartFile portfolio = new MockMultipartFile( + "portfolio", + "portfolio.pdf", + "application/pdf", + "GitHub: https://github.com/example".getBytes() + ); + String jobPosition = "백엔드 개발자"; + String jobDescription = "Spring Boot 기반 백엔드 개발"; + String jobCareer = "경력"; + + mockMvc.perform(multipart("/api/v1/resumes/evaluations") + .file(resume) + .file(portfolio) + .file("job_position", jobPosition.getBytes()) + .file("job_description", jobDescription.getBytes()) + .file("job_career", jobCareer.getBytes()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.evaluation_id").exists()) + .andDo(document("resume-evaluation-async-submit", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키 (선택)") + ), + requestParts( + partWithName("resume").description("이력서 PDF 파일"), + partWithName("portfolio").description("포트폴리오 PDF 파일 (선택)").optional(), + partWithName("job_position").description("지원 직무"), + partWithName("job_description").description("채용공고 상세 내용 (선택)").optional(), + partWithName("job_career").description("경력 구분 (신입/경력)") + ), + responseFields( + fieldWithPath("evaluation_id").description("평가 ID (회원: 숫자, 비회원: uuid-xxx 형식)") + ) + )); + } + + @Test + void 이력서_평가_상태_조회_대기중() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + ResumeEvaluation evaluation = resumeEvaluationRepository.save( + ResumeEvaluationFixtureBuilder.builder() + .member(member) + .build() + ); + + mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/resumes/evaluations/{evaluationId}/state", + evaluation.getId()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("PENDING")) + .andExpect(jsonPath("$.result").doesNotExist()) + .andDo(document("resume-evaluation-state-pending", + pathParameters( + parameterWithName("evaluationId").description("평가 ID") + ), + responseFields( + fieldWithPath("state").description("평가 상태 (PENDING, COMPLETED, FAILED)") + ) + )); + } + + @Test + void 이력서_평가_상태_조회_완료() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + ResumeEvaluation evaluation = resumeEvaluationRepository.save( + ResumeEvaluationFixtureBuilder.builder() + .member(member) + .completed() + .build() + ); + + mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/resumes/evaluations/{evaluationId}/state", + evaluation.getId()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("COMPLETED")) + .andExpect(jsonPath("$.result").exists()) + .andExpect(jsonPath("$.result.total_score").value(81)) + .andDo(document("resume-evaluation-state-completed", + pathParameters( + parameterWithName("evaluationId").description("평가 ID") + ), + responseFields( + fieldWithPath("state").description("평가 상태 (PENDING, COMPLETED, FAILED)"), + fieldWithPath("result").description("평가 결과"), + fieldWithPath("result.technical_skills").description("기술 역량 평가"), + fieldWithPath("result.technical_skills.score").description("기술 역량 점수"), + fieldWithPath("result.technical_skills.reason").description("기술 역량 평가 사유"), + fieldWithPath("result.technical_skills.improvements").description("기술 역량 개선점"), + fieldWithPath("result.project_experience").description("프로젝트 경험 평가"), + fieldWithPath("result.project_experience.score").description("프로젝트 경험 점수"), + fieldWithPath("result.project_experience.reason").description("프로젝트 경험 평가 사유"), + fieldWithPath("result.project_experience.improvements").description("프로젝트 경험 개선점"), + fieldWithPath("result.problem_solving").description("문제 해결 평가"), + fieldWithPath("result.problem_solving.score").description("문제 해결 점수"), + fieldWithPath("result.problem_solving.reason").description("문제 해결 평가 사유"), + fieldWithPath("result.problem_solving.improvements").description("문제 해결 개선점"), + fieldWithPath("result.career_growth").description("성장 가능성 평가"), + fieldWithPath("result.career_growth.score").description("성장 가능성 점수"), + fieldWithPath("result.career_growth.reason").description("성장 가능성 평가 사유"), + fieldWithPath("result.career_growth.improvements").description("성장 가능성 개선점"), + fieldWithPath("result.documentation").description("문서화 평가"), + fieldWithPath("result.documentation.score").description("문서화 점수"), + fieldWithPath("result.documentation.reason").description("문서화 평가 사유"), + fieldWithPath("result.documentation.improvements").description("문서화 개선점"), + fieldWithPath("result.total_score").description("총점"), + fieldWithPath("result.total_feedback").description("종합 피드백") + ) + )); + } + + @Test + void 이력서_평가_히스토리_조회() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + for (int i = 0; i < 3; i++) { + resumeEvaluationRepository.save( + ResumeEvaluationFixtureBuilder.builder() + .member(member) + .completed() + .totalScore(80 + i) + .build() + ); + } + + mockMvc.perform(get("/api/v1/resumes/evaluations") + .param("page", "0") + .param("size", "20") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.evaluations").isArray()) + .andExpect(jsonPath("$.evaluations.length()").value(3)) + .andExpect(jsonPath("$.current_page").value(0)) + .andExpect(jsonPath("$.total_resume_evaluation_count").value(3)) + .andExpect(jsonPath("$.total_pages").value(1)) + .andExpect(jsonPath("$.has_next").value(false)) + .andDo(document("resume-evaluation-history", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (0부터 시작)"), + parameterWithName("size").description("페이지 크기") + ), + responseFields( + fieldWithPath("evaluations").description("평가 목록"), + fieldWithPath("evaluations[].id").description("평가 ID"), + fieldWithPath("evaluations[].state").description("평가 상태"), + fieldWithPath("evaluations[].job_position").description("지원 직무"), + fieldWithPath("evaluations[].job_career").description("경력 구분"), + fieldWithPath("evaluations[].total_score").description("총점").optional(), + fieldWithPath("evaluations[].created_at").description("생성일시"), + fieldWithPath("current_page").description("현재 페이지 번호"), + fieldWithPath("total_resume_evaluation_count").description("전체 평가 개수"), + fieldWithPath("total_pages").description("전체 페이지 수"), + fieldWithPath("has_next").description("다음 페이지 존재 여부") + ) + )); + } + + @Test + void 이력서_평가_상세_조회() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + MemberResume resume = memberResumeRepository.save( + MemberResumeFixtureBuilder.builder() + .member(member) + .build() + ); + MemberPortfolio portfolio = memberPortfolioRepository.save( + MemberPortfolioFixtureBuilder.builder() + .member(member) + .build() + ); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + ResumeEvaluation evaluation = resumeEvaluationRepository.save( + ResumeEvaluationFixtureBuilder.builder() + .member(member) + .resume(resume) + .portfolio(portfolio) + .completed() + .build() + ); + + mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/resumes/evaluations/{evaluationId}", + evaluation.getId()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(evaluation.getId())) + .andExpect(jsonPath("$.state").value("COMPLETED")) + .andExpect(jsonPath("$.resume").exists()) + .andExpect(jsonPath("$.result").exists()) + .andDo(document("resume-evaluation-detail", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + pathParameters( + parameterWithName("evaluationId").description("평가 ID") + ), + responseFields( + fieldWithPath("id").description("평가 ID"), + fieldWithPath("state").description("평가 상태"), + fieldWithPath("resume").description("이력서 텍스트"), + fieldWithPath("resume.id").description("이력서 ID"), + fieldWithPath("resume.title").description("이력서 파일명"), + fieldWithPath("portfolio").description("포트폴리오 텍스트").optional(), + fieldWithPath("portfolio.id").description("포트폴리오 ID").optional(), + fieldWithPath("portfolio.title").description("포트폴리오 파일명").optional(), + fieldWithPath("job_position").description("지원 직무"), + fieldWithPath("job_description").description("직무 설명").optional(), + fieldWithPath("job_career").description("경력 구분"), + fieldWithPath("result").description("평가 결과"), + fieldWithPath("result.technical_skills").description("기술 역량 평가"), + fieldWithPath("result.technical_skills.score").description("기술 역량 점수"), + fieldWithPath("result.technical_skills.reason").description("기술 역량 평가 사유"), + fieldWithPath("result.technical_skills.improvements").description("기술 역량 개선점"), + fieldWithPath("result.project_experience").description("프로젝트 경험 평가"), + fieldWithPath("result.project_experience.score").description("프로젝트 경험 점수"), + fieldWithPath("result.project_experience.reason").description("프로젝트 경험 평가 사유"), + fieldWithPath("result.project_experience.improvements").description("프로젝트 경험 개선점"), + fieldWithPath("result.problem_solving").description("문제 해결 평가"), + fieldWithPath("result.problem_solving.score").description("문제 해결 점수"), + fieldWithPath("result.problem_solving.reason").description("문제 해결 평가 사유"), + fieldWithPath("result.problem_solving.improvements").description("문제 해결 개선점"), + fieldWithPath("result.career_growth").description("성장 가능성 평가"), + fieldWithPath("result.career_growth.score").description("성장 가능성 점수"), + fieldWithPath("result.career_growth.reason").description("성장 가능성 평가 사유"), + fieldWithPath("result.career_growth.improvements").description("성장 가능성 개선점"), + fieldWithPath("result.documentation").description("문서화 평가"), + fieldWithPath("result.documentation.score").description("문서화 점수"), + fieldWithPath("result.documentation.reason").description("문서화 평가 사유"), + fieldWithPath("result.documentation.improvements").description("문서화 개선점"), + fieldWithPath("result.total_score").description("총점"), + fieldWithPath("result.total_feedback").description("종합 피드백"), + fieldWithPath("created_at").description("생성일시") + ) + )); + } + + @Test + void 저장된_이력서_기반_평가_비동기_제출_성공() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + MemberResume resume = memberResumeRepository.save( + MemberResumeFixtureBuilder.builder() + .member(member) + .build() + ); + MemberPortfolio portfolio = memberPortfolioRepository.save( + MemberPortfolioFixtureBuilder.builder() + .member(member) + .build() + ); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + String jobPosition = "백엔드 개발자"; + String jobDescription = "Spring Boot 기반 백엔드 개발"; + String jobCareer = "경력"; + + mockMvc.perform(multipart("/api/v1/resumes/evaluations") + .file("resume_id", resume.getId().toString().getBytes()) + .file("portfolio_id", portfolio.getId().toString().getBytes()) + .file("job_position", jobPosition.getBytes()) + .file("job_description", jobDescription.getBytes()) + .file("job_career", jobCareer.getBytes()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.evaluation_id").exists()) + .andDo(document("resume-evaluation-saved-async-submit", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + requestParts( + partWithName("resume_id").description("저장된 이력서 ID"), + partWithName("portfolio_id").description("저장된 포트폴리오 ID (선택)").optional(), + partWithName("job_position").description("지원 직무"), + partWithName("job_description").description("채용공고 상세 내용 (선택)").optional(), + partWithName("job_career").description("경력 구분 (신입/경력)") + ), + responseFields( + fieldWithPath("evaluation_id").description("평가 ID") + ) + )); + } + + @Test + void 저장된_이력서_기반_평가_포트폴리오_없이_제출_성공() throws Exception { + Member member = memberRepository.save(MemberFixtureBuilder.builder().build()); + MemberResume resume = memberResumeRepository.save( + MemberResumeFixtureBuilder.builder() + .member(member) + .build() + ); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.FREE).tokenCount(20).build()); + tokenRepository.save( + TokenFixtureBuilder.builder().memberId(member.getId()).type(TokenType.PAID).tokenCount(0).build()); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", member.getId()); + + String jobPosition = "백엔드 개발자"; + String jobCareer = "신입"; + + mockMvc.perform(multipart("/api/v1/resumes/evaluations") + .file("resume_id", resume.getId().toString().getBytes()) + .file("job_position", jobPosition.getBytes()) + .file("job_career", jobCareer.getBytes()) + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session) + ) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.evaluation_id").exists()) + .andDo(document("resume-evaluation-saved-async-submit-without-portfolio", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + requestParts( + partWithName("resume_id").description("저장된 이력서 ID"), + partWithName("job_position").description("지원 직무"), + partWithName("job_career").description("경력 구분 (신입/경력)") + ), + responseFields( + fieldWithPath("evaluation_id").description("평가 ID") + ) + )); + } } diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/common/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 9641eeeb..d86fd558 100644 --- a/common/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/common/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -18,14 +18,15 @@ public class GlobalExceptionHandler { @ExceptionHandler(InternalApiException.class) public ResponseEntity handleInternalApiException(InternalApiException e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(e.getHttpStatusCode()) .body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(KokomenException.class) public ResponseEntity handleKokomenException(KokomenException e) { - log.warn("KokomenException :: status: {}, message: {}", e.getHttpStatusCode(), e.getMessage()); + log.warn("KokomenException :: status: {}, message: {}", e.getHttpStatusCode(), e.getMessage(), e); return ResponseEntity.status(e.getHttpStatusCode()) .body(new ErrorResponse(e.getMessage())); } @@ -52,13 +53,15 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho @ExceptionHandler(ExternalApiException.class) public ResponseEntity handleExternalApiException(ExternalApiException e) { - log.warn("ExternalApiException :: status: {}, message: {}, stackTrace: ", e.getHttpStatusCode(), e.getMessage(), e); + log.warn("ExternalApiException :: status: {}, message: {}, stackTrace: ", e.getHttpStatusCode(), e.getMessage(), + e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse(e.getMessage())); } @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; log.warn("MissingServletRequestParameterException :: message: {}", message); return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -85,7 +88,8 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); } diff --git a/common/src/main/java/com/samhap/kokomen/member/domain/Member.java b/common/src/main/java/com/samhap/kokomen/member/domain/Member.java index d68f20a2..73a3e0c1 100644 --- a/common/src/main/java/com/samhap/kokomen/member/domain/Member.java +++ b/common/src/main/java/com/samhap/kokomen/member/domain/Member.java @@ -59,4 +59,8 @@ public void withdraw() { this.nickname = null; this.score = 0; } + + public boolean isOwner(Long memberId) { + return this.id.equals(memberId); + } } diff --git a/common/src/main/java/com/samhap/kokomen/resume/domain/MemberPortfolio.java b/common/src/main/java/com/samhap/kokomen/resume/domain/MemberPortfolio.java index 521bdb05..de2f6714 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/domain/MemberPortfolio.java +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/MemberPortfolio.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -43,9 +44,26 @@ public class MemberPortfolio extends BaseEntity { @Column(name = "portfolio_url", nullable = false) private String portfolioUrl; - public MemberPortfolio(Member member, String title, String portfolioUrl) { + @Lob + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + public MemberPortfolio(Member member, String title, String portfolioUrl, String content) { this.member = member; this.title = title; this.portfolioUrl = portfolioUrl; + this.content = content; + } + + public MemberPortfolio(Member member, String title, String portfolioUrl) { + this(member, title, portfolioUrl, null); + } + + public void updateContent(String content) { + this.content = content; + } + + public boolean hasContent() { + return content != null && !content.isBlank(); } } diff --git a/common/src/main/java/com/samhap/kokomen/resume/domain/MemberResume.java b/common/src/main/java/com/samhap/kokomen/resume/domain/MemberResume.java index ceb33c16..9fa292b7 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/domain/MemberResume.java +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/MemberResume.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -43,9 +44,26 @@ public class MemberResume extends BaseEntity { @Column(name = "resume_url", nullable = false) private String resumeUrl; - public MemberResume(Member member, String title, String resumeUrl) { + @Lob + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + public MemberResume(Member member, String title, String resumeUrl, String content) { this.member = member; this.title = title; this.resumeUrl = resumeUrl; + this.content = content; + } + + public MemberResume(Member member, String title, String resumeUrl) { + this(member, title, resumeUrl, null); + } + + public void updateContent(String content) { + this.content = content; + } + + public boolean hasContent() { + return content != null && !content.isBlank(); } } diff --git a/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java new file mode 100644 index 00000000..d80b0d51 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java @@ -0,0 +1,168 @@ +package com.samhap.kokomen.resume.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "resume_evaluation", indexes = { + @Index(name = "idx_resume_evaluation_member_id", columnList = "member_id") +}) +public class ResumeEvaluation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false, length = 20) + private ResumeEvaluationState state; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_resume_id") + private MemberResume memberResume; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_portfolio_id") + private MemberPortfolio memberPortfolio; + + @Column(name = "job_position", nullable = false, length = 500) + private String jobPosition; + + @Column(name = "job_description", columnDefinition = "TEXT") + private String jobDescription; + + @Column(name = "job_career", nullable = false, length = 100) + private String jobCareer; + + @Column(name = "technical_skills_score") + private Integer technicalSkillsScore; + + @Column(name = "technical_skills_reason", columnDefinition = "TEXT") + private String technicalSkillsReason; + + @Column(name = "technical_skills_improvements", columnDefinition = "TEXT") + private String technicalSkillsImprovements; + + @Column(name = "project_experience_score") + private Integer projectExperienceScore; + + @Column(name = "project_experience_reason", columnDefinition = "TEXT") + private String projectExperienceReason; + + @Column(name = "project_experience_improvements", columnDefinition = "TEXT") + private String projectExperienceImprovements; + + @Column(name = "problem_solving_score") + private Integer problemSolvingScore; + + @Column(name = "problem_solving_reason", columnDefinition = "TEXT") + private String problemSolvingReason; + + @Column(name = "problem_solving_improvements", columnDefinition = "TEXT") + private String problemSolvingImprovements; + + @Column(name = "career_growth_score") + private Integer careerGrowthScore; + + @Column(name = "career_growth_reason", columnDefinition = "TEXT") + private String careerGrowthReason; + + @Column(name = "career_growth_improvements", columnDefinition = "TEXT") + private String careerGrowthImprovements; + + @Column(name = "documentation_score") + private Integer documentationScore; + + @Column(name = "documentation_reason", columnDefinition = "TEXT") + private String documentationReason; + + @Column(name = "documentation_improvements", columnDefinition = "TEXT") + private String documentationImprovements; + + @Column(name = "total_score") + private Integer totalScore; + + @Column(name = "total_feedback", columnDefinition = "TEXT") + private String totalFeedback; + + public ResumeEvaluation(Member member, MemberResume memberResume, MemberPortfolio memberPortfolio, + String jobPosition, String jobDescription, String jobCareer) { + this.member = member; + this.state = ResumeEvaluationState.PENDING; + this.memberResume = memberResume; + this.memberPortfolio = memberPortfolio; + this.jobPosition = jobPosition; + this.jobDescription = jobDescription; + this.jobCareer = jobCareer; + } + + public void complete(int technicalSkillsScore, String technicalSkillsReason, String technicalSkillsImprovements, + int projectExperienceScore, String projectExperienceReason, + String projectExperienceImprovements, + int problemSolvingScore, String problemSolvingReason, String problemSolvingImprovements, + int careerGrowthScore, String careerGrowthReason, String careerGrowthImprovements, + int documentationScore, String documentationReason, String documentationImprovements, + int totalScore, String totalFeedback) { + this.state = ResumeEvaluationState.COMPLETED; + this.technicalSkillsScore = technicalSkillsScore; + this.technicalSkillsReason = technicalSkillsReason; + this.technicalSkillsImprovements = technicalSkillsImprovements; + this.projectExperienceScore = projectExperienceScore; + this.projectExperienceReason = projectExperienceReason; + this.projectExperienceImprovements = projectExperienceImprovements; + this.problemSolvingScore = problemSolvingScore; + this.problemSolvingReason = problemSolvingReason; + this.problemSolvingImprovements = problemSolvingImprovements; + this.careerGrowthScore = careerGrowthScore; + this.careerGrowthReason = careerGrowthReason; + this.careerGrowthImprovements = careerGrowthImprovements; + this.documentationScore = documentationScore; + this.documentationReason = documentationReason; + this.documentationImprovements = documentationImprovements; + this.totalScore = totalScore; + this.totalFeedback = totalFeedback; + } + + public void fail() { + this.state = ResumeEvaluationState.FAILED; + } + + public boolean isCompleted() { + return this.state == ResumeEvaluationState.COMPLETED; + } + + public boolean isPending() { + return this.state == ResumeEvaluationState.PENDING; + } + + public boolean isOwner(Long memberId) { + return this.member.isOwner(memberId); + } + + public void updateMemberResume(MemberResume memberResume, MemberPortfolio memberPortfolio) { + this.memberResume = memberResume; + this.memberPortfolio = memberPortfolio; + } +} diff --git a/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluationState.java b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluationState.java new file mode 100644 index 00000000..49bb98aa --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluationState.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.resume.domain; + +public enum ResumeEvaluationState { + PENDING, + COMPLETED, + FAILED, + ; +} diff --git a/common/src/main/java/com/samhap/kokomen/resume/repository/MemberPortfolioRepository.java b/common/src/main/java/com/samhap/kokomen/resume/repository/MemberPortfolioRepository.java index c2207a64..f538db4a 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/repository/MemberPortfolioRepository.java +++ b/common/src/main/java/com/samhap/kokomen/resume/repository/MemberPortfolioRepository.java @@ -2,9 +2,12 @@ import com.samhap.kokomen.resume.domain.MemberPortfolio; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberPortfolioRepository extends JpaRepository { List findByMemberId(Long memberId); + + Optional findByIdAndMemberId(Long id, Long memberId); } diff --git a/common/src/main/java/com/samhap/kokomen/resume/repository/MemberResumeRepository.java b/common/src/main/java/com/samhap/kokomen/resume/repository/MemberResumeRepository.java index 6acbf668..c6cfd018 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/repository/MemberResumeRepository.java +++ b/common/src/main/java/com/samhap/kokomen/resume/repository/MemberResumeRepository.java @@ -2,9 +2,12 @@ import com.samhap.kokomen.resume.domain.MemberResume; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberResumeRepository extends JpaRepository { List findByMemberId(Long memberId); + + Optional findByIdAndMemberId(Long id, Long memberId); } diff --git a/common/src/main/java/com/samhap/kokomen/resume/repository/ResumeEvaluationRepository.java b/common/src/main/java/com/samhap/kokomen/resume/repository/ResumeEvaluationRepository.java new file mode 100644 index 00000000..28366f03 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/resume/repository/ResumeEvaluationRepository.java @@ -0,0 +1,11 @@ +package com.samhap.kokomen.resume.repository; + +import com.samhap.kokomen.resume.domain.ResumeEvaluation; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ResumeEvaluationRepository extends JpaRepository { + + Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); +} diff --git a/common/src/main/resources/db/migration/V29__create_resume_evaluation_table.sql b/common/src/main/resources/db/migration/V29__create_resume_evaluation_table.sql new file mode 100644 index 00000000..f64896a0 --- /dev/null +++ b/common/src/main/resources/db/migration/V29__create_resume_evaluation_table.sql @@ -0,0 +1,33 @@ +CREATE TABLE resume_evaluation ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + state VARCHAR(20) NOT NULL, + resume TEXT NOT NULL, + portfolio TEXT, + job_position VARCHAR(500) NOT NULL, + job_description TEXT, + job_career VARCHAR(100) NOT NULL, + technical_skills_score INT, + technical_skills_reason TEXT, + technical_skills_improvements TEXT, + project_experience_score INT, + project_experience_reason TEXT, + project_experience_improvements TEXT, + problem_solving_score INT, + problem_solving_reason TEXT, + problem_solving_improvements TEXT, + career_growth_score INT, + career_growth_reason TEXT, + career_growth_improvements TEXT, + documentation_score INT, + documentation_reason TEXT, + documentation_improvements TEXT, + total_score INT, + total_feedback TEXT, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6), + PRIMARY KEY (id), + CONSTRAINT fk_resume_evaluation_member FOREIGN KEY (member_id) REFERENCES member (id) +); + +CREATE INDEX idx_resume_evaluation_member_id ON resume_evaluation(member_id); diff --git a/common/src/main/resources/db/migration/V30__add_content_to_career_materials.sql b/common/src/main/resources/db/migration/V30__add_content_to_career_materials.sql new file mode 100644 index 00000000..2c2dbc58 --- /dev/null +++ b/common/src/main/resources/db/migration/V30__add_content_to_career_materials.sql @@ -0,0 +1,5 @@ +ALTER TABLE member_resume + ADD COLUMN content LONGTEXT NULL; + +ALTER TABLE member_portfolio + ADD COLUMN content LONGTEXT NULL; diff --git a/common/src/main/resources/db/migration/V31__modify_resume_evaluation_resume_nullable.sql b/common/src/main/resources/db/migration/V31__modify_resume_evaluation_resume_nullable.sql new file mode 100644 index 00000000..d626a446 --- /dev/null +++ b/common/src/main/resources/db/migration/V31__modify_resume_evaluation_resume_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE resume_evaluation + MODIFY COLUMN resume TEXT NULL; diff --git a/common/src/main/resources/db/migration/V32__alter_resume_evaluation_to_member_resume_relation.sql b/common/src/main/resources/db/migration/V32__alter_resume_evaluation_to_member_resume_relation.sql new file mode 100644 index 00000000..0fbff7a3 --- /dev/null +++ b/common/src/main/resources/db/migration/V32__alter_resume_evaluation_to_member_resume_relation.sql @@ -0,0 +1,16 @@ +-- 기존 TEXT 컬럼 삭제 +ALTER TABLE resume_evaluation DROP COLUMN resume; +ALTER TABLE resume_evaluation DROP COLUMN portfolio; + +-- 새 외래 키 컬럼 추가 +ALTER TABLE resume_evaluation ADD COLUMN member_resume_id BIGINT; +ALTER TABLE resume_evaluation ADD COLUMN member_portfolio_id BIGINT; + +-- 외래 키 제약조건 추가 +ALTER TABLE resume_evaluation + ADD CONSTRAINT fk_resume_evaluation_member_resume + FOREIGN KEY (member_resume_id) REFERENCES member_resume(id); + +ALTER TABLE resume_evaluation + ADD CONSTRAINT fk_resume_evaluation_member_portfolio + FOREIGN KEY (member_portfolio_id) REFERENCES member_portfolio(id);