From b972d7f7b8590cdd3d3fc8c95ee0039fa6230015 Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Sat, 6 Dec 2025 15:13:44 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[REFACTOR]=20=EC=9D=B4=EB=A0=A5=EC=84=9C,?= =?UTF-8?q?=20=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B1=84=EC=9A=A9=EA=B3=B5=EA=B3=A0=20=EC=A0=81?= =?UTF-8?q?=ED=95=A9=EB=8F=84=20=EB=B6=84=EC=84=9D=EC=9D=84=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/docs/asciidoc/index.adoc | 49 +++ .../kokomen/global/config/AsyncConfig.java | 14 + .../controller/CareerMaterialsController.java | 43 +++ .../service/CareerMaterialsFacadeService.java | 135 +++++++++ .../service/ResumeEvaluationAsyncService.java | 226 ++++++++++++++ .../ResumeEvaluationPersistenceService.java | 64 ++++ .../dto/NonMemberResumeEvaluationData.java | 22 ++ .../dto/ResumeEvaluationAsyncRequest.java | 22 ++ .../dto/ResumeEvaluationDetailResponse.java | 35 +++ .../dto/ResumeEvaluationHistoryResponse.java | 25 ++ .../dto/ResumeEvaluationHistoryResponses.java | 29 ++ .../service/dto/ResumeEvaluationResponse.java | 36 +++ .../dto/ResumeEvaluationStateResponse.java | 31 ++ .../dto/ResumeEvaluationSubmitResponse.java | 13 + .../ResumeEvaluationFixtureBuilder.java | 111 +++++++ .../CareerMaterialsControllerTest.java | 279 ++++++++++++++++++ .../samhap/kokomen/member/domain/Member.java | 4 + .../resume/domain/ResumeEvaluation.java | 161 ++++++++++ .../resume/domain/ResumeEvaluationState.java | 8 + .../ResumeEvaluationRepository.java | 11 + .../V29__create_resume_evaluation_table.sql | 33 +++ 21 files changed, 1351 insertions(+) create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/NonMemberResumeEvaluationData.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponse.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationHistoryResponses.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationStateResponse.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationSubmitResponse.java create mode 100644 api/src/test/java/com/samhap/kokomen/global/fixture/resume/ResumeEvaluationFixtureBuilder.java create mode 100644 common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java create mode 100644 common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluationState.java create mode 100644 common/src/main/java/com/samhap/kokomen/resume/repository/ResumeEvaluationRepository.java create mode 100644 common/src/main/resources/db/migration/V29__create_resume_evaluation_table.sql diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index f8b23212..5d85b7c7 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -575,3 +575,52 @@ 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-body.adoc[] +include::{snippetsDir}/resume-evaluation-async-submit/request-fields.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-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/resume/controller/CareerMaterialsController.java b/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java index fb49a15f..de26a464 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 @@ -5,13 +5,23 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -39,4 +49,37 @@ public ResponseEntity getCareerMaterials( ) { return ResponseEntity.ok(careerMaterialsFacadeService.getCareerMaterials(type, memberAuth)); } + + @PostMapping("/evaluations") + public ResponseEntity submitResumeEvaluationAsync( + @RequestBody @Valid ResumeEvaluationAsyncRequest request, + @Authentication(required = false) MemberAuth memberAuth + ) { + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth)); + } + + @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/service/CareerMaterialsFacadeService.java b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsFacadeService.java index 5183fa50..1fc2939d 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,26 +1,46 @@ package com.samhap.kokomen.resume.service; 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.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.ResumeSaveRequest; 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; @RequiredArgsConstructor @Service public class CareerMaterialsFacadeService { + private static final String UUID_PREFIX = "uuid-"; + private final ResumeService resumeService; private final PortfolioService portfolioService; private final MemberService memberService; private final ResumeEvaluationService resumeEvaluationService; + private final ResumeEvaluationPersistenceService resumeEvaluationPersistenceService; + private final ResumeEvaluationAsyncService resumeEvaluationAsyncService; + private final RedisService redisService; + @Transactional(readOnly = true) public CareerMaterialsResponse getCareerMaterials(CareerMaterialsType type, MemberAuth memberAuth) { return switch (type) { case ALL: @@ -41,6 +61,7 @@ yield new CareerMaterialsResponse( }; } + @Transactional public void saveCareerMaterials(ResumeSaveRequest request, MemberAuth memberAuth) { Member member = memberService.readById(memberAuth.memberId()); resumeService.saveResume(request.resume(), member); @@ -49,7 +70,121 @@ public void saveCareerMaterials(ResumeSaveRequest request, MemberAuth memberAuth } } + @Transactional public ResumeEvaluationResponse evaluateResume(ResumeEvaluationRequest request) { return resumeEvaluationService.evaluate(request); } + + @Transactional + public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, + MemberAuth memberAuth) { + if (memberAuth.isAuthenticated()) { + return submitMemberResumeEvaluationAsync(request, memberAuth); + } + return submitNonMemberResumeEvaluationAsync(request); + } + + private ResumeEvaluationSubmitResponse submitMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, + MemberAuth memberAuth) { + Member member = memberService.readById(memberAuth.memberId()); + ResumeEvaluation evaluation = new ResumeEvaluation( + member, + request.resume(), + request.portfolio(), + request.jobPosition(), + request.jobDescription(), + request.jobCareer() + ); + ResumeEvaluation savedEvaluation = resumeEvaluationPersistenceService.saveEvaluation(evaluation); + + resumeEvaluationAsyncService.evaluateMemberAsync( + savedEvaluation.getId(), + request.toEvaluationRequest() + ); + + return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); + } + + private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request) { + String uuid = UUID.randomUUID().toString(); + resumeEvaluationAsyncService.evaluateNonMemberAsync(uuid, request.toEvaluationRequest()); + return ResumeEvaluationSubmitResponse.fromUuid(uuid); + } + + @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, NonMemberResumeEvaluationData.class) + .map(this::convertToStateResponse) + .orElseThrow(() -> new BadRequestException("이력서 평가 결과를 찾을 수 없습니다. 만료되었거나 존재하지 않는 ID입니다.")); + } + + private String extractUuid(String evaluationId) { + return evaluationId.substring(UUID_PREFIX.length()); + } + + 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 = resumeEvaluationPersistenceService.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 = resumeEvaluationPersistenceService + .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 = resumeEvaluationPersistenceService.readById(evaluationId); + validateEvaluationOwner(evaluation, memberAuth.memberId()); + return ResumeEvaluationDetailResponse.from(evaluation); + } + + private void validateEvaluationOwner(ResumeEvaluation evaluation, Long memberId) { + if (!evaluation.isOwner(memberId)) { + 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..c926f3bd --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -0,0 +1,226 @@ +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.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 java.time.Duration; +import java.util.Map; +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 ResumeEvaluationPersistenceService resumeEvaluationPersistenceService; + private final RedisService redisService; + private final BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient; + private final ResumeGptClient resumeGptClient; + private final ObjectMapper objectMapper; + private final ThreadPoolTaskExecutor executor; + + public ResumeEvaluationAsyncService( + ResumeEvaluationPersistenceService resumeEvaluationPersistenceService, + RedisService redisService, + BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient, + ResumeGptClient resumeGptClient, + ObjectMapper objectMapper, + @Qualifier("resumeEvaluationExecutor") + ThreadPoolTaskExecutor executor + ) { + this.resumeEvaluationPersistenceService = resumeEvaluationPersistenceService; + this.redisService = redisService; + this.bedrockAgentRuntimeAsyncClient = bedrockAgentRuntimeAsyncClient; + this.resumeGptClient = resumeGptClient; + this.objectMapper = objectMapper; + this.executor = executor; + } + + public 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); + resumeEvaluationPersistenceService.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); + resumeEvaluationPersistenceService.updateCompleted(evaluationId, response); + } catch (Exception e) { + log.error("GPT 폴백 실패 - evaluationId: {}", evaluationId, e); + resumeEvaluationPersistenceService.updateFailed(evaluationId); + } finally { + MDC.clear(); + } + }); + } + + public void evaluateNonMemberAsync(String uuid, ResumeEvaluationRequest request) { + Map mdcContext = MDC.getCopyOfContextMap(); + String redisKey = createRedisKey(uuid); + InvokeFlowRequest flowRequest = ResumeInvokeFlowRequestFactory.createResumeEvaluationFlowRequest(request); + + redisService.setValue(redisKey, NonMemberResumeEvaluationData.pending(request), REDIS_TTL); + + 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); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.completed(request, response), REDIS_TTL); + } + } 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); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.completed(request, response), REDIS_TTL); + } catch (Exception e) { + log.error("GPT 폴백 실패 - uuid: {}", uuid, e); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(request), REDIS_TTL); + } finally { + MDC.clear(); + } + }); + } + + 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/ResumeEvaluationPersistenceService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java new file mode 100644 index 00000000..7a92dade --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java @@ -0,0 +1,64 @@ +package com.samhap.kokomen.resume.service; + +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; +import com.samhap.kokomen.resume.repository.ResumeEvaluationRepository; +import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; +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; + +@RequiredArgsConstructor +@Service +public class ResumeEvaluationPersistenceService { + + private final ResumeEvaluationRepository resumeEvaluationRepository; + + @Transactional + 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 = readById(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 updateFailed(Long evaluationId) { + ResumeEvaluation evaluation = readById(evaluationId); + evaluation.fail(); + } +} 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/ResumeEvaluationAsyncRequest.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java new file mode 100644 index 00000000..1f10df03 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationAsyncRequest.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.resume.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ResumeEvaluationAsyncRequest( + @NotBlank + String resume, + + String portfolio, + + @NotBlank + String jobPosition, + + String jobDescription, + + @NotBlank + String jobCareer +) { + public ResumeEvaluationRequest toEvaluationRequest() { + return new ResumeEvaluationRequest(resume, portfolio, jobPosition, jobDescription, jobCareer); + } +} 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..92cf77b6 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java @@ -0,0 +1,35 @@ +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 ResumeEvaluationDetailResponse( + Long id, + ResumeEvaluationState state, + String resume, + String portfolio, + String jobPosition, + String jobDescription, + String jobCareer, + ResumeEvaluationResponse result, + LocalDateTime createdAt +) { + public static ResumeEvaluationDetailResponse from(ResumeEvaluation evaluation) { + ResumeEvaluationResponse result = evaluation.isCompleted() + ? ResumeEvaluationResponse.from(evaluation) + : null; + + return new ResumeEvaluationDetailResponse( + evaluation.getId(), + evaluation.getState(), + evaluation.getResume(), + evaluation.getPortfolio(), + evaluation.getJobPosition(), + evaluation.getJobDescription(), + evaluation.getJobCareer(), + result, + evaluation.getCreatedAt() + ); + } +} 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/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..570d3dc5 --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/resume/ResumeEvaluationFixtureBuilder.java @@ -0,0 +1,111 @@ +package com.samhap.kokomen.global.fixture.resume; + +import com.samhap.kokomen.member.domain.Member; +import com.samhap.kokomen.resume.domain.ResumeEvaluation; + +public class ResumeEvaluationFixtureBuilder { + + private Member member; + private String resume; + private String 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(String resume) { + this.resume = resume; + return this; + } + + public ResumeEvaluationFixtureBuilder portfolio(String 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 != null ? resume : "테스트 이력서 내용입니다. Java, Spring Boot 경험 3년.", + portfolio != null ? 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..b0f9acfc 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 @@ -4,13 +4,16 @@ import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; 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; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -18,17 +21,24 @@ 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.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.http.MediaType; 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; class CareerMaterialsControllerTest extends BaseControllerTest { @@ -40,6 +50,10 @@ class CareerMaterialsControllerTest extends BaseControllerTest { private MemberPortfolioRepository memberPortfolioRepository; @Autowired private MemberResumeRepository memberResumeRepository; + @Autowired + private ResumeEvaluationRepository resumeEvaluationRepository; + @MockitoBean + private ResumeEvaluationAsyncService resumeEvaluationAsyncService; @Test void 이력서_업로드_성공() throws Exception { @@ -137,4 +151,269 @@ 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()); + + String requestBody = """ + { + "resume": "Java, Spring Boot 경험 3년. 백엔드 개발자로서 RESTful API 설계 및 구현 경험이 있습니다.", + "portfolio": "GitHub: https://github.com/example", + "job_position": "백엔드 개발자", + "job_description": "Spring Boot 기반 백엔드 개발", + "job_career": "경력" + } + """; + + mockMvc.perform(post("/api/v1/resumes/evaluations") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .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 쿠키 (선택)") + ), + requestFields( + fieldWithPath("resume").description("이력서 텍스트"), + fieldWithPath("portfolio").description("포트폴리오 텍스트 (선택)").optional(), + fieldWithPath("job_position").description("지원 직무"), + fieldWithPath("job_description").description("직무 설명 (선택)").optional(), + fieldWithPath("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()); + 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}", + 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("portfolio").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("생성일시") + ) + )); + } } 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/ResumeEvaluation.java b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java new file mode 100644 index 00000000..d42fecb5 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java @@ -0,0 +1,161 @@ +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; + + @Column(name = "resume", nullable = false, columnDefinition = "TEXT") + private String resume; + + @Column(name = "portfolio", columnDefinition = "TEXT") + private String portfolio; + + @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, String resume, String portfolio, + String jobPosition, String jobDescription, String jobCareer) { + this.member = member; + this.state = ResumeEvaluationState.PENDING; + this.resume = resume; + this.portfolio = portfolio; + 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); + } +} 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/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); From 5167d01a70d2e12d3a2dd324aa5069578e79b938 Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:31:17 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[REFACTOR]=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=EC=84=9C=20=ED=8F=89=EA=B0=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/build.gradle | 3 + .../controller/CareerMaterialsController.java | 13 +++- .../resume/domain/PdfTextExtractor.java | 27 ++++++++ .../service/CareerMaterialsFacadeService.java | 69 ++++++++++++++----- .../dto/ResumeEvaluationAsyncRequest.java | 29 ++++---- .../CareerMaterialsControllerTest.java | 63 +++++++++++------ 6 files changed, 151 insertions(+), 53 deletions(-) create mode 100644 api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java 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/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java b/api/src/main/java/com/samhap/kokomen/resume/controller/CareerMaterialsController.java index de26a464..ffb4409f 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 @@ -21,10 +21,11 @@ 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.RequestBody; 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") @@ -50,11 +51,17 @@ public ResponseEntity getCareerMaterials( return ResponseEntity.ok(careerMaterialsFacadeService.getCareerMaterials(type, memberAuth)); } - @PostMapping("/evaluations") + @PostMapping(value = "/evaluations", consumes = {"multipart/form-data"}) public ResponseEntity submitResumeEvaluationAsync( - @RequestBody @Valid ResumeEvaluationAsyncRequest request, + @RequestPart(value = "resume") MultipartFile resume, + @RequestPart(value = "portfolio", required = false) MultipartFile portfolio, + @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 ) { + ResumeEvaluationAsyncRequest request = new ResumeEvaluationAsyncRequest( + resume, portfolio, jobPosition, jobDescription, jobCareer); return ResponseEntity.status(HttpStatus.ACCEPTED) .body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth)); } 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..66ec1a7a --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java @@ -0,0 +1,27 @@ +package com.samhap.kokomen.resume.domain; + +import com.samhap.kokomen.global.exception.BadRequestException; +import java.io.IOException; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class PdfTextExtractor { + + public String extractText(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + + try (PDDocument document = Loader.loadPDF(file.getBytes())) { + PDFTextStripper stripper = new PDFTextStripper(); + String text = stripper.getText(document); + return text.trim(); + } catch (IOException 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 1fc2939d..5ab004b2 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 @@ -6,6 +6,8 @@ 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.PdfTextExtractor; +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; @@ -39,6 +41,8 @@ public class CareerMaterialsFacadeService { private final ResumeEvaluationPersistenceService resumeEvaluationPersistenceService; private final ResumeEvaluationAsyncService resumeEvaluationAsyncService; private final RedisService redisService; + private final PdfValidator pdfValidator; + private final PdfTextExtractor pdfTextExtractor; @Transactional(readOnly = true) public CareerMaterialsResponse getCareerMaterials(CareerMaterialsType type, MemberAuth memberAuth) { @@ -78,36 +82,69 @@ public ResumeEvaluationResponse evaluateResume(ResumeEvaluationRequest request) @Transactional public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, MemberAuth memberAuth) { + validatePdfFiles(request); + String resumeText = extractResumeText(request); + String portfolioText = pdfTextExtractor.extractText(request.getPortfolio()); + + ResumeEvaluationRequest evaluationRequest = new ResumeEvaluationRequest( + resumeText, + portfolioText, + request.getJobPosition(), + request.getJobDescription(), + request.getJobCareer() + ); + if (memberAuth.isAuthenticated()) { - return submitMemberResumeEvaluationAsync(request, memberAuth); + return submitMemberResumeEvaluationAsync(request, memberAuth, evaluationRequest); + } + return submitNonMemberResumeEvaluationAsync(evaluationRequest); + } + + private void validatePdfFiles(ResumeEvaluationAsyncRequest request) { + pdfValidator.validate(request.getResume()); + if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { + pdfValidator.validate(request.getPortfolio()); + } + } + + private String extractResumeText(ResumeEvaluationAsyncRequest request) { + String resumeText = pdfTextExtractor.extractText(request.getResume()); + if (resumeText == null || resumeText.isBlank()) { + throw new BadRequestException("이력서 PDF에서 텍스트를 추출할 수 없습니다."); } - return submitNonMemberResumeEvaluationAsync(request); + return resumeText; } private ResumeEvaluationSubmitResponse submitMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, - MemberAuth memberAuth) { + MemberAuth memberAuth, + ResumeEvaluationRequest evaluationRequest) { Member member = memberService.readById(memberAuth.memberId()); ResumeEvaluation evaluation = new ResumeEvaluation( member, - request.resume(), - request.portfolio(), - request.jobPosition(), - request.jobDescription(), - request.jobCareer() - ); - ResumeEvaluation savedEvaluation = resumeEvaluationPersistenceService.saveEvaluation(evaluation); - - resumeEvaluationAsyncService.evaluateMemberAsync( - savedEvaluation.getId(), - request.toEvaluationRequest() + evaluationRequest.resume(), + evaluationRequest.portfolio(), + request.getJobPosition(), + request.getJobDescription(), + request.getJobCareer() ); + ResumeEvaluation savedEvaluation = resumeEvaluationPersistenceService.saveEvaluation(evaluation); + uploadEvaluationPdfsToS3(request, member); + resumeEvaluationAsyncService.evaluateMemberAsync(savedEvaluation.getId(), evaluationRequest); return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); } - private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request) { + private void uploadEvaluationPdfsToS3(ResumeEvaluationAsyncRequest request, Member member) { + resumeService.saveResume(request.getResume(), member); + if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { + portfolioService.savePortfolio(request.getPortfolio(), member); + } + } + + private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync( + ResumeEvaluationRequest evaluationRequest) { String uuid = UUID.randomUUID().toString(); - resumeEvaluationAsyncService.evaluateNonMemberAsync(uuid, request.toEvaluationRequest()); + resumeEvaluationAsyncService.evaluateNonMemberAsync(uuid, evaluationRequest); return ResumeEvaluationSubmitResponse.fromUuid(uuid); } 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 index 1f10df03..7ad69fd7 100644 --- 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 @@ -1,22 +1,25 @@ package com.samhap.kokomen.resume.service.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; -public record ResumeEvaluationAsyncRequest( - @NotBlank - String resume, +@Getter +@AllArgsConstructor +public class ResumeEvaluationAsyncRequest { - String portfolio, + @NotNull(message = "이력서 파일은 필수입니다.") + private MultipartFile resume; - @NotBlank - String jobPosition, + private MultipartFile portfolio; - String jobDescription, + @NotBlank(message = "직무는 필수입니다.") + private String jobPosition; - @NotBlank - String jobCareer -) { - public ResumeEvaluationRequest toEvaluationRequest() { - return new ResumeEvaluationRequest(resume, portfolio, jobPosition, jobDescription, jobCareer); - } + private String jobDescription; + + @NotBlank(message = "경력은 필수입니다.") + private String jobCareer; } 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 b0f9acfc..ca584735 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,10 +1,12 @@ 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; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.partWithName; @@ -13,7 +15,6 @@ import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -25,6 +26,8 @@ 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.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; @@ -34,11 +37,11 @@ import com.samhap.kokomen.token.repository.TokenRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; 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 { @@ -54,6 +57,10 @@ class CareerMaterialsControllerTest extends BaseControllerTest { private ResumeEvaluationRepository resumeEvaluationRepository; @MockitoBean private ResumeEvaluationAsyncService resumeEvaluationAsyncService; + @MockitoBean + private PdfValidator pdfValidator; + @MockitoBean + private PdfTextExtractor pdfTextExtractor; @Test void 이력서_업로드_성공() throws Exception { @@ -162,19 +169,33 @@ class CareerMaterialsControllerTest extends BaseControllerTest { MockHttpSession session = new MockHttpSession(); session.setAttribute("MEMBER_ID", member.getId()); - String requestBody = """ - { - "resume": "Java, Spring Boot 경험 3년. 백엔드 개발자로서 RESTful API 설계 및 구현 경험이 있습니다.", - "portfolio": "GitHub: https://github.com/example", - "job_position": "백엔드 개발자", - "job_description": "Spring Boot 기반 백엔드 개발", - "job_career": "경력" - } - """; + 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(post("/api/v1/resumes/evaluations") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + 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) ) @@ -184,12 +205,12 @@ class CareerMaterialsControllerTest extends BaseControllerTest { requestHeaders( headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키 (선택)") ), - requestFields( - fieldWithPath("resume").description("이력서 텍스트"), - fieldWithPath("portfolio").description("포트폴리오 텍스트 (선택)").optional(), - fieldWithPath("job_position").description("지원 직무"), - fieldWithPath("job_description").description("직무 설명 (선택)").optional(), - fieldWithPath("job_career").description("경력 구분 (신입/경력)") + 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 형식)") From e9115fe5ee7a46e10fce451a11b37321a5cbd503 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 9 Dec 2025 12:35:12 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20=EC=9D=B4=EC=8A=88=20=EB=B0=8F=20?= =?UTF-8?q?pr=20=ED=85=9C=ED=94=8C=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .../{default-request.md => feat-request.md} | 7 ++----- .github/ISSUE_TEMPLATE/refactor-report.md | 12 ++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 5 ++--- 4 files changed, 17 insertions(+), 9 deletions(-) rename .github/ISSUE_TEMPLATE/{default-request.md => feat-request.md} (60%) create mode 100644 .github/ISSUE_TEMPLATE/refactor-report.md 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 # 스크린샷 From aea0571e3793ee0108510a3ff0e1263eef18b0bb Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 9 Dec 2025 12:41:29 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20api=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/docs/asciidoc/index.adoc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index 5d85b7c7..76ea0e5f 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -580,8 +580,7 @@ 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-body.adoc[] -include::{snippetsDir}/resume-evaluation-async-submit/request-fields.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[] From c9cb79ae76bf274479fd55b59b1407c6cf3ec9ec Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:09:17 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[REFACTOR]=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=EC=84=9C=20=ED=8F=89=EA=B0=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/CareerMaterialsPathResolver.java | 13 +- .../resume/domain/PdfTextExtractor.java | 54 +++++++- .../service/CareerMaterialsFacadeService.java | 117 ++++++----------- .../service/CareerMaterialsService.java | 62 +++++++++ .../resume/service/PdfUploadService.java | 95 ++++++++++++++ .../resume/service/PortfolioService.java | 58 --------- .../service/ResumeEvaluationAsyncService.java | 118 ++++++++++++++++-- .../ResumeEvaluationPersistenceService.java | 64 ---------- .../service/ResumeEvaluationService.java | 63 ++++++++++ .../kokomen/resume/service/ResumeService.java | 58 --------- .../service/dto/TextExtractionResult.java | 15 +++ .../resume/MemberPortfolioFixtureBuilder.java | 11 +- .../resume/MemberResumeFixtureBuilder.java | 9 +- .../resume/domain/MemberPortfolio.java | 20 ++- .../kokomen/resume/domain/MemberResume.java | 20 ++- .../resume/domain/ResumeEvaluation.java | 7 +- .../V30__add_content_to_career_materials.sql | 5 + ...dify_resume_evaluation_resume_nullable.sql | 2 + 18 files changed, 508 insertions(+), 283 deletions(-) create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java delete mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/PortfolioService.java delete mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java delete mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/ResumeService.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/TextExtractionResult.java create mode 100644 common/src/main/resources/db/migration/V30__add_content_to_career_materials.sql create mode 100644 common/src/main/resources/db/migration/V31__modify_resume_evaluation_resume_nullable.sql 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 index 66ec1a7a..1dae4062 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java +++ b/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java @@ -2,26 +2,70 @@ 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 (PDDocument document = Loader.loadPDF(file.getBytes())) { - PDFTextStripper stripper = new PDFTextStripper(); - String text = stripper.getText(document); - return text.trim(); + try { + if (file.getSize() <= MEMORY_THRESHOLD) { + return extractTextFromMemory(file); + } + return extractTextFromStream(file); } catch (IOException e) { - throw new BadRequestException("PDF 파일에서 텍스트를 추출할 수 없습니다."); + 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(); + } } 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 5ab004b2..a9454be3 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 @@ -6,7 +6,6 @@ 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.PdfTextExtractor; import com.samhap.kokomen.resume.domain.PdfValidator; import com.samhap.kokomen.resume.domain.ResumeEvaluation; import com.samhap.kokomen.resume.service.dto.CareerMaterialsResponse; @@ -34,70 +33,30 @@ public class CareerMaterialsFacadeService { private static final String UUID_PREFIX = "uuid-"; - private final ResumeService resumeService; - private final PortfolioService portfolioService; + private final CareerMaterialsService careerMaterialsService; private final MemberService memberService; private final ResumeEvaluationService resumeEvaluationService; - private final ResumeEvaluationPersistenceService resumeEvaluationPersistenceService; private final ResumeEvaluationAsyncService resumeEvaluationAsyncService; private final RedisService redisService; private final PdfValidator pdfValidator; - private final PdfTextExtractor pdfTextExtractor; + private final PdfUploadService pdfUploadService; @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()) - ); - }; - } - - @Transactional - public void saveCareerMaterials(ResumeSaveRequest request, MemberAuth memberAuth) { - Member member = memberService.readById(memberAuth.memberId()); - resumeService.saveResume(request.resume(), member); - if (request.portfolio() != null) { - portfolioService.savePortfolio(request.portfolio(), member); - } - } - - @Transactional - public ResumeEvaluationResponse evaluateResume(ResumeEvaluationRequest request) { - return resumeEvaluationService.evaluate(request); + return careerMaterialsService.getCareerMaterials(type, memberAuth); } @Transactional - public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, - MemberAuth memberAuth) { + public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync( + ResumeEvaluationAsyncRequest request, + MemberAuth memberAuth + ) { validatePdfFiles(request); - String resumeText = extractResumeText(request); - String portfolioText = pdfTextExtractor.extractText(request.getPortfolio()); - - ResumeEvaluationRequest evaluationRequest = new ResumeEvaluationRequest( - resumeText, - portfolioText, - request.getJobPosition(), - request.getJobDescription(), - request.getJobCareer() - ); if (memberAuth.isAuthenticated()) { - return submitMemberResumeEvaluationAsync(request, memberAuth, evaluationRequest); + return submitMemberResumeEvaluationAsync(request, memberAuth); } - return submitNonMemberResumeEvaluationAsync(evaluationRequest); + return submitNonMemberResumeEvaluationAsync(request); } private void validatePdfFiles(ResumeEvaluationAsyncRequest request) { @@ -107,44 +66,30 @@ private void validatePdfFiles(ResumeEvaluationAsyncRequest request) { } } - private String extractResumeText(ResumeEvaluationAsyncRequest request) { - String resumeText = pdfTextExtractor.extractText(request.getResume()); - if (resumeText == null || resumeText.isBlank()) { - throw new BadRequestException("이력서 PDF에서 텍스트를 추출할 수 없습니다."); - } - return resumeText; - } - private ResumeEvaluationSubmitResponse submitMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, - MemberAuth memberAuth, - ResumeEvaluationRequest evaluationRequest) { + MemberAuth memberAuth) { Member member = memberService.readById(memberAuth.memberId()); ResumeEvaluation evaluation = new ResumeEvaluation( member, - evaluationRequest.resume(), - evaluationRequest.portfolio(), + null, + null, request.getJobPosition(), request.getJobDescription(), request.getJobCareer() ); + ResumeEvaluation savedEvaluation = resumeEvaluationService.saveEvaluation(evaluation); - ResumeEvaluation savedEvaluation = resumeEvaluationPersistenceService.saveEvaluation(evaluation); - uploadEvaluationPdfsToS3(request, member); - resumeEvaluationAsyncService.evaluateMemberAsync(savedEvaluation.getId(), evaluationRequest); + resumeEvaluationAsyncService.processAndEvaluateMemberAsync( + savedEvaluation.getId(), + member, + request + ); return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); } - private void uploadEvaluationPdfsToS3(ResumeEvaluationAsyncRequest request, Member member) { - resumeService.saveResume(request.getResume(), member); - if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { - portfolioService.savePortfolio(request.getPortfolio(), member); - } - } - - private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync( - ResumeEvaluationRequest evaluationRequest) { + private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request) { String uuid = UUID.randomUUID().toString(); - resumeEvaluationAsyncService.evaluateNonMemberAsync(uuid, evaluationRequest); + resumeEvaluationAsyncService.processAndEvaluateNonMemberAsync(uuid, request); return ResumeEvaluationSubmitResponse.fromUuid(uuid); } @@ -183,7 +128,7 @@ private ResumeEvaluationStateResponse convertToStateResponse(NonMemberResumeEval private ResumeEvaluationStateResponse findMemberResumeEvaluationState(String evaluationId, MemberAuth memberAuth) { Long id = parseMemberEvaluationId(evaluationId); - ResumeEvaluation evaluation = resumeEvaluationPersistenceService.readById(id); + ResumeEvaluation evaluation = resumeEvaluationService.readById(id); validateEvaluationOwner(evaluation, memberAuth.memberId()); return ResumeEvaluationStateResponse.from(evaluation); } @@ -198,7 +143,7 @@ private Long parseMemberEvaluationId(String evaluationId) { @Transactional(readOnly = true) public ResumeEvaluationHistoryResponses findResumeEvaluationHistory(MemberAuth memberAuth, Pageable pageable) { - Page evaluationPage = resumeEvaluationPersistenceService + Page evaluationPage = resumeEvaluationService .findByMemberId(memberAuth.memberId(), pageable); List evaluations = evaluationPage.stream() @@ -214,7 +159,7 @@ public ResumeEvaluationHistoryResponses findResumeEvaluationHistory(MemberAuth m @Transactional(readOnly = true) public ResumeEvaluationDetailResponse findResumeEvaluationDetail(Long evaluationId, MemberAuth memberAuth) { - ResumeEvaluation evaluation = resumeEvaluationPersistenceService.readById(evaluationId); + ResumeEvaluation evaluation = resumeEvaluationService.readById(evaluationId); validateEvaluationOwner(evaluation, memberAuth.memberId()); return ResumeEvaluationDetailResponse.from(evaluation); } @@ -224,4 +169,20 @@ private void validateEvaluationOwner(ResumeEvaluation evaluation, Long 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..0692b59e --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java @@ -0,0 +1,62 @@ +package com.samhap.kokomen.resume.service; + +import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.resume.domain.CareerMaterialsType; +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(); + } +} 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..49bf5c13 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java @@ -0,0 +1,95 @@ +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; + + @Async + @Transactional + public void 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); + memberPortfolioRepository.save(memberPortfolio); + + uploadToS3IfNotExists(s3Key, portfolio); + } + + // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 + @Async + @Transactional + public void savePortfolio(MultipartFile portfolio, Member member) { + String content = extractTextSafely(portfolio); + savePortfolio(portfolio, member, content); + } + + @Async + @Transactional + public void 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); + memberResumeRepository.save(memberResume); + + uploadToS3IfNotExists(s3Key, resume); + } + + // 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("파일 업로드에 실패했습니다."); + } + } +} 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 index c926f3bd..9d92cc46 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -4,18 +4,24 @@ import com.fasterxml.jackson.databind.ObjectMapper; 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.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.ResumeEvaluationAsyncRequest; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationRequest; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; +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 org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient; import software.amazon.awssdk.services.bedrockagentruntime.model.FlowOutputEvent; import software.amazon.awssdk.services.bedrockagentruntime.model.FlowResponseStream; @@ -29,31 +35,127 @@ public class ResumeEvaluationAsyncService { private static final String REDIS_KEY_PREFIX = "resume:evaluation:nonmember:"; private static final Duration REDIS_TTL = Duration.ofMinutes(5); - private final ResumeEvaluationPersistenceService resumeEvaluationPersistenceService; + private final ResumeEvaluationService resumeEvaluationService; + private final PdfUploadService pdfUploadService; private final RedisService redisService; private final BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient; private final ResumeGptClient resumeGptClient; + private final PdfTextExtractor pdfTextExtractor; private final ObjectMapper objectMapper; private final ThreadPoolTaskExecutor executor; public ResumeEvaluationAsyncService( - ResumeEvaluationPersistenceService resumeEvaluationPersistenceService, + ResumeEvaluationService resumeEvaluationService, + PdfUploadService pdfUploadService, RedisService redisService, BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient, ResumeGptClient resumeGptClient, + PdfTextExtractor pdfTextExtractor, ObjectMapper objectMapper, @Qualifier("resumeEvaluationExecutor") ThreadPoolTaskExecutor executor ) { - this.resumeEvaluationPersistenceService = resumeEvaluationPersistenceService; + this.resumeEvaluationService = resumeEvaluationService; + this.pdfUploadService = pdfUploadService; this.redisService = redisService; this.bedrockAgentRuntimeAsyncClient = bedrockAgentRuntimeAsyncClient; this.resumeGptClient = resumeGptClient; + this.pdfTextExtractor = pdfTextExtractor; this.objectMapper = objectMapper; this.executor = executor; } - public void evaluateMemberAsync(Long evaluationId, ResumeEvaluationRequest request) { + public void processAndEvaluateMemberAsync(Long evaluationId, Member member, + ResumeEvaluationAsyncRequest request) { + Map mdcContext = MDC.getCopyOfContextMap(); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + TextExtractionResult extraction = extractTexts(request); + + if (!extraction.hasResumeText()) { + log.error("이력서 텍스트 추출 실패 - evaluationId: {}", evaluationId); + resumeEvaluationService.updateFailed(evaluationId); + return; + } + + pdfUploadService.saveResume(request.getResume(), member, extraction.resumeText()); + if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { + pdfUploadService.savePortfolio(request.getPortfolio(), member, extraction.portfolioText()); + } + + resumeEvaluationService.updateResumeText(evaluationId, + extraction.resumeText(), extraction.portfolioText()); + + ResumeEvaluationRequest evalRequest = new ResumeEvaluationRequest( + extraction.resumeText(), extraction.portfolioText(), + request.getJobPosition(), request.getJobDescription(), request.getJobCareer() + ); + evaluateMemberAsync(evaluationId, evalRequest); + } catch (Exception e) { + log.error("회원 이력서 평가 처리 실패 - evaluationId: {}", evaluationId, e); + resumeEvaluationService.updateFailed(evaluationId); + } finally { + MDC.clear(); + } + }); + } + + public void processAndEvaluateNonMemberAsync(String uuid, ResumeEvaluationAsyncRequest request) { + String redisKey = createRedisKey(uuid); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.pending(null), REDIS_TTL); + + Map mdcContext = MDC.getCopyOfContextMap(); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + TextExtractionResult extraction = extractTexts(request); + + if (!extraction.hasResumeText()) { + log.error("이력서 텍스트 추출 실패 - uuid: {}", uuid); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(null), REDIS_TTL); + return; + } + + ResumeEvaluationRequest evaluationRequest = new ResumeEvaluationRequest( + extraction.resumeText(), extraction.portfolioText(), + request.getJobPosition(), request.getJobDescription(), request.getJobCareer() + ); + evaluateNonMemberAsync(uuid, evaluationRequest); + } catch (Exception e) { + log.error("비회원 이력서 평가 처리 실패 - uuid: {}", uuid, e); + redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(null), REDIS_TTL); + } finally { + MDC.clear(); + } + }); + } + + private TextExtractionResult extractTexts(ResumeEvaluationAsyncRequest request) { + CompletableFuture resumeFuture = CompletableFuture.supplyAsync( + () -> extractTextSafely(request.getResume()), executor); + + CompletableFuture portfolioFuture = CompletableFuture.supplyAsync(() -> { + MultipartFile portfolio = request.getPortfolio(); + if (portfolio == null || portfolio.isEmpty()) { + return null; + } + return extractTextSafely(portfolio); + }, executor); + + return resumeFuture.thenCombine(portfolioFuture, TextExtractionResult::of).join(); + } + + private String extractTextSafely(MultipartFile file) { + try { + return pdfTextExtractor.extractText(file); + } 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); @@ -82,7 +184,7 @@ private void handleMemberBedrockResponse(FlowResponseStream event, Long evaluati if (event instanceof FlowOutputEvent outputEvent) { String jsonPayload = outputEvent.content().document().toString(); ResumeEvaluationResponse response = parseResponse(jsonPayload); - resumeEvaluationPersistenceService.updateCompleted(evaluationId, response); + resumeEvaluationService.updateCompleted(evaluationId, response); } } catch (Exception e) { log.error("Bedrock 응답 처리 실패, GPT 폴백 시도 - evaluationId: {}", evaluationId, e); @@ -110,17 +212,17 @@ private void fallbackToGptForMember(Long evaluationId, ResumeEvaluationRequest r setMdcContext(mdcContext); String jsonResponse = resumeGptClient.requestResumeEvaluation(request); ResumeEvaluationResponse response = parseResponse(jsonResponse); - resumeEvaluationPersistenceService.updateCompleted(evaluationId, response); + resumeEvaluationService.updateCompleted(evaluationId, response); } catch (Exception e) { log.error("GPT 폴백 실패 - evaluationId: {}", evaluationId, e); - resumeEvaluationPersistenceService.updateFailed(evaluationId); + resumeEvaluationService.updateFailed(evaluationId); } finally { MDC.clear(); } }); } - public void evaluateNonMemberAsync(String uuid, ResumeEvaluationRequest request) { + private void evaluateNonMemberAsync(String uuid, ResumeEvaluationRequest request) { Map mdcContext = MDC.getCopyOfContextMap(); String redisKey = createRedisKey(uuid); InvokeFlowRequest flowRequest = ResumeInvokeFlowRequestFactory.createResumeEvaluationFlowRequest(request); diff --git a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java deleted file mode 100644 index 7a92dade..00000000 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationPersistenceService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.samhap.kokomen.resume.service; - -import com.samhap.kokomen.global.exception.BadRequestException; -import com.samhap.kokomen.resume.domain.ResumeEvaluation; -import com.samhap.kokomen.resume.repository.ResumeEvaluationRepository; -import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; -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; - -@RequiredArgsConstructor -@Service -public class ResumeEvaluationPersistenceService { - - private final ResumeEvaluationRepository resumeEvaluationRepository; - - @Transactional - 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 = readById(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 updateFailed(Long evaluationId) { - ResumeEvaluation evaluation = readById(evaluationId); - evaluation.fail(); - } -} 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..4289d191 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,21 @@ 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.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.Transactional; import software.amazon.awssdk.services.bedrockagentruntime.model.InvokeFlowRequest; @Slf4j @@ -18,10 +24,67 @@ @Service public class ResumeEvaluationService { + private final ResumeEvaluationRepository resumeEvaluationRepository; private final BedrockFlowClient bedrockFlowClient; private final ResumeGptClient resumeGptClient; private final ObjectMapper objectMapper; + @Transactional + 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 updateResumeText(Long evaluationId, String resumeText, String portfolioText) { + ResumeEvaluation evaluation = resumeEvaluationRepository.findById(evaluationId) + .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + evaluationId)); + evaluation.updateResumeText(resumeText, portfolioText); + } + + @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/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/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 index d42fecb5..56a63c10 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java @@ -39,7 +39,7 @@ public class ResumeEvaluation extends BaseEntity { @Column(name = "state", nullable = false, length = 20) private ResumeEvaluationState state; - @Column(name = "resume", nullable = false, columnDefinition = "TEXT") + @Column(name = "resume", columnDefinition = "TEXT") private String resume; @Column(name = "portfolio", columnDefinition = "TEXT") @@ -158,4 +158,9 @@ public boolean isPending() { public boolean isOwner(Long memberId) { return this.member.isOwner(memberId); } + + public void updateResumeText(String resume, String portfolio) { + this.resume = resume; + this.portfolio = portfolio; + } } 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; From c11082e6a7147566c8e7e7f0db0b98c1ab9e6aac Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 9 Dec 2025 22:06:01 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20Buffer=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/samhap/kokomen/resume/service/PdfUploadService.java | 2 -- 1 file changed, 2 deletions(-) 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 index 49bf5c13..47549c27 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java @@ -29,7 +29,6 @@ public class PdfUploadService { private final MemberResumeRepository memberResumeRepository; private final S3Service s3Service; - @Async @Transactional public void savePortfolio(MultipartFile portfolio, Member member, String content) { pdfValidator.validate(portfolio); @@ -51,7 +50,6 @@ public void savePortfolio(MultipartFile portfolio, Member member) { savePortfolio(portfolio, member, content); } - @Async @Transactional public void saveResume(MultipartFile resume, Member member, String content) { pdfValidator.validate(resume); From 4284a2a786148df8210cfc25f3bf6d5608909807 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 9 Dec 2025 22:54:30 +0900 Subject: [PATCH 07/13] =?UTF-8?q?refactor:=20Buffer=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resume/domain/PdfTextExtractor.java | 13 ++++++ .../service/CareerMaterialsFacadeService.java | 33 ++++++++++++- .../resume/service/PdfUploadService.java | 37 +++++++++++++++ .../service/ResumeEvaluationAsyncService.java | 46 +++++++++++-------- .../resume/service/dto/ResumeFileData.java | 21 +++++++++ 5 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeFileData.java 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 index 1dae4062..cd62392a 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java +++ b/api/src/main/java/com/samhap/kokomen/resume/domain/PdfTextExtractor.java @@ -68,4 +68,17 @@ private String extractText(PDDocument document) throws IOException { 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 a9454be3..d1f3a23c 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 @@ -18,9 +18,12 @@ 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 org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -79,20 +82,46 @@ private ResumeEvaluationSubmitResponse submitMemberResumeEvaluationAsync(ResumeE ); ResumeEvaluation savedEvaluation = resumeEvaluationService.saveEvaluation(evaluation); + ResumeFileData resumeFileData = createResumeFileData(request.getResume()); + ResumeFileData portfolioFileData = createResumeFileData(request.getPortfolio()); + resumeEvaluationAsyncService.processAndEvaluateMemberAsync( savedEvaluation.getId(), member, - request + resumeFileData, + portfolioFileData, + request.getJobPosition(), + request.getJobDescription(), + request.getJobCareer() ); return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); } private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request) { String uuid = UUID.randomUUID().toString(); - resumeEvaluationAsyncService.processAndEvaluateNonMemberAsync(uuid, request); + + ResumeFileData resumeFileData = createResumeFileData(request.getResume()); + ResumeFileData portfolioFileData = createResumeFileData(request.getPortfolio()); + + resumeEvaluationAsyncService.processAndEvaluateNonMemberAsync( + uuid, + resumeFileData, + portfolioFileData, + request.getJobPosition(), + request.getJobDescription(), + request.getJobCareer() + ); return ResumeEvaluationSubmitResponse.fromUuid(uuid); } + 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)) { 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 index 47549c27..0f78c993 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java @@ -90,4 +90,41 @@ private void uploadToS3IfNotExists(String s3Key, MultipartFile file) { throw new BadRequestException("파일 업로드에 실패했습니다."); } } + + @Transactional + public void 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); + memberResumeRepository.save(memberResume); + + uploadToS3IfNotExists(s3Key, resumeData); + } + + @Transactional + public void 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); + memberPortfolioRepository.save(memberPortfolio); + + uploadToS3IfNotExists(s3Key, portfolioData); + } + + 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/ResumeEvaluationAsyncService.java b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java index 9d92cc46..66cedf3f 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -9,7 +9,7 @@ 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.ResumeEvaluationAsyncRequest; +import com.samhap.kokomen.resume.service.dto.ResumeFileData; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationRequest; import com.samhap.kokomen.resume.service.dto.ResumeEvaluationResponse; import com.samhap.kokomen.resume.service.dto.TextExtractionResult; @@ -21,7 +21,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; + import software.amazon.awssdk.services.bedrockagentruntime.BedrockAgentRuntimeAsyncClient; import software.amazon.awssdk.services.bedrockagentruntime.model.FlowOutputEvent; import software.amazon.awssdk.services.bedrockagentruntime.model.FlowResponseStream; @@ -66,12 +66,16 @@ public ResumeEvaluationAsyncService( } public void processAndEvaluateMemberAsync(Long evaluationId, Member member, - ResumeEvaluationAsyncRequest request) { + ResumeFileData resumeFileData, + ResumeFileData portfolioFileData, + String jobPosition, + String jobDescription, + String jobCareer) { Map mdcContext = MDC.getCopyOfContextMap(); executor.execute(() -> { try { setMdcContext(mdcContext); - TextExtractionResult extraction = extractTexts(request); + TextExtractionResult extraction = extractTexts(resumeFileData, portfolioFileData); if (!extraction.hasResumeText()) { log.error("이력서 텍스트 추출 실패 - evaluationId: {}", evaluationId); @@ -79,9 +83,11 @@ public void processAndEvaluateMemberAsync(Long evaluationId, Member member, return; } - pdfUploadService.saveResume(request.getResume(), member, extraction.resumeText()); - if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { - pdfUploadService.savePortfolio(request.getPortfolio(), member, extraction.portfolioText()); + pdfUploadService.saveResume(resumeFileData.content(), resumeFileData.filename(), + member, extraction.resumeText()); + if (portfolioFileData != null && !portfolioFileData.isEmpty()) { + pdfUploadService.savePortfolio(portfolioFileData.content(), portfolioFileData.filename(), + member, extraction.portfolioText()); } resumeEvaluationService.updateResumeText(evaluationId, @@ -89,7 +95,7 @@ public void processAndEvaluateMemberAsync(Long evaluationId, Member member, ResumeEvaluationRequest evalRequest = new ResumeEvaluationRequest( extraction.resumeText(), extraction.portfolioText(), - request.getJobPosition(), request.getJobDescription(), request.getJobCareer() + jobPosition, jobDescription, jobCareer ); evaluateMemberAsync(evaluationId, evalRequest); } catch (Exception e) { @@ -101,7 +107,12 @@ public void processAndEvaluateMemberAsync(Long evaluationId, Member member, }); } - public void processAndEvaluateNonMemberAsync(String uuid, ResumeEvaluationAsyncRequest request) { + public void processAndEvaluateNonMemberAsync(String uuid, + ResumeFileData resumeFileData, + ResumeFileData portfolioFileData, + String jobPosition, + String jobDescription, + String jobCareer) { String redisKey = createRedisKey(uuid); redisService.setValue(redisKey, NonMemberResumeEvaluationData.pending(null), REDIS_TTL); @@ -109,7 +120,7 @@ public void processAndEvaluateNonMemberAsync(String uuid, ResumeEvaluationAsyncR executor.execute(() -> { try { setMdcContext(mdcContext); - TextExtractionResult extraction = extractTexts(request); + TextExtractionResult extraction = extractTexts(resumeFileData, portfolioFileData); if (!extraction.hasResumeText()) { log.error("이력서 텍스트 추출 실패 - uuid: {}", uuid); @@ -119,7 +130,7 @@ public void processAndEvaluateNonMemberAsync(String uuid, ResumeEvaluationAsyncR ResumeEvaluationRequest evaluationRequest = new ResumeEvaluationRequest( extraction.resumeText(), extraction.portfolioText(), - request.getJobPosition(), request.getJobDescription(), request.getJobCareer() + jobPosition, jobDescription, jobCareer ); evaluateNonMemberAsync(uuid, evaluationRequest); } catch (Exception e) { @@ -131,24 +142,23 @@ public void processAndEvaluateNonMemberAsync(String uuid, ResumeEvaluationAsyncR }); } - private TextExtractionResult extractTexts(ResumeEvaluationAsyncRequest request) { + private TextExtractionResult extractTexts(ResumeFileData resumeFileData, ResumeFileData portfolioFileData) { CompletableFuture resumeFuture = CompletableFuture.supplyAsync( - () -> extractTextSafely(request.getResume()), executor); + () -> extractTextSafely(resumeFileData), executor); CompletableFuture portfolioFuture = CompletableFuture.supplyAsync(() -> { - MultipartFile portfolio = request.getPortfolio(); - if (portfolio == null || portfolio.isEmpty()) { + if (portfolioFileData == null || portfolioFileData.isEmpty()) { return null; } - return extractTextSafely(portfolio); + return extractTextSafely(portfolioFileData); }, executor); return resumeFuture.thenCombine(portfolioFuture, TextExtractionResult::of).join(); } - private String extractTextSafely(MultipartFile file) { + private String extractTextSafely(ResumeFileData fileData) { try { - return pdfTextExtractor.extractText(file); + return pdfTextExtractor.extractText(fileData.content()); } catch (Exception e) { log.error("PDF 텍스트 추출 실패", e); return null; 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; + } +} From e3315076ba9778302f6d68fb112a0328dc807769 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Tue, 9 Dec 2025 23:05:30 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20bucket=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/samhap/kokomen/global/service/S3Service.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..b1d8dc39 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 @@ -14,7 +14,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; From cbe11955416b0b3d16723071be05037be8ab5de9 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 10 Dec 2025 14:06:53 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20kokomen=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=8A=A4=ED=83=9D=20=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/GlobalExceptionHandler.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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("서버에 문제가 발생하였습니다.")); } From 2ddfaaf5fd5e6ece95e348624c7fbee1d238784a Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 10 Dec 2025 14:27:37 +0900 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20redis=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CareerMaterialsFacadeService.java | 16 +++++++++-- .../service/ResumeEvaluationAsyncService.java | 27 ++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) 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 d1f3a23c..de994911 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,5 +1,7 @@ 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; @@ -23,12 +25,12 @@ import java.io.IOException; import java.util.List; import java.util.UUID; -import org.springframework.web.multipart.MultipartFile; 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 @@ -43,6 +45,7 @@ public class CareerMaterialsFacadeService { 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) { @@ -138,7 +141,8 @@ private ResumeEvaluationStateResponse findNonMemberResumeEvaluationState(String String uuid = extractUuid(evaluationId); String redisKey = ResumeEvaluationAsyncService.createRedisKey(uuid); - return redisService.get(redisKey, NonMemberResumeEvaluationData.class) + return redisService.get(redisKey, String.class) + .map(this::parseNonMemberEvaluationData) .map(this::convertToStateResponse) .orElseThrow(() -> new BadRequestException("이력서 평가 결과를 찾을 수 없습니다. 만료되었거나 존재하지 않는 ID입니다.")); } @@ -147,6 +151,14 @@ 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(); 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 index 66cedf3f..d20d3adf 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -9,9 +9,9 @@ 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.ResumeFileData; 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; @@ -21,7 +21,6 @@ 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; @@ -114,7 +113,7 @@ public void processAndEvaluateNonMemberAsync(String uuid, String jobDescription, String jobCareer) { String redisKey = createRedisKey(uuid); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.pending(null), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.pending(null)); Map mdcContext = MDC.getCopyOfContextMap(); executor.execute(() -> { @@ -124,7 +123,7 @@ public void processAndEvaluateNonMemberAsync(String uuid, if (!extraction.hasResumeText()) { log.error("이력서 텍스트 추출 실패 - uuid: {}", uuid); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(null), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.failed(null)); return; } @@ -135,7 +134,7 @@ public void processAndEvaluateNonMemberAsync(String uuid, evaluateNonMemberAsync(uuid, evaluationRequest); } catch (Exception e) { log.error("비회원 이력서 평가 처리 실패 - uuid: {}", uuid, e); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(null), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.failed(null)); } finally { MDC.clear(); } @@ -237,7 +236,7 @@ private void evaluateNonMemberAsync(String uuid, ResumeEvaluationRequest request String redisKey = createRedisKey(uuid); InvokeFlowRequest flowRequest = ResumeInvokeFlowRequestFactory.createResumeEvaluationFlowRequest(request); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.pending(request), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.pending(request)); bedrockAgentRuntimeAsyncClient.invokeFlow( flowRequest, @@ -265,7 +264,7 @@ private void handleNonMemberBedrockResponse(FlowResponseStream event, String uui if (event instanceof FlowOutputEvent outputEvent) { String jsonPayload = outputEvent.content().document().toString(); ResumeEvaluationResponse response = parseResponse(jsonPayload); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.completed(request, response), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.completed(request, response)); } } catch (Exception e) { log.error("Bedrock 응답 처리 실패, GPT 폴백 시도 - uuid: {}", uuid, e); @@ -294,16 +293,26 @@ private void fallbackToGptForNonMember(String uuid, ResumeEvaluationRequest requ setMdcContext(mdcContext); String jsonResponse = resumeGptClient.requestResumeEvaluation(request); ResumeEvaluationResponse response = parseResponse(jsonResponse); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.completed(request, response), REDIS_TTL); + saveNonMemberDataToRedis(redisKey, NonMemberResumeEvaluationData.completed(request, response)); } catch (Exception e) { log.error("GPT 폴백 실패 - uuid: {}", uuid, e); - redisService.setValue(redisKey, NonMemberResumeEvaluationData.failed(request), REDIS_TTL); + 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); From 86cce87325673ebe55eca7f395809229b8d51cae Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:09:53 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[FEAT]=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EC=84=9C=EB=A1=9C=20=ED=8F=89=EA=B0=80=20API?= =?UTF-8?q?=20(#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/docs/asciidoc/index.adoc | 24 +++- .../kokomen/global/service/S3Service.java | 26 ++++ .../controller/CareerMaterialsController.java | 11 ++ .../service/CareerMaterialsFacadeService.java | 43 ++++++ .../service/CareerMaterialsService.java | 13 ++ .../resume/service/PdfUploadService.java | 20 +-- .../service/ResumeEvaluationAsyncService.java | 66 +++++++++- .../service/ResumeEvaluationService.java | 6 +- .../resume/service/dto/PortfolioInfo.java | 4 + .../dto/ResumeEvaluationDetailResponse.java | 37 ++++-- .../resume/service/dto/ResumeInfo.java | 4 + .../SavedResumeEvaluationAsyncRequest.java | 20 +++ .../ResumeEvaluationFixtureBuilder.java | 14 +- .../CareerMaterialsControllerTest.java | 122 ++++++++++++++++++ .../resume/domain/ResumeEvaluation.java | 22 ++-- .../repository/MemberPortfolioRepository.java | 3 + .../repository/MemberResumeRepository.java | 3 + ...e_evaluation_to_member_resume_relation.sql | 16 +++ 18 files changed, 412 insertions(+), 42 deletions(-) create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/PortfolioInfo.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeInfo.java create mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java create mode 100644 common/src/main/resources/db/migration/V32__alter_resume_evaluation_to_member_resume_relation.sql diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index 76ea0e5f..03e930d3 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -576,7 +576,7 @@ 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[] @@ -586,6 +586,28 @@ 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-body.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit/request-fields.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-body.adoc[] +include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-fields.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[] 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 b1d8dc39..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; @@ -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 ffb4409f..ef80c6c9 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 @@ -11,6 +11,7 @@ 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 com.samhap.kokomen.resume.service.dto.SavedResumeEvaluationAsyncRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -21,6 +22,7 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -66,6 +68,15 @@ public ResponseEntity submitResumeEvaluationAsyn .body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth)); } + @PostMapping(value = "/evaluations", consumes = {"application/json"}) + public ResponseEntity submitSavedResumeEvaluationAsync( + @Valid @RequestBody SavedResumeEvaluationAsyncRequest request, + @Authentication MemberAuth memberAuth + ) { + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(careerMaterialsFacadeService.submitSavedResumeEvaluationAsync(request, memberAuth)); + } + @GetMapping("/evaluations/{evaluationId}/state") public ResponseEntity findResumeEvaluationState( @PathVariable String evaluationId, 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 de994911..612fe988 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 @@ -8,6 +8,8 @@ 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; @@ -22,6 +24,7 @@ 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 com.samhap.kokomen.resume.service.dto.SavedResumeEvaluationAsyncRequest; import java.io.IOException; import java.util.List; import java.util.UUID; @@ -65,6 +68,46 @@ public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync( return submitNonMemberResumeEvaluationAsync(request); } + @Transactional + public ResumeEvaluationSubmitResponse submitSavedResumeEvaluationAsync( + SavedResumeEvaluationAsyncRequest request, + MemberAuth memberAuth + ) { + Member member = memberService.readById(memberAuth.memberId()); + + MemberResume resume = careerMaterialsService.getResumeByIdAndMemberId( + request.resumeId(), memberAuth.memberId()); + MemberPortfolio portfolio = getMemberPortfolio(request, memberAuth); + + ResumeEvaluation evaluation = new ResumeEvaluation( + member, + resume, + portfolio, + request.jobPosition(), + request.jobDescription(), + request.jobCareer() + ); + ResumeEvaluation savedEvaluation = resumeEvaluationService.saveEvaluation(evaluation); + + resumeEvaluationAsyncService.processAndEvaluateSavedMemberAsync( + savedEvaluation.getId(), + resume, + portfolio, + request.jobPosition(), + request.jobDescription(), + request.jobCareer() + ); + + return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); + } + + private MemberPortfolio getMemberPortfolio(SavedResumeEvaluationAsyncRequest request, MemberAuth memberAuth) { + if (request.portfolioId() == null) { + return null; + } + return careerMaterialsService.getPortfolioByIdAndMemberId(request.portfolioId(), memberAuth.memberId()); + } + private void validatePdfFiles(ResumeEvaluationAsyncRequest request) { pdfValidator.validate(request.getResume()); if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { 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 index 0692b59e..36abf1d0 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/CareerMaterialsService.java @@ -1,7 +1,10 @@ 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; @@ -59,4 +62,14 @@ private List getPortfoliosByMemberId(Long memberId) { )) .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 index 0f78c993..5db7c37b 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/PdfUploadService.java @@ -30,16 +30,17 @@ public class PdfUploadService { private final S3Service s3Service; @Transactional - public void savePortfolio(MultipartFile portfolio, Member member, String content) { + 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); - memberPortfolioRepository.save(memberPortfolio); + MemberPortfolio savedPortfolio = memberPortfolioRepository.save(memberPortfolio); uploadToS3IfNotExists(s3Key, portfolio); + return savedPortfolio; } // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 @@ -51,16 +52,17 @@ public void savePortfolio(MultipartFile portfolio, Member member) { } @Transactional - public void saveResume(MultipartFile resume, Member member, String content) { + 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); - memberResumeRepository.save(memberResume); + MemberResume savedResume = memberResumeRepository.save(memberResume); uploadToS3IfNotExists(s3Key, resume); + return savedResume; } // TODO: 이력서 평가가 비동기로 전환 완료되면 삭제하기 @@ -92,27 +94,29 @@ private void uploadToS3IfNotExists(String s3Key, MultipartFile file) { } @Transactional - public void saveResume(byte[] resumeData, String filename, Member member, String content) { + 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); - memberResumeRepository.save(memberResume); + MemberResume savedResume = memberResumeRepository.save(memberResume); uploadToS3IfNotExists(s3Key, resumeData); + return savedResume; } @Transactional - public void savePortfolio(byte[] portfolioData, String filename, Member member, String content) { + 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); - memberPortfolioRepository.save(memberPortfolio); + MemberPortfolio savedPortfolio = memberPortfolioRepository.save(memberPortfolio); uploadToS3IfNotExists(s3Key, portfolioData); + return savedPortfolio; } private void validateByteArray(byte[] data) { 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 index d20d3adf..caf3ffb5 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -4,7 +4,10 @@ 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; @@ -37,6 +40,7 @@ public class ResumeEvaluationAsyncService { 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; @@ -47,6 +51,7 @@ public ResumeEvaluationAsyncService( ResumeEvaluationService resumeEvaluationService, PdfUploadService pdfUploadService, RedisService redisService, + S3Service s3Service, BedrockAgentRuntimeAsyncClient bedrockAgentRuntimeAsyncClient, ResumeGptClient resumeGptClient, PdfTextExtractor pdfTextExtractor, @@ -57,6 +62,7 @@ public ResumeEvaluationAsyncService( this.resumeEvaluationService = resumeEvaluationService; this.pdfUploadService = pdfUploadService; this.redisService = redisService; + this.s3Service = s3Service; this.bedrockAgentRuntimeAsyncClient = bedrockAgentRuntimeAsyncClient; this.resumeGptClient = resumeGptClient; this.pdfTextExtractor = pdfTextExtractor; @@ -82,15 +88,15 @@ public void processAndEvaluateMemberAsync(Long evaluationId, Member member, return; } - pdfUploadService.saveResume(resumeFileData.content(), resumeFileData.filename(), - member, extraction.resumeText()); + MemberResume memberResume = pdfUploadService.saveResume(resumeFileData.content(), + resumeFileData.filename(), member, extraction.resumeText()); + MemberPortfolio memberPortfolio = null; if (portfolioFileData != null && !portfolioFileData.isEmpty()) { - pdfUploadService.savePortfolio(portfolioFileData.content(), portfolioFileData.filename(), - member, extraction.portfolioText()); + memberPortfolio = pdfUploadService.savePortfolio(portfolioFileData.content(), + portfolioFileData.filename(), member, extraction.portfolioText()); } - resumeEvaluationService.updateResumeText(evaluationId, - extraction.resumeText(), extraction.portfolioText()); + resumeEvaluationService.updateMemberResume(evaluationId, memberResume, memberPortfolio); ResumeEvaluationRequest evalRequest = new ResumeEvaluationRequest( extraction.resumeText(), extraction.portfolioText(), @@ -106,6 +112,54 @@ public void processAndEvaluateMemberAsync(Long evaluationId, Member member, }); } + public void processAndEvaluateSavedMemberAsync(Long evaluationId, + MemberResume resume, + MemberPortfolio portfolio, + String jobPosition, + String jobDescription, + String jobCareer) { + Map mdcContext = MDC.getCopyOfContextMap(); + executor.execute(() -> { + try { + setMdcContext(mdcContext); + + String resumeText = getOrExtractText(resume.getContent(), resume.getResumeUrl()); + String portfolioText = portfolio != null + ? getOrExtractText(portfolio.getContent(), portfolio.getPortfolioUrl()) + : null; + + if (resumeText == null || resumeText.isBlank()) { + log.error("이력서 텍스트 없음 - evaluationId: {}", evaluationId); + resumeEvaluationService.updateFailed(evaluationId); + return; + } + + 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, 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 4289d191..e565e767 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 @@ -4,6 +4,8 @@ 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; @@ -71,10 +73,10 @@ public void updateCompleted(Long evaluationId, ResumeEvaluationResponse response } @Transactional - public void updateResumeText(Long evaluationId, String resumeText, String portfolioText) { + public void updateMemberResume(Long evaluationId, MemberResume memberResume, MemberPortfolio memberPortfolio) { ResumeEvaluation evaluation = resumeEvaluationRepository.findById(evaluationId) .orElseThrow(() -> new BadRequestException("이력서 평가를 찾을 수 없습니다. id: " + evaluationId)); - evaluation.updateResumeText(resumeText, portfolioText); + evaluation.updateMemberResume(memberResume, memberPortfolio); } @Transactional 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/ResumeEvaluationDetailResponse.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/ResumeEvaluationDetailResponse.java index 92cf77b6..a506ccd8 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -7,8 +9,8 @@ public record ResumeEvaluationDetailResponse( Long id, ResumeEvaluationState state, - String resume, - String portfolio, + ResumeInfo resume, + PortfolioInfo portfolio, String jobPosition, String jobDescription, String jobCareer, @@ -16,20 +18,37 @@ public record ResumeEvaluationDetailResponse( LocalDateTime createdAt ) { public static ResumeEvaluationDetailResponse from(ResumeEvaluation evaluation) { - ResumeEvaluationResponse result = evaluation.isCompleted() - ? ResumeEvaluationResponse.from(evaluation) - : null; - return new ResumeEvaluationDetailResponse( evaluation.getId(), evaluation.getState(), - evaluation.getResume(), - evaluation.getPortfolio(), + createResumeInfo(evaluation.getMemberResume()), + createPortfolioInfo(evaluation.getMemberPortfolio()), evaluation.getJobPosition(), evaluation.getJobDescription(), evaluation.getJobCareer(), - result, + 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/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/SavedResumeEvaluationAsyncRequest.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java new file mode 100644 index 00000000..669d74ca --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java @@ -0,0 +1,20 @@ +package com.samhap.kokomen.resume.service.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record SavedResumeEvaluationAsyncRequest( + @NotNull + Long resumeId, + + Long portfolioId, + + @NotBlank + String jobPosition, + + String jobDescription, + + @NotBlank + String jobCareer +) { +} 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 index 570d3dc5..f22df40b 100644 --- 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 @@ -1,13 +1,15 @@ 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 String resume; - private String portfolio; + private MemberResume resume; + private MemberPortfolio portfolio; private String jobPosition; private String jobDescription; private String jobCareer; @@ -41,12 +43,12 @@ public ResumeEvaluationFixtureBuilder member(Member member) { return this; } - public ResumeEvaluationFixtureBuilder resume(String resume) { + public ResumeEvaluationFixtureBuilder resume(MemberResume resume) { this.resume = resume; return this; } - public ResumeEvaluationFixtureBuilder portfolio(String portfolio) { + public ResumeEvaluationFixtureBuilder portfolio(MemberPortfolio portfolio) { this.portfolio = portfolio; return this; } @@ -84,8 +86,8 @@ public ResumeEvaluationFixtureBuilder totalScore(int totalScore) { public ResumeEvaluation build() { ResumeEvaluation evaluation = new ResumeEvaluation( member, - resume != null ? resume : "테스트 이력서 내용입니다. Java, Spring Boot 경험 3년.", - portfolio != null ? portfolio : "테스트 포트폴리오 내용입니다.", + resume, + portfolio, jobPosition != null ? jobPosition : "백엔드 개발자", jobDescription != null ? jobDescription : "Spring Boot 기반 백엔드 개발", jobCareer != null ? jobCareer : "신입" 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 ca584735..e1135ae7 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 @@ -12,9 +12,11 @@ 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.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -26,6 +28,8 @@ 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; @@ -37,6 +41,7 @@ import com.samhap.kokomen.token.repository.TokenRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; @@ -371,6 +376,16 @@ class CareerMaterialsControllerTest extends BaseControllerTest { @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( @@ -381,6 +396,8 @@ class CareerMaterialsControllerTest extends BaseControllerTest { ResumeEvaluation evaluation = resumeEvaluationRepository.save( ResumeEvaluationFixtureBuilder.builder() .member(member) + .resume(resume) + .portfolio(portfolio) .completed() .build() ); @@ -406,7 +423,11 @@ class CareerMaterialsControllerTest extends BaseControllerTest { 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("경력 구분"), @@ -437,4 +458,105 @@ class CareerMaterialsControllerTest extends BaseControllerTest { ) )); } + + @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 requestBody = """ + { + "resume_id": %d, + "portfolio_id": %d, + "job_position": "백엔드 개발자", + "job_description": "Spring Boot 기반 백엔드 개발", + "job_career": "경력" + } + """.formatted(resume.getId(), portfolio.getId()); + + mockMvc.perform(post("/api/v1/resumes/evaluations") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .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 쿠키") + ), + requestFields( + fieldWithPath("resume_id").description("저장된 이력서 ID"), + fieldWithPath("portfolio_id").description("저장된 포트폴리오 ID (선택)").optional(), + fieldWithPath("job_position").description("지원 직무"), + fieldWithPath("job_description").description("채용공고 상세 내용 (선택)").optional(), + fieldWithPath("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 requestBody = """ + { + "resume_id": %d, + "job_position": "백엔드 개발자", + "job_career": "신입" + } + """.formatted(resume.getId()); + + mockMvc.perform(post("/api/v1/resumes/evaluations") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .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 쿠키") + ), + requestFields( + fieldWithPath("resume_id").description("저장된 이력서 ID"), + fieldWithPath("job_position").description("지원 직무"), + fieldWithPath("job_career").description("경력 구분 (신입/경력)") + ), + responseFields( + fieldWithPath("evaluation_id").description("평가 ID") + ) + )); + } } 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 index 56a63c10..d80b0d51 100644 --- a/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java +++ b/common/src/main/java/com/samhap/kokomen/resume/domain/ResumeEvaluation.java @@ -39,11 +39,13 @@ public class ResumeEvaluation extends BaseEntity { @Column(name = "state", nullable = false, length = 20) private ResumeEvaluationState state; - @Column(name = "resume", columnDefinition = "TEXT") - private String resume; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_resume_id") + private MemberResume memberResume; - @Column(name = "portfolio", columnDefinition = "TEXT") - private String portfolio; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_portfolio_id") + private MemberPortfolio memberPortfolio; @Column(name = "job_position", nullable = false, length = 500) private String jobPosition; @@ -105,12 +107,12 @@ public class ResumeEvaluation extends BaseEntity { @Column(name = "total_feedback", columnDefinition = "TEXT") private String totalFeedback; - public ResumeEvaluation(Member member, String resume, String portfolio, + public ResumeEvaluation(Member member, MemberResume memberResume, MemberPortfolio memberPortfolio, String jobPosition, String jobDescription, String jobCareer) { this.member = member; this.state = ResumeEvaluationState.PENDING; - this.resume = resume; - this.portfolio = portfolio; + this.memberResume = memberResume; + this.memberPortfolio = memberPortfolio; this.jobPosition = jobPosition; this.jobDescription = jobDescription; this.jobCareer = jobCareer; @@ -159,8 +161,8 @@ public boolean isOwner(Long memberId) { return this.member.isOwner(memberId); } - public void updateResumeText(String resume, String portfolio) { - this.resume = resume; - this.portfolio = portfolio; + public void updateMemberResume(MemberResume memberResume, MemberPortfolio memberPortfolio) { + this.memberResume = memberResume; + this.memberPortfolio = memberPortfolio; } } 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/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); From 84dfd2535719a637da87750848a1aed4536f951d Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:01:43 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[REFACTOR]=20=EC=9D=B4=EB=A0=A5=EC=84=9C?= =?UTF-8?q?=20=ED=8F=89=EA=B0=80=20API=20=ED=98=95=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/docs/asciidoc/index.adoc | 6 +- .../controller/CareerMaterialsController.java | 27 +-- .../service/CareerMaterialsFacadeService.java | 166 ++++++++++-------- .../service/ResumeEvaluationAsyncService.java | 88 ++++------ .../dto/ResumeEvaluationAsyncRequest.java | 40 +++-- .../SavedResumeEvaluationAsyncRequest.java | 20 --- .../CareerMaterialsControllerTest.java | 64 +++---- 7 files changed, 198 insertions(+), 213 deletions(-) delete mode 100644 api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc index 03e930d3..f76e930d 100644 --- a/api/src/docs/asciidoc/index.adoc +++ b/api/src/docs/asciidoc/index.adoc @@ -590,8 +590,7 @@ 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-body.adoc[] -include::{snippetsDir}/resume-evaluation-saved-async-submit/request-fields.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[] @@ -601,8 +600,7 @@ 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-body.adoc[] -include::{snippetsDir}/resume-evaluation-saved-async-submit-without-portfolio/request-fields.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[] 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 ef80c6c9..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,6 +2,7 @@ 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; @@ -11,7 +12,6 @@ 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 com.samhap.kokomen.resume.service.dto.SavedResumeEvaluationAsyncRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -22,7 +22,6 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -55,26 +54,32 @@ public ResponseEntity getCareerMaterials( @PostMapping(value = "/evaluations", consumes = {"multipart/form-data"}) public ResponseEntity submitResumeEvaluationAsync( - @RequestPart(value = "resume") MultipartFile resume, + @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, jobPosition, jobDescription, jobCareer); + resume, portfolio, resumeId, portfolioId, jobPosition, jobDescription, jobCareer); return ResponseEntity.status(HttpStatus.ACCEPTED) .body(careerMaterialsFacadeService.submitResumeEvaluationAsync(request, memberAuth)); } - @PostMapping(value = "/evaluations", consumes = {"application/json"}) - public ResponseEntity submitSavedResumeEvaluationAsync( - @Valid @RequestBody SavedResumeEvaluationAsyncRequest request, - @Authentication MemberAuth memberAuth - ) { - return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(careerMaterialsFacadeService.submitSavedResumeEvaluationAsync(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") 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 612fe988..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 @@ -24,7 +24,6 @@ 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 com.samhap.kokomen.resume.service.dto.SavedResumeEvaluationAsyncRequest; import java.io.IOException; import java.util.List; import java.util.UUID; @@ -60,104 +59,127 @@ public ResumeEvaluationSubmitResponse submitResumeEvaluationAsync( ResumeEvaluationAsyncRequest request, MemberAuth memberAuth ) { - validatePdfFiles(request); - if (memberAuth.isAuthenticated()) { - return submitMemberResumeEvaluationAsync(request, memberAuth); + return submitMemberEvaluation(request, memberAuth); } - return submitNonMemberResumeEvaluationAsync(request); + return submitNonMemberEvaluation(request); } - @Transactional - public ResumeEvaluationSubmitResponse submitSavedResumeEvaluationAsync( - SavedResumeEvaluationAsyncRequest request, + private ResumeEvaluationSubmitResponse submitMemberEvaluation( + ResumeEvaluationAsyncRequest request, MemberAuth memberAuth ) { + validatePdfFiles(request); Member member = memberService.readById(memberAuth.memberId()); - MemberResume resume = careerMaterialsService.getResumeByIdAndMemberId( - request.resumeId(), memberAuth.memberId()); - MemberPortfolio portfolio = getMemberPortfolio(request, memberAuth); - - ResumeEvaluation evaluation = new ResumeEvaluation( - member, - resume, - portfolio, - request.jobPosition(), - request.jobDescription(), - request.jobCareer() - ); - ResumeEvaluation savedEvaluation = resumeEvaluationService.saveEvaluation(evaluation); - - resumeEvaluationAsyncService.processAndEvaluateSavedMemberAsync( - savedEvaluation.getId(), - resume, - portfolio, - request.jobPosition(), - request.jobDescription(), - request.jobCareer() - ); + 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 MemberPortfolio getMemberPortfolio(SavedResumeEvaluationAsyncRequest request, MemberAuth memberAuth) { + 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 void validatePdfFiles(ResumeEvaluationAsyncRequest request) { - pdfValidator.validate(request.getResume()); - if (request.getPortfolio() != null && !request.getPortfolio().isEmpty()) { - pdfValidator.validate(request.getPortfolio()); - } + private boolean hasFile(MultipartFile file) { + return file != null && !file.isEmpty(); } - private ResumeEvaluationSubmitResponse submitMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request, - MemberAuth memberAuth) { - Member member = memberService.readById(memberAuth.memberId()); + private ResumeEvaluation saveEvaluation( + Member member, + MemberResume memberResume, + MemberPortfolio memberPortfolio, + ResumeEvaluationAsyncRequest request + ) { ResumeEvaluation evaluation = new ResumeEvaluation( - member, - null, - null, - request.getJobPosition(), - request.getJobDescription(), - request.getJobCareer() - ); - ResumeEvaluation savedEvaluation = resumeEvaluationService.saveEvaluation(evaluation); - - ResumeFileData resumeFileData = createResumeFileData(request.getResume()); - ResumeFileData portfolioFileData = createResumeFileData(request.getPortfolio()); - - resumeEvaluationAsyncService.processAndEvaluateMemberAsync( - savedEvaluation.getId(), - member, - resumeFileData, - portfolioFileData, - request.getJobPosition(), - request.getJobDescription(), - request.getJobCareer() + member, memberResume, memberPortfolio, + request.jobPosition(), request.jobDescription(), request.jobCareer() ); - return ResumeEvaluationSubmitResponse.from(savedEvaluation.getId()); + return resumeEvaluationService.saveEvaluation(evaluation); } - private ResumeEvaluationSubmitResponse submitNonMemberResumeEvaluationAsync(ResumeEvaluationAsyncRequest request) { - String uuid = UUID.randomUUID().toString(); - - ResumeFileData resumeFileData = createResumeFileData(request.getResume()); - ResumeFileData portfolioFileData = createResumeFileData(request.getPortfolio()); + 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.getJobPosition(), - request.getJobDescription(), - request.getJobCareer() + uuid, resumeFileData, portfolioFileData, + request.jobPosition(), request.jobDescription(), request.jobCareer() ); - return ResumeEvaluationSubmitResponse.fromUuid(uuid); } private ResumeFileData createResumeFileData(MultipartFile file) { 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 index caf3ffb5..dcd4b2f3 100644 --- a/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java +++ b/api/src/main/java/com/samhap/kokomen/resume/service/ResumeEvaluationAsyncService.java @@ -70,76 +70,64 @@ public ResumeEvaluationAsyncService( this.executor = executor; } - public void processAndEvaluateMemberAsync(Long evaluationId, Member member, - ResumeFileData resumeFileData, - ResumeFileData portfolioFileData, - String jobPosition, - String jobDescription, - String jobCareer) { + 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); - TextExtractionResult extraction = extractTexts(resumeFileData, portfolioFileData); - if (!extraction.hasResumeText()) { - log.error("이력서 텍스트 추출 실패 - evaluationId: {}", evaluationId); + 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; } - MemberResume memberResume = pdfUploadService.saveResume(resumeFileData.content(), - resumeFileData.filename(), member, extraction.resumeText()); - MemberPortfolio memberPortfolio = null; - if (portfolioFileData != null && !portfolioFileData.isEmpty()) { - memberPortfolio = pdfUploadService.savePortfolio(portfolioFileData.content(), - portfolioFileData.filename(), member, extraction.portfolioText()); - } - - resumeEvaluationService.updateMemberResume(evaluationId, memberResume, memberPortfolio); - - ResumeEvaluationRequest evalRequest = new ResumeEvaluationRequest( - extraction.resumeText(), extraction.portfolioText(), - jobPosition, jobDescription, jobCareer - ); - evaluateMemberAsync(evaluationId, evalRequest); - } catch (Exception e) { - log.error("회원 이력서 평가 처리 실패 - evaluationId: {}", evaluationId, e); - resumeEvaluationService.updateFailed(evaluationId); - } finally { - MDC.clear(); - } - }); - } - - public void processAndEvaluateSavedMemberAsync(Long evaluationId, - MemberResume resume, - MemberPortfolio portfolio, - String jobPosition, - String jobDescription, - String jobCareer) { - Map mdcContext = MDC.getCopyOfContextMap(); - executor.execute(() -> { - try { - setMdcContext(mdcContext); - - String resumeText = getOrExtractText(resume.getContent(), resume.getResumeUrl()); - String portfolioText = portfolio != null - ? getOrExtractText(portfolio.getContent(), portfolio.getPortfolioUrl()) - : null; - 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); + log.error("혼합 이력서 평가 처리 실패 - evaluationId: {}", evaluationId, e); resumeEvaluationService.updateFailed(evaluationId); } finally { MDC.clear(); 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 index 7ad69fd7..2531345a 100644 --- 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 @@ -1,25 +1,27 @@ package com.samhap.kokomen.resume.service.dto; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Getter; +import com.samhap.kokomen.global.exception.BadRequestException; import org.springframework.web.multipart.MultipartFile; -@Getter -@AllArgsConstructor -public class ResumeEvaluationAsyncRequest { +public record ResumeEvaluationAsyncRequest( + MultipartFile resume, + MultipartFile portfolio, + Long resumeId, + Long portfolioId, + String jobPosition, + String jobDescription, + String jobCareer +) { - @NotNull(message = "이력서 파일은 필수입니다.") - private MultipartFile resume; - - private MultipartFile portfolio; - - @NotBlank(message = "직무는 필수입니다.") - private String jobPosition; - - private String jobDescription; - - @NotBlank(message = "경력은 필수입니다.") - private 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/SavedResumeEvaluationAsyncRequest.java b/api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java deleted file mode 100644 index 669d74ca..00000000 --- a/api/src/main/java/com/samhap/kokomen/resume/service/dto/SavedResumeEvaluationAsyncRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.samhap.kokomen.resume.service.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record SavedResumeEvaluationAsyncRequest( - @NotNull - Long resumeId, - - Long portfolioId, - - @NotBlank - String jobPosition, - - String jobDescription, - - @NotBlank - String jobCareer -) { -} 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 e1135ae7..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 @@ -12,11 +12,9 @@ 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.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -41,7 +39,6 @@ import com.samhap.kokomen.token.repository.TokenRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; @@ -479,19 +476,16 @@ class CareerMaterialsControllerTest extends BaseControllerTest { MockHttpSession session = new MockHttpSession(); session.setAttribute("MEMBER_ID", member.getId()); - String requestBody = """ - { - "resume_id": %d, - "portfolio_id": %d, - "job_position": "백엔드 개발자", - "job_description": "Spring Boot 기반 백엔드 개발", - "job_career": "경력" - } - """.formatted(resume.getId(), portfolio.getId()); - - mockMvc.perform(post("/api/v1/resumes/evaluations") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + 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) ) @@ -501,12 +495,12 @@ class CareerMaterialsControllerTest extends BaseControllerTest { requestHeaders( headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") ), - requestFields( - fieldWithPath("resume_id").description("저장된 이력서 ID"), - fieldWithPath("portfolio_id").description("저장된 포트폴리오 ID (선택)").optional(), - fieldWithPath("job_position").description("지원 직무"), - fieldWithPath("job_description").description("채용공고 상세 내용 (선택)").optional(), - fieldWithPath("job_career").description("경력 구분 (신입/경력)") + 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") @@ -529,17 +523,13 @@ class CareerMaterialsControllerTest extends BaseControllerTest { MockHttpSession session = new MockHttpSession(); session.setAttribute("MEMBER_ID", member.getId()); - String requestBody = """ - { - "resume_id": %d, - "job_position": "백엔드 개발자", - "job_career": "신입" - } - """.formatted(resume.getId()); - - mockMvc.perform(post("/api/v1/resumes/evaluations") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody) + 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) ) @@ -549,10 +539,10 @@ class CareerMaterialsControllerTest extends BaseControllerTest { requestHeaders( headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") ), - requestFields( - fieldWithPath("resume_id").description("저장된 이력서 ID"), - fieldWithPath("job_position").description("지원 직무"), - fieldWithPath("job_career").description("경력 구분 (신입/경력)") + requestParts( + partWithName("resume_id").description("저장된 이력서 ID"), + partWithName("job_position").description("지원 직무"), + partWithName("job_career").description("경력 구분 (신입/경력)") ), responseFields( fieldWithPath("evaluation_id").description("평가 ID") From ded228983d9b3fa47355978416a44823b510f2ec Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Sun, 21 Dec 2025 17:44:12 +0900 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A0=A5=EC=84=9C?= =?UTF-8?q?=20=ED=8F=89=EA=B0=80=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samhap/kokomen/resume/service/ResumeEvaluationService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e565e767..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 @@ -18,6 +18,7 @@ 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; @@ -31,7 +32,7 @@ public class ResumeEvaluationService { private final ResumeGptClient resumeGptClient; private final ObjectMapper objectMapper; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public ResumeEvaluation saveEvaluation(ResumeEvaluation evaluation) { return resumeEvaluationRepository.save(evaluation); }