From 71fb6a4eeed86c57ff0da8d211165ca9fb606618 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:03:02 +0900 Subject: [PATCH 01/55] =?UTF-8?q?[Test]=20=ED=86=A0=EC=8A=A4=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=8A=B9=EC=9D=B8=EA=B3=BC=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analysis-server | 1 + .../payment/service/PaymentServiceTest.java | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 160000 analysis-server diff --git a/analysis-server b/analysis-server new file mode 160000 index 00000000..892724a2 --- /dev/null +++ b/analysis-server @@ -0,0 +1 @@ +Subproject commit 892724a2ba9fd70aa15e4976006df1944c3315d2 diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 3a1430bf..2358cd2a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -48,6 +48,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.function.IntFunction; import static org.assertj.core.api.Assertions.assertThat; @@ -282,6 +283,62 @@ void confirmConcurrentlyChargesOnlyOnce() throws Exception { )).hasSize(1); } + @Test + @DisplayName("토스 직접 승인 중 PAY_COMPLETE 콜백이 동시에 오면 콜백이 PAYMENT_ALREADY_PROCESSED로 실패한다") + void confirmAndTossPayCallbackRaceCurrentlyFailsCallbackWithAlreadyProcessed() throws Exception { + User user = saveUser("payment-confirm-callback-race@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + String paymentKey = "payment-key-" + prepared.orderId(); + PaymentConfirmRequest confirmRequest = new PaymentConfirmRequest(paymentKey, prepared.orderId(), 2500); + TossPayCallbackRequest callbackRequest = tossPayCompleteCallback(prepared); + + CountDownLatch confirmEnteredExternalCall = new CountDownLatch(1); + CountDownLatch callbackAttemptFinished = new CountDownLatch(1); + when(tossPaymentClient.confirm(paymentKey, prepared.orderId(), 2500)) + .thenAnswer(invocation -> { + confirmEnteredExternalCall.countDown(); + assertThat(callbackAttemptFinished.await(5, TimeUnit.SECONDS)).isTrue(); + return tossPayResponse(paymentKey, prepared, 2500); + }); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + + List results = runConcurrentlyIndexed(2, index -> { + try { + if (index == 0) { + paymentService.confirm(user, confirmRequest); + return Result.ok(); + } + + assertThat(confirmEnteredExternalCall.await(5, TimeUnit.SECONDS)).isTrue(); + paymentService.handleTossPayCallback(callbackRequest); + return Result.ok(); + } catch (Exception e) { + return Result.failure(e); + } finally { + if (index == 1) { + callbackAttemptFinished.countDown(); + } + } + }); + + assertThat(results).hasSize(2); + assertThat(results.get(0).success()).isTrue(); + assertThat(results.get(1).success()).isFalse(); + assertThat(results.get(1).exception()) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED); + + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).hasSize(1); + } + @Test @DisplayName("토스 승인 응답이 간편결제가 아니면 결제를 실패 처리하고 크레딧을 충전하지 않는다") void confirmThrowsWhenPaymentMethodIsNotEasyPay() { From b33874509e3102ad87843c606e02c6ae114da4d8 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:04:53 +0900 Subject: [PATCH 02/55] =?UTF-8?q?[Fix]=20=ED=86=A0=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=20=EC=BD=9C=EB=B0=B1=20=EC=83=81=ED=83=9C=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=EC=9D=84=20200=20ack=20=EC=B2=98=EB=A6=AC=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/service/PaymentService.java | 1 + .../payment/service/PaymentServiceTest.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 6f2b896f..0973ad80 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -526,6 +526,7 @@ public boolean shouldAcknowledgeTossPayCallbackFailure(Exception exception) { } BaseErrorCode code = generalException.getCode(); return code == GeneralErrorCode.INVALID_PARAMETER + || code == GeneralErrorCode.PAYMENT_ALREADY_PROCESSED || code == GeneralErrorCode.PAYMENT_CONFIRM_FAILED; } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 2358cd2a..53b67359 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -682,6 +682,22 @@ void tossPayCallbackControllerAcknowledgesUnrecoverableValidationFailure() { assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); } + @Test + @DisplayName("이미 PROCESSING 상태인 결제의 토스페이 콜백 충돌은 컨트롤러에서 200으로 응답한다") + void tossPayCallbackControllerAcknowledgesAlreadyProcessedConflict() { + User user = saveUser("payment-callback-already-processed@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + + var response = paymentController.tossPayCallback(tossPayCompleteCallback(prepared)); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } + @Test @DisplayName("재시도 가능한 토스페이 콜백 상태 조회 실패는 컨트롤러에서 전파한다") void tossPayCallbackControllerPropagatesRetryableStatusFailure() { From fb8dd9a318d88a8c23b94e7cc5a88c6ba3f0124c Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:14:17 +0900 Subject: [PATCH 03/55] =?UTF-8?q?[Fix]=20PROCESSING=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=9D=98=20=ED=86=A0=EC=8A=A4=ED=8E=98=EC=9D=B4=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20=EB=A9=B1=EB=93=B1=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PaymentTransactionService.java | 2 +- .../payment/service/PaymentServiceTest.java | 77 ++++++++++++++++--- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index b85da781..d9b39a22 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -227,7 +227,7 @@ public PaymentConfirmResponse applyTossPayStatus(String orderId, String payToken return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); } - if (payment.getStatus() == PaymentStatus.FAILED || payment.getStatus() == PaymentStatus.PROCESSING) { + if (payment.getStatus() == PaymentStatus.FAILED) { throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "처리할 수 없는 결제 상태입니다."); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 53b67359..18f05e89 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -284,8 +284,8 @@ void confirmConcurrentlyChargesOnlyOnce() throws Exception { } @Test - @DisplayName("토스 직접 승인 중 PAY_COMPLETE 콜백이 동시에 오면 콜백이 PAYMENT_ALREADY_PROCESSED로 실패한다") - void confirmAndTossPayCallbackRaceCurrentlyFailsCallbackWithAlreadyProcessed() throws Exception { + @DisplayName("토스 직접 승인 중 PAY_COMPLETE 콜백이 동시에 와도 한 번만 충전하고 둘 다 성공 처리한다") + void confirmAndTossPayCallbackRaceCompletesIdempotently() throws Exception { User user = saveUser("payment-confirm-callback-race@example.com"); mockTossPayCreateSuccess(); PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); @@ -324,14 +324,11 @@ void confirmAndTossPayCallbackRaceCurrentlyFailsCallbackWithAlreadyProcessed() t assertThat(results).hasSize(2); assertThat(results.get(0).success()).isTrue(); - assertThat(results.get(1).success()).isFalse(); - assertThat(results.get(1).exception()) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED); + assertThat(results.get(1).success()).isTrue(); Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(payment.getTossStatus()).isEqualTo("PAY_COMPLETE"); assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( user.getId(), @@ -339,6 +336,65 @@ void confirmAndTossPayCallbackRaceCurrentlyFailsCallbackWithAlreadyProcessed() t )).hasSize(1); } + @Test + @DisplayName("PROCESSING 상태에서 PAY_CANCEL 콜백이 오면 결제를 실패 처리하고 크레딧을 지급하지 않는다") + void tossPayCallbackFailsProcessingPaymentWhenCanceled() { + User user = saveUser("payment-callback-processing-cancel@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_CANCEL, prepared.amount()); + + paymentService.handleTossPayCallback(new TossPayCallbackRequest( + "PAY_CANCEL", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + )); + + assertPaymentFailedWithoutCreditCharge(user, prepared.orderId()); + } + + @Test + @DisplayName("PROCESSING 상태에서 중간 상태 콜백이 오면 PROCESSING을 유지하고 상태만 갱신한다") + void tossPayCallbackKeepsProcessingPaymentWhenStatusIsIntermediate() { + User user = saveUser("payment-callback-processing-approved@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + payment.markProcessing("payment-key-" + prepared.orderId()); + paymentRepository.saveAndFlush(payment); + mockTossPayStatus(prepared, TossPayStatus.PAY_APPROVED, prepared.amount()); + + paymentService.handleTossPayCallback(new TossPayCallbackRequest( + "PAY_APPROVED", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + )); + + Payment updatedPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.PROCESSING); + assertThat(updatedPayment.getTossStatus()).isEqualTo("PAY_APPROVED"); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.CHARGE + )).isEmpty(); + } + @Test @DisplayName("토스 승인 응답이 간편결제가 아니면 결제를 실패 처리하고 크레딧을 충전하지 않는다") void confirmThrowsWhenPaymentMethodIsNotEasyPay() { @@ -683,8 +739,8 @@ void tossPayCallbackControllerAcknowledgesUnrecoverableValidationFailure() { } @Test - @DisplayName("이미 PROCESSING 상태인 결제의 토스페이 콜백 충돌은 컨트롤러에서 200으로 응답한다") - void tossPayCallbackControllerAcknowledgesAlreadyProcessedConflict() { + @DisplayName("이미 PROCESSING 상태인 결제의 PAY_COMPLETE 콜백은 컨트롤러에서 성공 처리한다") + void tossPayCallbackControllerProcessesProcessingPayment() { User user = saveUser("payment-callback-already-processed@example.com"); mockTossPayCreateSuccess(); PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); @@ -696,6 +752,9 @@ void tossPayCallbackControllerAcknowledgesAlreadyProcessedConflict() { var response = paymentController.tossPayCallback(tossPayCompleteCallback(prepared)); assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + Payment updatedPayment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(updatedPayment.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(2); } @Test From 12ad69276592199ffe1810a9da7c952e807578bc Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:18:58 +0900 Subject: [PATCH 04/55] =?UTF-8?q?[Refactor]=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20=EB=82=B4=EB=B6=80=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/gateway/PaymentGateway.java | 27 +++++++++++++++++++ .../gateway/model/GatewayConfirmCommand.java | 8 ++++++ .../gateway/model/GatewayPaymentQuery.java | 8 ++++++ .../gateway/model/GatewayPaymentSnapshot.java | 16 +++++++++++ .../gateway/model/GatewayPaymentStatus.java | 11 ++++++++ .../gateway/model/GatewayPrepareCommand.java | 13 +++++++++ .../gateway/model/GatewayPrepareResult.java | 15 +++++++++++ .../gateway/model/GatewayRefundCommand.java | 14 ++++++++++ .../gateway/model/GatewayRefundResult.java | 10 +++++++ .../gateway/model/GatewayRefundStatus.java | 7 +++++ 10 files changed, 129 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java new file mode 100644 index 00000000..2d892f12 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java @@ -0,0 +1,27 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; + +/** + * Normalized payment gateway contract used to isolate provider-specific DTOs + * and API flows from application services. + */ +public interface PaymentGateway { + + PaymentProviderType type(); + + GatewayPrepareResult prepare(GatewayPrepareCommand command); + + GatewayPaymentSnapshot confirm(GatewayConfirmCommand command); + + GatewayPaymentSnapshot fetch(GatewayPaymentQuery query); + + GatewayRefundResult refund(GatewayRefundCommand command); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java new file mode 100644 index 00000000..c95427e9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +public record GatewayConfirmCommand( + String orderId, + String paymentKey, + int amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java new file mode 100644 index 00000000..29ccdce7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +public record GatewayPaymentQuery( + String orderId, + String externalPaymentId, + String payToken +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java new file mode 100644 index 00000000..a4bc5fc9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; + +public record GatewayPaymentSnapshot( + PaymentProviderType provider, + GatewayPaymentStatus status, + String orderId, + String paymentKey, + String payToken, + String externalPaymentId, + String externalTransactionId, + String externalStatus, + Integer amount +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java new file mode 100644 index 00000000..b2691b4c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +public enum GatewayPaymentStatus { + PENDING, + PROCESSING, + APPROVED, + COMPLETED, + CANCELED, + FAILED, + UNKNOWN +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java new file mode 100644 index 00000000..941e23a6 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; + +public record GatewayPrepareCommand( + PaymentProviderType provider, + String orderId, + String orderName, + int amount, + int creditAmount, + String customerEmail +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java new file mode 100644 index 00000000..66abea34 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; + +public record GatewayPrepareResult( + PaymentProviderType provider, + String externalPaymentId, + String payToken, + String checkoutPage, + String redirectUrl, + String storeId, + String channelKey, + String currency +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java new file mode 100644 index 00000000..ba030831 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; + +public record GatewayRefundCommand( + Long paymentId, + PaymentProviderType provider, + String orderId, + String externalPaymentId, + String payToken, + int amount, + String reason +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java new file mode 100644 index 00000000..96619006 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; + +public record GatewayRefundResult( + PaymentProviderType provider, + GatewayRefundStatus status, + String externalStatus +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java new file mode 100644 index 00000000..1df88afb --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.model; + +public enum GatewayRefundStatus { + SUCCEEDED, + FAILED, + UNKNOWN +} From 08c59184cd601db0bff9257ba9c0a5cd3e60ea15 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:24:06 +0900 Subject: [PATCH 05/55] =?UTF-8?q?[Test]=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20helper=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=B6=94=EA=B0=80=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentServiceTest.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 18f05e89..b8d500a7 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -49,6 +49,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.IntFunction; import static org.assertj.core.api.Assertions.assertThat; @@ -66,6 +67,7 @@ class PaymentServiceTest { private static final String TEST_PORTONE_WEBHOOK_SECRET = "whsec_dGVzdC13ZWJob29rLXNlY3JldA=="; private static final String ORDER_ID_PATTERN = "^jobdri-[0-9a-f]{32}$"; + private static final long CONCURRENCY_TEST_TIMEOUT_SECONDS = 10L; @Autowired private PaymentService paymentService; @@ -1464,12 +1466,26 @@ private List runConcurrentlyIndexed(int threadCount, IntFunction var futures = tasks.stream() .map(executor::submit) .toList(); - ready.await(); + if (!ready.await(CONCURRENCY_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + cancelAll(futures); + throw new AssertionError("Concurrent test setup timed out before all workers became ready."); + } start.countDown(); List results = new java.util.ArrayList<>(); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(CONCURRENCY_TEST_TIMEOUT_SECONDS); for (var future : futures) { - results.add(future.get()); + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + cancelAll(futures); + throw new AssertionError("Concurrent test timed out while waiting for worker completion."); + } + try { + results.add(future.get(remainingNanos, TimeUnit.NANOSECONDS)); + } catch (TimeoutException e) { + cancelAll(futures); + throw new AssertionError("Concurrent test timed out while waiting for worker completion.", e); + } } return results; } finally { @@ -1477,6 +1493,12 @@ private List runConcurrentlyIndexed(int threadCount, IntFunction } } + private void cancelAll(List> futures) { + for (var future : futures) { + future.cancel(true); + } + } + private record Result(boolean success, Exception exception) { static Result ok() { return new Result(true, null); From a54fddb42f0179bec49e746093dad0f11037bb11 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:28:06 +0900 Subject: [PATCH 06/55] =?UTF-8?q?[Refactor]=20TossPay=20=EC=A4=80=EB=B9=84?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EC=9D=84=20PaymentGateway=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/TossPayPaymentGateway.java | 65 +++++++++++++++++++ .../payment/service/PaymentService.java | 36 +++++++--- 2 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java new file mode 100644 index 00000000..2ddeca1c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java @@ -0,0 +1,65 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.service.TossPayClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TossPayPaymentGateway implements PaymentGateway { + + private static final String CURRENCY = "KRW"; + + private final TossPayClient tossPayClient; + + @Override + public PaymentProviderType type() { + return PaymentProviderType.TOSS_PAY_DIRECT; + } + + @Override + public GatewayPrepareResult prepare(GatewayPrepareCommand command) { + var response = tossPayClient.createPayment( + command.orderId(), + command.amount(), + command.orderName() + ); + return new GatewayPrepareResult( + type(), + null, + response.payToken(), + response.checkoutPage(), + null, + null, + null, + CURRENCY + ); + } + + @Override + public GatewayPaymentSnapshot confirm(GatewayConfirmCommand command) { + throw unsupported(); + } + + @Override + public GatewayPaymentSnapshot fetch(GatewayPaymentQuery query) { + throw unsupported(); + } + + @Override + public GatewayRefundResult refund(GatewayRefundCommand command) { + throw unsupported(); + } + + private UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("TossPayPaymentGateway currently supports prepare only."); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 0973ad80..36ec903c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -2,6 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.payment.gateway.PaymentGateway; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; @@ -12,7 +15,6 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; @@ -56,6 +58,7 @@ public class PaymentService { private final UserService userService; private final CreditTransactionRepository creditTransactionRepository; private final PaymentTransactionService paymentTransactionService; + private final List paymentGateways; private final TossPaymentClient tossPaymentClient; private final TossPayClient tossPayClient; private final PortOneClient portOneClient; @@ -88,15 +91,18 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) validatedUser.getId(), plan, orderId, - PaymentProviderType.TOSS_PAY_DIRECT + provider ); - TossPayCreateResponse tossPayResponse; + GatewayPrepareResult prepareResult; try { - tossPayResponse = tossPayClient.createPayment( + prepareResult = resolveGateway(provider).prepare(new GatewayPrepareCommand( + provider, payment.getOrderId(), + payment.getContent(), payment.getPrice(), - payment.getContent() - ); + payment.getCreditAmount(), + validatedUser.getEmail() + )); } catch (RuntimeException e) { if (e instanceof GeneralException generalException && generalException.getCode() == GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT) { @@ -111,15 +117,15 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) payment = paymentTransactionService.completeTossPayCreation( validatedUser.getId(), payment.getOrderId(), - tossPayResponse.payToken(), - tossPayResponse.checkoutPage() + prepareResult.payToken(), + prepareResult.checkoutPage() ); } catch (RuntimeException e) { paymentTransactionService.markTossPayCreationUnknown( validatedUser.getId(), payment.getOrderId(), - tossPayResponse.payToken(), - tossPayResponse.checkoutPage() + prepareResult.payToken(), + prepareResult.checkoutPage() ); throw e; } @@ -134,6 +140,16 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request) return PaymentPrepareResponse.of(payment, validatedUser.getEmail()); } + private PaymentGateway resolveGateway(PaymentProviderType provider) { + return paymentGateways.stream() + .filter(gateway -> gateway.type() == provider) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "지원하는 결제 게이트웨이를 찾을 수 없습니다. provider=" + provider + )); + } + private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan plan) { PortOnePrepareData prepareData = portOneClient.prepareData(); String orderId = generateOrderId(); From 67f298f3c4e9c87027d37622386ef4a1f36fc4ac Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:33:29 +0900 Subject: [PATCH 07/55] =?UTF-8?q?[Refactor]=20PortOne=20=EC=A4=80=EB=B9=84?= =?UTF-8?q?/=EC=A1=B0=ED=9A=8C=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20PaymentGate?= =?UTF-8?q?way=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/PortOnePaymentGateway.java | 86 +++++++++++++++++++ .../gateway/model/GatewayPaymentSnapshot.java | 2 + .../payment/service/PaymentService.java | 23 +++-- .../service/PaymentTransactionService.java | 85 ++++++++++++++++++ 4 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java new file mode 100644 index 00000000..40e73a8b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java @@ -0,0 +1,86 @@ +package com.jobdri.jobdri_api.domain.payment.gateway; + +import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentStatus; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.service.PortOneClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PortOnePaymentGateway implements PaymentGateway { + + private final PortOneClient portOneClient; + + @Override + public PaymentProviderType type() { + return PaymentProviderType.PORTONE; + } + + @Override + public GatewayPrepareResult prepare(GatewayPrepareCommand command) { + var response = portOneClient.prepareData(); + return new GatewayPrepareResult( + type(), + command.orderId(), + null, + null, + response.redirectUrl(), + response.storeId(), + response.channelKey(), + "KRW" + ); + } + + @Override + public GatewayPaymentSnapshot fetch(GatewayPaymentQuery query) { + var response = portOneClient.getPayment(query.externalPaymentId()); + return new GatewayPaymentSnapshot( + type(), + mapStatus(response.status()), + response.id(), + null, + null, + response.id(), + response.transactionId(), + response.status(), + response.storeId(), + response.currency(), + response.amount() == null ? null : response.amount().total() + ); + } + + @Override + public GatewayPaymentSnapshot confirm(GatewayConfirmCommand command) { + throw unsupported(); + } + + @Override + public GatewayRefundResult refund(GatewayRefundCommand command) { + throw unsupported(); + } + + private GatewayPaymentStatus mapStatus(String externalStatus) { + if (externalStatus == null) { + return GatewayPaymentStatus.UNKNOWN; + } + return switch (externalStatus.toUpperCase()) { + case "READY", "PENDING" -> GatewayPaymentStatus.PENDING; + case "PAID" -> GatewayPaymentStatus.COMPLETED; + case "FAILED" -> GatewayPaymentStatus.FAILED; + case "CANCELLED" -> GatewayPaymentStatus.CANCELED; + default -> GatewayPaymentStatus.UNKNOWN; + }; + } + + private UnsupportedOperationException unsupported() { + return new UnsupportedOperationException("PortOnePaymentGateway currently supports prepare and fetch only."); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java index a4bc5fc9..5e2cf80e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java @@ -11,6 +11,8 @@ public record GatewayPaymentSnapshot( String externalPaymentId, String externalTransactionId, String externalStatus, + String storeId, + String currency, Integer amount ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 36ec903c..70d1d467 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.payment.gateway.PaymentGateway; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; @@ -151,13 +153,20 @@ private PaymentGateway resolveGateway(PaymentProviderType provider) { } private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan plan) { - PortOnePrepareData prepareData = portOneClient.prepareData(); String orderId = generateOrderId(); Payment payment = paymentTransactionService.createPortOnePendingPayment( validatedUser.getId(), plan, orderId ); + GatewayPrepareResult prepareResult = resolveGateway(PaymentProviderType.PORTONE).prepare(new GatewayPrepareCommand( + PaymentProviderType.PORTONE, + payment.getOrderId(), + payment.getContent(), + payment.getPrice(), + payment.getCreditAmount(), + validatedUser.getEmail() + )); try (var ignored = LoggingContext.with( "payment.portone.prepare.completed", null, @@ -168,9 +177,9 @@ private PaymentPrepareResponse preparePortOne(User validatedUser, CreditPlan pla return PaymentPrepareResponse.portOne( payment, validatedUser.getEmail(), - prepareData.storeId(), - prepareData.channelKey(), - prepareData.redirectUrl() + prepareResult.storeId(), + prepareResult.channelKey(), + prepareResult.redirectUrl() ); } @@ -279,10 +288,12 @@ public PaymentRefundResponse refund(User user, Long paymentId, PaymentRefundRequ @Transactional(propagation = Propagation.NOT_SUPPORTED) public PaymentConfirmResponse completePortOne(User user, PortOnePaymentCompleteRequest request) { User validatedUser = userService.validateUser(user); - PortOnePaymentResponse response = portOneClient.getPayment(request.paymentId()); + GatewayPaymentSnapshot paymentSnapshot = resolveGateway(PaymentProviderType.PORTONE).fetch( + new GatewayPaymentQuery(null, request.paymentId(), null) + ); return paymentTransactionService.applyPortOnePayment( validatedUser.getId(), - response, + paymentSnapshot, portOneClient.storeId() ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index d9b39a22..9d3a21ed 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentOrderStatusResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentRefundResponse; @@ -257,6 +258,11 @@ public PaymentConfirmResponse applyPortOnePayment(Long userId, PortOnePaymentRes return applyPortOnePayment(userId, response, expectedStoreId, true); } + @Transactional + public PaymentConfirmResponse applyPortOnePayment(Long userId, GatewayPaymentSnapshot snapshot, String expectedStoreId) { + return applyPortOnePayment(userId, snapshot, expectedStoreId, true); + } + @Transactional public PaymentConfirmResponse applyPortOneWebhookPayment(PortOnePaymentResponse response, String expectedStoreId) { return applyPortOnePayment(null, response, expectedStoreId, false); @@ -314,6 +320,58 @@ private PaymentConfirmResponse applyPortOnePayment( return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); } + private PaymentConfirmResponse applyPortOnePayment( + Long userId, + GatewayPaymentSnapshot snapshot, + String expectedStoreId, + boolean requireOwner + ) { + validatePortOneSnapshotMinimum(snapshot); + Payment payment = paymentRepository.findByOrderIdForUpdate(snapshot.orderId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.PAYMENT_NOT_FOUND, + "결제 정보를 찾을 수 없습니다. paymentId=" + snapshot.orderId() + )); + if (requireOwner && !payment.belongsTo(userId)) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); + } + validatePortOnePayment(payment, snapshot, expectedStoreId); + + PortOnePaymentStatus portOneStatus = PortOnePaymentStatus.from(snapshot.externalStatus()); + if (payment.getStatus() == PaymentStatus.REFUNDED) { + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + if (payment.getStatus() == PaymentStatus.COMPLETED) { + payment.updatePortOneStatus(snapshot.externalStatus(), snapshot.externalTransactionId()); + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + + if (portOneStatus == PortOnePaymentStatus.PAID) { + User user = userService.getUser(payment.getUser().getId()); + payment.completeByPortOne(snapshot.externalStatus(), snapshot.externalTransactionId()); + int creditBalance = creditService.charge( + user, + payment.getCreditAmount(), + payment.getContent(), + "PAYMENT:PORTONE:" + payment.getOrderId() + ); + return PaymentConfirmResponse.of(payment, creditBalance); + } + + if (portOneStatus == PortOnePaymentStatus.FAILED || portOneStatus == PortOnePaymentStatus.CANCELLED) { + payment.failByPortOne(snapshot.externalStatus(), snapshot.externalTransactionId()); + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + + if (portOneStatus == PortOnePaymentStatus.UNKNOWN || portOneStatus == PortOnePaymentStatus.PARTIAL_CANCELLED) { + payment.markPortOneUnknown(snapshot.externalStatus(), snapshot.externalTransactionId()); + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + + payment.updatePortOneStatus(snapshot.externalStatus(), snapshot.externalTransactionId()); + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + @Transactional public void markTossPayStatusChecked(String orderId, String payToken, TossPayStatus tossPayStatus, int amount) { Payment payment = paymentRepository.findByOrderIdForUpdate(orderId) @@ -392,6 +450,12 @@ private void validatePortOneResponseMinimum(PortOnePaymentResponse response) { } } + private void validatePortOneSnapshotMinimum(GatewayPaymentSnapshot snapshot) { + if (snapshot == null || snapshot.orderId() == null || snapshot.orderId().isBlank()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 조회 응답 검증에 실패했습니다."); + } + } + private void validatePortOnePayment(Payment payment, PortOnePaymentResponse response, String expectedStoreId) { if (!payment.isProvider(PaymentProviderType.PORTONE)) { throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 건이 아닙니다."); @@ -413,6 +477,27 @@ private void validatePortOnePayment(Payment payment, PortOnePaymentResponse resp } } + private void validatePortOnePayment(Payment payment, GatewayPaymentSnapshot snapshot, String expectedStoreId) { + if (!payment.isProvider(PaymentProviderType.PORTONE)) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 건이 아닙니다."); + } + if (payment.getExternalPaymentId() == null || !payment.getExternalPaymentId().equals(snapshot.externalPaymentId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 paymentId가 일치하지 않습니다."); + } + if (expectedStoreId != null && !expectedStoreId.isBlank() && !expectedStoreId.equals(snapshot.storeId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 storeId가 일치하지 않습니다."); + } + if (!"KRW".equals(snapshot.currency())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "지원하지 않는 포트원 결제 통화입니다."); + } + if (snapshot.amount() == null) { + throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 금액이 누락되었습니다."); + } + if (payment.getPrice() != snapshot.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다."); + } + } + private String refundCreditReferenceId(Payment payment) { return "PAYMENT_REFUND:" + payment.getProviderOrDefault() + ":" + payment.getOrderId(); } From 882e5dbf90427219a7a75853c0731c7689ddd510 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:40:08 +0900 Subject: [PATCH 08/55] =?UTF-8?q?[Refactor]=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=98=20=EA=B2=B0=EC=A0=9C=EC=82=AC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=A5=BC=20PaymentGateway=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/PortOnePaymentGateway.java | 32 ++++++- .../gateway/TossPayPaymentGateway.java | 45 +++++++++- .../payment/service/PaymentService.java | 88 +++---------------- 3 files changed, 84 insertions(+), 81 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java index 40e73a8b..4eed8384 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.gateway; +import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; @@ -9,7 +10,10 @@ import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundStatus; import com.jobdri.jobdri_api.domain.payment.service.PortOneClient; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -64,7 +68,16 @@ public GatewayPaymentSnapshot confirm(GatewayConfirmCommand command) { @Override public GatewayRefundResult refund(GatewayRefundCommand command) { - throw unsupported(); + if (isBlank(command.externalPaymentId())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "포트원 paymentId가 없는 결제는 환불할 수 없습니다."); + } + PortOneCancelResponse response = portOneClient.cancelPayment( + command.externalPaymentId(), + command.amount(), + command.reason() + ); + validateRefundResponse(response, command.amount()); + return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, "CANCELLED"); } private GatewayPaymentStatus mapStatus(String externalStatus) { @@ -81,6 +94,21 @@ private GatewayPaymentStatus mapStatus(String externalStatus) { } private UnsupportedOperationException unsupported() { - return new UnsupportedOperationException("PortOnePaymentGateway currently supports prepare and fetch only."); + return new UnsupportedOperationException("PortOnePaymentGateway currently supports prepare, fetch, and refund only."); + } + + private void validateRefundResponse(PortOneCancelResponse response, int amount) { + if (response == null + || response.cancellation() == null + || isBlank(response.cancellation().id()) + || !"SUCCEEDED".equals(response.cancellation().status()) + || response.cancellation().amount() == null + || response.cancellation().amount() != amount) { + throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "포트원 결제 취소 응답 검증에 실패했습니다."); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java index 2ddeca1c..8975083f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.gateway; +import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; @@ -8,7 +10,10 @@ import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundStatus; import com.jobdri.jobdri_api.domain.payment.service.TossPayClient; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -56,10 +61,46 @@ public GatewayPaymentSnapshot fetch(GatewayPaymentQuery query) { @Override public GatewayRefundResult refund(GatewayRefundCommand command) { - throw unsupported(); + if (isBlank(command.payToken())) { + throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "토스페이 payToken이 없는 결제는 환불할 수 없습니다."); + } + String refundNo = refundNo(command.paymentId()); + TossPayRefundResponse response = tossPayClient.refundPayment( + command.payToken(), + command.orderId(), + refundNo, + command.amount(), + command.reason() + ); + validateRefundResponse(response, command, refundNo); + return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, response.payStatus()); } private UnsupportedOperationException unsupported() { - return new UnsupportedOperationException("TossPayPaymentGateway currently supports prepare only."); + return new UnsupportedOperationException("TossPayPaymentGateway currently supports prepare and refund only."); + } + + private void validateRefundResponse( + TossPayRefundResponse response, + GatewayRefundCommand command, + String refundNo + ) { + if (response == null + || !response.successful() + || !refundNo.equals(response.refundNo()) + || !command.payToken().equals(response.payToken()) + || !TossPayStatus.REFUND_SUCCESS.name().equals(response.payStatus()) + || response.refundedAmount() == null + || response.refundedAmount() != command.amount()) { + throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "토스페이 환불 응답 검증에 실패했습니다."); + } + } + + private String refundNo(Long paymentId) { + return "jobdri-refund-" + paymentId; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 70d1d467..1f977c50 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -7,9 +7,9 @@ import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneWebhookPayload; import com.jobdri.jobdri_api.domain.payment.dto.request.PortOnePaymentCompleteRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; @@ -17,7 +17,6 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; @@ -272,7 +271,15 @@ public PaymentRefundResponse refund(User user, Long paymentId, PaymentRefundRequ ); } try { - RefundExternalResult result = refundExternalPayment(start, reason); + GatewayRefundResult result = resolveGateway(start.provider()).refund(new GatewayRefundCommand( + start.paymentId(), + start.provider(), + start.orderId(), + start.externalPaymentId(), + start.payToken(), + start.amount(), + reason + )); return paymentTransactionService.completeRefund( start.paymentId(), start.provider(), @@ -470,76 +477,6 @@ private void validateTossPayStatusResponse(TossPayStatusResponse response, Strin } } - private RefundExternalResult refundExternalPayment( - PaymentTransactionService.PaymentRefundStart start, - String reason - ) { - return switch (start.provider()) { - case PORTONE -> refundPortOnePayment(start, reason); - case TOSS_PAY_DIRECT -> refundTossPayPayment(start, reason); - default -> throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "지원하지 않는 결제수단입니다."); - }; - } - - private RefundExternalResult refundPortOnePayment(PaymentTransactionService.PaymentRefundStart start, String reason) { - if (isBlank(start.externalPaymentId())) { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "포트원 paymentId가 없는 결제는 환불할 수 없습니다."); - } - PortOneCancelResponse response = portOneClient.cancelPayment(start.externalPaymentId(), start.amount(), reason); - validatePortOneRefundResponse(response, start); - return new RefundExternalResult("CANCELLED"); - } - - private RefundExternalResult refundTossPayPayment(PaymentTransactionService.PaymentRefundStart start, String reason) { - if (isBlank(start.payToken())) { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "토스페이 payToken이 없는 결제는 환불할 수 없습니다."); - } - String refundNo = tossPayRefundNo(start.paymentId()); - TossPayRefundResponse response = tossPayClient.refundPayment( - start.payToken(), - start.orderId(), - refundNo, - start.amount(), - reason - ); - validateTossPayRefundResponse(response, start, refundNo); - return new RefundExternalResult(response.payStatus()); - } - - private void validatePortOneRefundResponse( - PortOneCancelResponse response, - PaymentTransactionService.PaymentRefundStart start - ) { - if (response == null - || response.cancellation() == null - || isBlank(response.cancellation().id()) - || !"SUCCEEDED".equals(response.cancellation().status()) - || response.cancellation().amount() == null - || response.cancellation().amount() != start.amount()) { - throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "포트원 결제 취소 응답 검증에 실패했습니다."); - } - } - - private void validateTossPayRefundResponse( - TossPayRefundResponse response, - PaymentTransactionService.PaymentRefundStart start, - String refundNo - ) { - if (response == null - || !response.successful() - || !refundNo.equals(response.refundNo()) - || !start.payToken().equals(response.payToken()) - || !TossPayStatus.REFUND_SUCCESS.name().equals(response.payStatus()) - || response.refundedAmount() == null - || response.refundedAmount() != start.amount()) { - throw new GeneralException(GeneralErrorCode.PAYMENT_REFUND_FAILED, "토스페이 환불 응답 검증에 실패했습니다."); - } - } - - private String tossPayRefundNo(Long paymentId) { - return "jobdri-refund-" + paymentId; - } - private String normalizeRefundReason(String reason) { if (isBlank(reason)) { return DEFAULT_REFUND_REASON; @@ -560,7 +497,4 @@ public boolean shouldAcknowledgeTossPayCallbackFailure(Exception exception) { private boolean isBlank(String value) { return value == null || value.isBlank(); } - - private record RefundExternalResult(String externalStatus) { - } } From dad51db4f5c9ee30d8090dbcbdea4f751939d701 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:44:55 +0900 Subject: [PATCH 09/55] =?UTF-8?q?[Refactor]=20PaymentTransactionService?= =?UTF-8?q?=EB=A5=BC=20=EB=82=B4=EB=B6=80=20=EC=83=81=ED=83=9C=20=EC=A0=84?= =?UTF-8?q?=EC=9D=B4=20=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 35 +++ .../service/PaymentTransactionService.java | 236 +++++++++--------- 2 files changed, 158 insertions(+), 113 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index a0286a0a..5e7e6e39 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -228,6 +228,41 @@ public void refundByPortOne(String externalStatus, String refundReason) { this.lastStatusCheckedAt = LocalDateTime.now(); } + public void completeByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> completeByTossPay(providerStatus); + case PORTONE -> completeByPortOne(providerStatus, providerTransactionId); + } + } + + public void failByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> failByTossPay(providerStatus); + case PORTONE -> failByPortOne(providerStatus, providerTransactionId); + } + } + + public void markUnknownByProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> updateTossStatus(providerStatus); + case PORTONE -> markPortOneUnknown(providerStatus, providerTransactionId); + } + } + + public void updateProviderStatus(String providerStatus, String providerTransactionId) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> updateTossStatus(providerStatus); + case PORTONE -> updatePortOneStatus(providerStatus, providerTransactionId); + } + } + + public void refundByProviderStatus(String providerStatus, String refundReason) { + switch (getProviderOrDefault()) { + case TOSS_PAY_DIRECT -> refundByTossPay(providerStatus, refundReason); + case PORTONE -> refundByPortOne(providerStatus, refundReason); + } + } + private void validateRefundableStatus() { if (this.status != PaymentStatus.COMPLETED) { throw new IllegalStateException("완료된 결제만 환불 상태로 변경할 수 있습니다."); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index 9d3a21ed..a99236ed 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentStatus; import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentOrderStatusResponse; @@ -10,7 +11,6 @@ import com.jobdri.jobdri_api.domain.payment.entity.Payment; import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.PortOnePaymentStatus; import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -183,13 +183,7 @@ public PaymentRefundResponse completeRefund( if (!payment.isProvider(provider)) { throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "환불 요청 결제수단이 일치하지 않습니다."); } - if (provider == PaymentProviderType.PORTONE) { - payment.refundByPortOne(externalStatus, reason); - } else if (provider == PaymentProviderType.TOSS_PAY_DIRECT) { - payment.refundByTossPay(externalStatus, reason); - } else { - throw new GeneralException(GeneralErrorCode.PAYMENT_NOT_REFUNDABLE, "지원하지 않는 결제수단입니다."); - } + payment.refundByProviderStatus(externalStatus, reason); int creditBalance = creditService.use( user, payment.getCreditAmount(), @@ -217,40 +211,22 @@ public PaymentConfirmResponse applyTossPayStatus(String orderId, String payToken "결제 정보를 찾을 수 없습니다. orderId=" + orderId )); validateTossPayStatus(payment, payToken, amount); - - if (payment.getStatus() == PaymentStatus.REFUNDED) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - if (payment.getStatus() == PaymentStatus.COMPLETED) { - if (tossPayStatus == TossPayStatus.PAY_CANCEL) { - payment.updateTossStatus(tossPayStatus.name()); - } - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } + GatewayPaymentStatus normalizedStatus = normalizeTossPayStatus(tossPayStatus); if (payment.getStatus() == PaymentStatus.FAILED) { throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "처리할 수 없는 결제 상태입니다."); } - - if (tossPayStatus == TossPayStatus.PAY_COMPLETE) { - User user = userService.getUser(payment.getUser().getId()); - payment.completeByTossPay(tossPayStatus.name()); - int creditBalance = creditService.charge( - user, - payment.getCreditAmount(), - payment.getContent(), - payment.getOrderId() - ); - return PaymentConfirmResponse.of(payment, creditBalance); - } - - if (tossPayStatus == TossPayStatus.PAY_CANCEL) { - payment.failByTossPay(tossPayStatus.name()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + if (payment.getStatus() == PaymentStatus.COMPLETED && normalizedStatus != GatewayPaymentStatus.CANCELED) { + return currentConfirmResponse(payment); } - payment.updateTossStatus(tossPayStatus.name()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + return applyGatewayPaymentTransition( + payment, + normalizedStatus, + tossPayStatus.name(), + null, + chargeReferenceId(payment) + ); } @Transactional @@ -275,49 +251,7 @@ private PaymentConfirmResponse applyPortOnePayment( boolean requireOwner ) { validatePortOneResponseMinimum(response); - Payment payment = paymentRepository.findByOrderIdForUpdate(response.id()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.PAYMENT_NOT_FOUND, - "결제 정보를 찾을 수 없습니다. paymentId=" + response.id() - )); - if (requireOwner && !payment.belongsTo(userId)) { - throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); - } - validatePortOnePayment(payment, response, expectedStoreId); - - PortOnePaymentStatus portOneStatus = PortOnePaymentStatus.from(response.status()); - if (payment.getStatus() == PaymentStatus.REFUNDED) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - if (payment.getStatus() == PaymentStatus.COMPLETED) { - payment.updatePortOneStatus(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.PAID) { - User user = userService.getUser(payment.getUser().getId()); - payment.completeByPortOne(response.status(), response.transactionId()); - int creditBalance = creditService.charge( - user, - payment.getCreditAmount(), - payment.getContent(), - "PAYMENT:PORTONE:" + payment.getOrderId() - ); - return PaymentConfirmResponse.of(payment, creditBalance); - } - - if (portOneStatus == PortOnePaymentStatus.FAILED || portOneStatus == PortOnePaymentStatus.CANCELLED) { - payment.failByPortOne(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.UNKNOWN || portOneStatus == PortOnePaymentStatus.PARTIAL_CANCELLED) { - payment.markPortOneUnknown(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - payment.updatePortOneStatus(response.status(), response.transactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + return applyPortOnePayment(userId, toGatewaySnapshot(response), expectedStoreId, requireOwner); } private PaymentConfirmResponse applyPortOnePayment( @@ -336,40 +270,13 @@ private PaymentConfirmResponse applyPortOnePayment( throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다."); } validatePortOnePayment(payment, snapshot, expectedStoreId); - - PortOnePaymentStatus portOneStatus = PortOnePaymentStatus.from(snapshot.externalStatus()); - if (payment.getStatus() == PaymentStatus.REFUNDED) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - if (payment.getStatus() == PaymentStatus.COMPLETED) { - payment.updatePortOneStatus(snapshot.externalStatus(), snapshot.externalTransactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.PAID) { - User user = userService.getUser(payment.getUser().getId()); - payment.completeByPortOne(snapshot.externalStatus(), snapshot.externalTransactionId()); - int creditBalance = creditService.charge( - user, - payment.getCreditAmount(), - payment.getContent(), - "PAYMENT:PORTONE:" + payment.getOrderId() - ); - return PaymentConfirmResponse.of(payment, creditBalance); - } - - if (portOneStatus == PortOnePaymentStatus.FAILED || portOneStatus == PortOnePaymentStatus.CANCELLED) { - payment.failByPortOne(snapshot.externalStatus(), snapshot.externalTransactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - if (portOneStatus == PortOnePaymentStatus.UNKNOWN || portOneStatus == PortOnePaymentStatus.PARTIAL_CANCELLED) { - payment.markPortOneUnknown(snapshot.externalStatus(), snapshot.externalTransactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); - } - - payment.updatePortOneStatus(snapshot.externalStatus(), snapshot.externalTransactionId()); - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + return applyGatewayPaymentTransition( + payment, + snapshot.status(), + snapshot.externalStatus(), + snapshot.externalTransactionId(), + chargeReferenceId(payment) + ); } @Transactional @@ -456,6 +363,109 @@ private void validatePortOneSnapshotMinimum(GatewayPaymentSnapshot snapshot) { } } + private PaymentConfirmResponse applyGatewayPaymentTransition( + Payment payment, + GatewayPaymentStatus normalizedStatus, + String providerStatus, + String providerTransactionId, + String chargeReferenceId + ) { + if (payment.getStatus() == PaymentStatus.REFUNDED) { + return currentConfirmResponse(payment); + } + if (payment.getStatus() == PaymentStatus.COMPLETED) { + payment.updateProviderStatus(providerStatus, providerTransactionId); + return currentConfirmResponse(payment); + } + + return switch (normalizedStatus) { + case COMPLETED -> PaymentConfirmResponse.of( + payment, + completeAndCharge(payment, providerStatus, providerTransactionId, chargeReferenceId) + ); + case FAILED, CANCELED -> { + payment.failByProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment); + } + case UNKNOWN -> { + payment.markUnknownByProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment); + } + default -> { + payment.updateProviderStatus(providerStatus, providerTransactionId); + yield currentConfirmResponse(payment); + } + }; + } + + private int completeAndCharge( + Payment payment, + String providerStatus, + String providerTransactionId, + String chargeReferenceId + ) { + User user = userService.getUser(payment.getUser().getId()); + payment.completeByProviderStatus(providerStatus, providerTransactionId); + return creditService.charge( + user, + payment.getCreditAmount(), + payment.getContent(), + chargeReferenceId + ); + } + + private PaymentConfirmResponse currentConfirmResponse(Payment payment) { + return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + } + + private String chargeReferenceId(Payment payment) { + if (payment.isProvider(PaymentProviderType.PORTONE)) { + return "PAYMENT:PORTONE:" + payment.getOrderId(); + } + return payment.getOrderId(); + } + + private GatewayPaymentSnapshot toGatewaySnapshot(PortOnePaymentResponse response) { + return new GatewayPaymentSnapshot( + PaymentProviderType.PORTONE, + normalizePortOneStatus(response.status()), + response.id(), + null, + null, + response.id(), + response.transactionId(), + response.status(), + response.storeId(), + response.currency(), + response.amount() == null ? null : response.amount().total() + ); + } + + private GatewayPaymentStatus normalizeTossPayStatus(TossPayStatus tossPayStatus) { + if (tossPayStatus == null) { + return GatewayPaymentStatus.UNKNOWN; + } + return switch (tossPayStatus) { + case PAY_COMPLETE -> GatewayPaymentStatus.COMPLETED; + case PAY_CANCEL -> GatewayPaymentStatus.CANCELED; + case PAY_APPROVED, REFUND_SUCCESS -> GatewayPaymentStatus.APPROVED; + }; + } + + private GatewayPaymentStatus normalizePortOneStatus(String externalStatus) { + if (externalStatus == null || externalStatus.isBlank()) { + return GatewayPaymentStatus.UNKNOWN; + } + return switch (externalStatus.toUpperCase()) { + case "PAID" -> GatewayPaymentStatus.COMPLETED; + case "FAILED", "CANCELLED" -> GatewayPaymentStatus.FAILED; + case "PARTIAL_CANCELLED", "UNKNOWN" -> GatewayPaymentStatus.UNKNOWN; + case "READY", "PENDING", "VIRTUAL_ACCOUNT_ISSUED", "PAY_PENDING", "CANCEL_PENDING" -> + GatewayPaymentStatus.PENDING; + default -> GatewayPaymentStatus.UNKNOWN; + }; + } + private void validatePortOnePayment(Payment payment, PortOnePaymentResponse response, String expectedStoreId) { if (!payment.isProvider(PaymentProviderType.PORTONE)) { throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "포트원 결제 건이 아닙니다."); From e91bb7dbb738cbb68d42070603775102c6d83638 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 12:49:25 +0900 Subject: [PATCH 10/55] =?UTF-8?q?[Feat]=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EC=A7=80=EC=9B=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 15 ++++++ .../CreditTransactionRepository.java | 4 ++ .../payment/service/PaymentService.java | 29 ++++++++++ .../payment/service/PaymentServiceTest.java | 54 +++++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java index 0573b67b..f7e65ed1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java @@ -17,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -160,4 +161,18 @@ public ApiResponse> getTransactions( paymentService.getTransactions(userDetails.getUser(), type) ); } + + @Operation(summary = "내 크레딧 거래 내역 페이징 조회", description = "크레딧 거래 내역을 페이지 단위로 조회합니다. type query parameter로 필터링할 수 있습니다.") + @GetMapping("/credits/me/transactions/page") + public ApiResponse> getTransactionsPage( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(required = false) CreditTransactionType type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + "크레딧 거래 내역 페이징 조회에 성공했습니다.", + paymentService.getTransactionsPage(userDetails.getUser(), type, page, size) + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java index b2c57827..1a2eb8ac 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java @@ -2,6 +2,8 @@ import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,5 +12,7 @@ public interface CreditTransactionRepository extends JpaRepository { List findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId); List findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type); + Page findAllByUserId(Long userId, Pageable pageable); + Page findAllByUserIdAndType(Long userId, CreditTransactionType type, Pageable pageable); Optional findByUserIdAndTypeAndReferenceId(Long userId, CreditTransactionType type, String referenceId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 1f977c50..808a3b8f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -33,8 +33,13 @@ import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; +import com.jobdri.jobdri_api.global.pagination.PaginationPolicy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -51,6 +56,7 @@ @Transactional(readOnly = true) public class PaymentService { + private static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE; private static final String EXPECTED_PAYMENT_METHOD = "간편결제"; private static final String EXPECTED_EASY_PAY_PROVIDER = "토스페이"; private static final String ORDER_ID_PREFIX = "jobdri-"; @@ -396,6 +402,29 @@ public List getTransactions(User user, CreditTransact .toList(); } + public Page getTransactionsPage( + User user, + CreditTransactionType type, + int page, + int size + ) { + User validatedUser = userService.validateUser(user); + Pageable pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), MAX_PAGE_SIZE), + Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ) + ); + if (type == null) { + return creditTransactionRepository.findAllByUserId(validatedUser.getId(), pageable) + .map(CreditTransactionResponse::from); + } + return creditTransactionRepository.findAllByUserIdAndType(validatedUser.getId(), type, pageable) + .map(CreditTransactionResponse::from); + } + private void validateTossResponse(Long userId, PaymentConfirmRequest request, TossPaymentConfirmResponse response) { if (response == null || !request.orderId().equals(response.orderId()) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index b8d500a7..5e800418 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -20,6 +20,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.controller.PaymentController; import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.entity.Payment; import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; @@ -35,6 +36,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.ActiveProfiles; @@ -1220,6 +1222,40 @@ void portOneWebhookPropagatesTimeout() { assertPaymentPendingWithoutCreditCharge(user, prepared.orderId()); } + @Test + @DisplayName("크레딧 거래 내역을 페이지 단위로 최신순 조회한다") + void getTransactionsPage() { + User user = saveUser("payment-transactions-page@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "첫 충전", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 1, "사용", "use-1"); + saveCreditTransaction(user, CreditTransactionType.COUPON, 1, 2, "쿠폰", "coupon-1"); + + Page firstPage = paymentService.getTransactionsPage(user, null, 0, 2); + Page secondPage = paymentService.getTransactionsPage(user, null, 1, 2); + + assertThat(firstPage.getTotalElements()).isEqualTo(3); + assertThat(firstPage.getContent()).extracting("referenceId") + .containsExactly("coupon-1", "use-1"); + assertThat(secondPage.getContent()).extracting("referenceId") + .containsExactly("charge-1"); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 조회는 타입 필터와 최대 페이지 크기 제한을 적용한다") + void getTransactionsPageWithTypeFilter() { + User user = saveUser("payment-transactions-page-filter@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "충전 1", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 5, 7, "충전 2", "charge-2"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 6, "사용", "use-1"); + + Page page = paymentService.getTransactionsPage(user, CreditTransactionType.CHARGE, 0, 999); + + assertThat(page.getSize()).isEqualTo(100); + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting("referenceId") + .containsExactly("charge-2", "charge-1"); + } + @Test @DisplayName("결제 주문 상태 조회는 소유자의 주문만 반환한다") void getOrderStatus() { @@ -1298,6 +1334,24 @@ private void mockPortOneCancel(Payment payment, String reason) { ))); } + private CreditTransaction saveCreditTransaction( + User user, + CreditTransactionType type, + int amount, + int balanceAfter, + String description, + String referenceId + ) { + return creditTransactionRepository.save(CreditTransaction.create( + user, + type, + amount, + balanceAfter, + description, + referenceId + )); + } + private String portOneWebhookBody(String paymentId) { return """ {"type":"Transaction.Paid","timestamp":"2026-07-30T10:00:00.000Z","data":{"paymentId":"%s","storeId":"store-test","transactionId":"transaction-%s"}} From e7b55a43bfd9193a94e8c5684c1fca9e77b59959 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:01:57 +0900 Subject: [PATCH 11/55] =?UTF-8?q?[Refactor]=20payment=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 2 +- .../{ => external}/portone/PortOneAmount.java | 2 +- .../portone/PortOneCancelRequest.java | 2 +- .../portone/PortOneCancelResponse.java | 2 +- .../portone/PortOneCancellation.java | 2 +- .../portone/PortOnePaymentResponse.java | 2 +- .../portone/PortOnePrepareData.java | 2 +- .../portone/PortOneWebhookPayload.java | 2 +- .../{ => external}/toss/TossEasyPayInfo.java | 2 +- .../toss/TossPaymentConfirmRequest.java | 2 +- .../toss/TossPaymentConfirmResponse.java | 2 +- .../tosspay/TossPayCreateRequest.java | 2 +- .../tosspay/TossPayCreateResponse.java | 2 +- .../tosspay/TossPayRefundRequest.java | 2 +- .../tosspay/TossPayRefundResponse.java | 2 +- .../tosspay/TossPayStatusRequest.java | 2 +- .../tosspay/TossPayStatusResponse.java | 2 +- .../dto/response/CreditPlanResponse.java | 2 +- .../response/CreditTransactionResponse.java | 2 +- .../dto/response/PaymentConfirmResponse.java | 2 +- .../response/PaymentOrderStatusResponse.java | 4 +-- .../dto/response/PaymentPrepareResponse.java | 2 +- .../dto/response/PaymentRefundResponse.java | 4 +-- .../payment/entity/CreditTransaction.java | 1 + .../domain/payment/entity/Payment.java | 2 ++ .../payment/gateway/PaymentGateway.java | 16 +++++----- .../gateway/PortOnePaymentGateway.java | 22 +++++++------- .../gateway/TossPayPaymentGateway.java | 22 +++++++------- .../GatewayConfirmCommand.java | 2 +- .../GatewayPrepareCommand.java | 4 +-- .../GatewayRefundCommand.java | 4 +-- .../gateway/model/GatewayRefundResult.java | 10 ------- .../{model => query}/GatewayPaymentQuery.java | 2 +- .../GatewayPaymentSnapshot.java | 5 ++-- .../GatewayPrepareResult.java | 4 +-- .../gateway/result/GatewayRefundResult.java | 11 +++++++ .../{model => type}/GatewayPaymentStatus.java | 2 +- .../{model => type}/GatewayRefundStatus.java | 2 +- .../CreditTransactionRepository.java | 2 +- .../payment/repository/PaymentRepository.java | 2 +- .../domain/payment/service/CreditService.java | 2 +- .../payment/service/PaymentService.java | 30 +++++++++---------- .../service/PaymentTransactionService.java | 14 ++++----- .../domain/payment/service/PortOneClient.java | 8 ++--- .../domain/payment/service/TossPayClient.java | 12 ++++---- .../payment/service/TossPaymentClient.java | 4 +-- .../payment/{entity => type}/CreditPlan.java | 2 +- .../CreditTransactionType.java | 2 +- .../{entity => type}/PaymentProviderType.java | 2 +- .../{entity => type}/PaymentStatus.java | 2 +- .../PortOnePaymentStatus.java | 2 +- .../{entity => type}/TossPayStatus.java | 2 +- .../service/core/AnalysisServiceTest.java | 2 +- .../payment/service/CouponServiceTest.java | 2 +- .../payment/service/CreditServiceTest.java | 2 +- .../payment/service/PaymentServiceTest.java | 30 +++++++++---------- .../payment/service/PortOneClientTest.java | 2 +- .../payment/service/TossPayClientTest.java | 4 +-- 58 files changed, 146 insertions(+), 141 deletions(-) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOneAmount.java (71%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOneCancelRequest.java (75%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOneCancelResponse.java (74%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOneCancellation.java (76%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOnePaymentResponse.java (81%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOnePrepareData.java (63%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/portone/PortOneWebhookPayload.java (86%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/toss/TossEasyPayInfo.java (72%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/toss/TossPaymentConfirmRequest.java (80%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/toss/TossPaymentConfirmResponse.java (84%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayCreateRequest.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayCreateResponse.java (87%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayRefundRequest.java (82%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayRefundResponse.java (87%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayStatusRequest.java (62%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/dto/{ => external}/tosspay/TossPayStatusResponse.java (86%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => command}/GatewayConfirmCommand.java (64%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => command}/GatewayPrepareCommand.java (61%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => command}/GatewayRefundCommand.java (63%) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => query}/GatewayPaymentQuery.java (67%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => result}/GatewayPaymentSnapshot.java (63%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => result}/GatewayPrepareResult.java (67%) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => type}/GatewayPaymentStatus.java (69%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/{model => type}/GatewayRefundStatus.java (55%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/CreditPlan.java (95%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/CreditTransactionType.java (61%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/PaymentProviderType.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/PaymentStatus.java (73%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/PortOnePaymentStatus.java (90%) rename src/main/java/com/jobdri/jobdri_api/domain/payment/{entity => type}/TossPayStatus.java (65%) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java index f7e65ed1..c8ba1cfd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/controller/PaymentController.java @@ -7,7 +7,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PortOnePaymentCompleteRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.service.CouponService; import com.jobdri.jobdri_api.domain.payment.service.PaymentService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java similarity index 71% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java index fa493adb..29a87b4d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneAmount.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneAmount.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java similarity index 75% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java index 5569258b..61102005 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java similarity index 74% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java index 4de357ee..13bb5d15 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancelResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancelResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java similarity index 76% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java index 49355eb1..b66ec347 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneCancellation.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneCancellation.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java index f8cb7ea0..9d998dda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePaymentResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePaymentResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java index e49508a3..f05f6a2c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOnePrepareData.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOnePrepareData.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; public record PortOnePrepareData( String storeId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java similarity index 86% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java index 8eed1c88..3ae4515c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/portone/PortOneWebhookPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/portone/PortOneWebhookPayload.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.portone; +package com.jobdri.jobdri_api.domain.payment.dto.external.portone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java similarity index 72% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java index b5132025..00723fb5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossEasyPayInfo.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossEasyPayInfo.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java similarity index 80% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java index ae15c03a..5f4f0692 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java similarity index 84% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java index 91bb8031..74c64f31 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/toss/TossPaymentConfirmResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.toss; +package com.jobdri.jobdri_api.domain.payment.dto.external.toss; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java index f799588c..34451996 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; public record TossPayCreateRequest( String orderNo, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java index f8628675..f4c64ca3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayCreateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayCreateResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java similarity index 82% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java index 683ea842..de735c9e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java index 51ce6059..4ec96d50 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayRefundResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayRefundResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java similarity index 62% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java index 96ccadf6..353e7d84 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; public record TossPayStatusRequest( String apiKey, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java similarity index 86% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java index 522b07f7..6ff8cd54 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/tosspay/TossPayStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/external/tosspay/TossPayStatusResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.dto.tosspay; +package com.jobdri.jobdri_api.domain.payment.dto.external.tosspay; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java index 88898c8e..b437a911 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditPlanResponse.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; public record CreditPlanResponse( String planCode, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java index c1f91269..1eb337fa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/CreditTransactionResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import java.time.LocalDateTime; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java index 465ff1ec..935448a9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentConfirmResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentConfirmResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java index c37ad0b9..99d19986 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentOrderStatusResponse.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentOrderStatusResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java index 57cba441..2a2db39b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; public record PaymentPrepareResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java index 2eaba9a7..09df0d86 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentRefundResponse.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.payment.dto.response; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; public record PaymentRefundResponse( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java index 84ac802c..c18e23b6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.entity; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.entity.CreatedAtEntity; import jakarta.persistence.*; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 5e7e6e39..1c625f14 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.entity; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.entity.BaseEntity; import jakarta.persistence.*; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java index 2d892f12..d8426046 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PaymentGateway.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.payment.gateway; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; /** * Normalized payment gateway contract used to isolate provider-specific DTOs diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java index 4eed8384..f4ebeff1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java @@ -1,16 +1,16 @@ package com.jobdri.jobdri_api.domain.payment.gateway; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentStatus; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundStatus; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; import com.jobdri.jobdri_api.domain.payment.service.PortOneClient; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java index 8975083f..e8916e96 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/TossPayPaymentGateway.java @@ -1,16 +1,16 @@ package com.jobdri.jobdri_api.domain.payment.gateway; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayConfirmCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundStatus; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayConfirmCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; import com.jobdri.jobdri_api.domain.payment.service.TossPayClient; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java similarity index 64% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java index c95427e9..961ba765 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayConfirmCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayConfirmCommand.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.command; public record GatewayConfirmCommand( String orderId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java similarity index 61% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java index 941e23a6..2f14beb4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayPrepareCommand.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.command; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; public record GatewayPrepareCommand( PaymentProviderType provider, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java index ba030831..70a93b10 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundCommand.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/command/GatewayRefundCommand.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.command; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; public record GatewayRefundCommand( Long paymentId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java deleted file mode 100644 index 96619006..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundResult.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; - -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; - -public record GatewayRefundResult( - PaymentProviderType provider, - GatewayRefundStatus status, - String externalStatus -) { -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java similarity index 67% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java index 29ccdce7..2f5117c5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentQuery.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/query/GatewayPaymentQuery.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.query; public record GatewayPaymentQuery( String orderId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java index 5e2cf80e..8bba7d05 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentSnapshot.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPaymentSnapshot.java @@ -1,6 +1,7 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.result; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; public record GatewayPaymentSnapshot( PaymentProviderType provider, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java similarity index 67% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java index 66abea34..1d3c8742 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPrepareResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayPrepareResult.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.result; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; public record GatewayPrepareResult( PaymentProviderType provider, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java new file mode 100644 index 00000000..197afa7a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/result/GatewayRefundResult.java @@ -0,0 +1,11 @@ +package com.jobdri.jobdri_api.domain.payment.gateway.result; + +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayRefundStatus; + +public record GatewayRefundResult( + PaymentProviderType provider, + GatewayRefundStatus status, + String externalStatus +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java similarity index 69% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java index b2691b4c..6ec3c4ed 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayPaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.type; public enum GatewayPaymentStatus { PENDING, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java similarity index 55% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java index 1df88afb..5f9501d5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/model/GatewayRefundStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayRefundStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.gateway.model; +package com.jobdri.jobdri_api.domain.payment.gateway.type; public enum GatewayRefundStatus { SUCCEEDED, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java index 1a2eb8ac..f6251c68 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.repository; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java index c11fc685..d03f8fea 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/repository/PaymentRepository.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.repository; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java index 9ec8c8d2..7b89c4ff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.service; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java index 808a3b8f..17a2b3ea 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java @@ -3,28 +3,28 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.payment.gateway.PaymentGateway; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentQuery; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPrepareResult; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundCommand; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayRefundResult; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneWebhookPayload; +import com.jobdri.jobdri_api.domain.payment.gateway.query.GatewayPaymentQuery; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayPrepareCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPrepareResult; +import com.jobdri.jobdri_api.domain.payment.gateway.command.GatewayRefundCommand; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayRefundResult; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneWebhookPayload; import com.jobdri.jobdri_api.domain.payment.dto.request.PortOnePaymentCompleteRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.TossPayCallbackRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.*; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.entity.UserRole; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index a99236ed..f70811a4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -1,17 +1,17 @@ package com.jobdri.jobdri_api.domain.payment.service; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentStatus; -import com.jobdri.jobdri_api.domain.payment.gateway.model.GatewayPaymentSnapshot; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; +import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentOrderStatusResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentRefundResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java index 3dd1c8cf..91d84b74 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClient.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelRequest; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePrepareData; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java index 3923ab4a..c7e391b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClient.java @@ -1,11 +1,11 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusRequest; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java index 39713480..fdcdbbf5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/TossPaymentClient.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmRequest; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmRequest; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.jobdri.jobdri_api.global.logging.LoggingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java index 6256b569..1959039f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditPlan.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditPlan.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java similarity index 61% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java index 64575358..2d61aa1f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransactionType.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/CreditTransactionType.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum CreditTransactionType { CHARGE, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java index 291ce590..b540a3db 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentProviderType.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentProviderType.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java similarity index 73% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java index 5e75072c..754d9563 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PaymentStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; import lombok.Getter; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java similarity index 90% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java index c12e9835..e89ebdd4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PortOnePaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/PortOnePaymentStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum PortOnePaymentStatus { READY, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java similarity index 65% rename from src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java index 2db90ae2..c0fae5e8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/TossPayStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/type/TossPayStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.payment.entity; +package com.jobdri.jobdri_api.domain.payment.type; public enum TossPayStatus { PAY_COMPLETE, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index 7739f4c9..d26e43fc 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -29,7 +29,7 @@ import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java index d5cb25a7..3c5ffcd8 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CouponServiceTest.java @@ -2,7 +2,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.CouponRedeemRequest; import com.jobdri.jobdri_api.domain.payment.dto.response.CouponRedeemResponse; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CouponRedemptionRepository; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java index 2df4d58e..5e01ba4a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/CreditServiceTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.repository.UserRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 5e800418..53f9fffb 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -1,10 +1,10 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancelResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneCancellation; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOneAmount; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePrepareData; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancelResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneCancellation; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOneAmount; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePrepareData; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest; import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentRefundRequest; @@ -13,19 +13,19 @@ import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentPrepareResponse; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayCreateResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayRefundResponse; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossEasyPayInfo; -import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayCreateResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayRefundResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossEasyPayInfo; +import com.jobdri.jobdri_api.domain.payment.dto.external.toss.TossPaymentConfirmResponse; import com.jobdri.jobdri_api.domain.payment.controller.PaymentController; -import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan; +import com.jobdri.jobdri_api.domain.payment.type.CreditPlan; import com.jobdri.jobdri_api.domain.payment.entity.CreditTransaction; -import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType; +import com.jobdri.jobdri_api.domain.payment.type.CreditTransactionType; import com.jobdri.jobdri_api.domain.payment.entity.Payment; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentProviderType; -import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus; -import com.jobdri.jobdri_api.domain.payment.entity.TossPayStatus; +import com.jobdri.jobdri_api.domain.payment.type.PaymentProviderType; +import com.jobdri.jobdri_api.domain.payment.type.PaymentStatus; +import com.jobdri.jobdri_api.domain.payment.type.TossPayStatus; import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository; import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository; import com.jobdri.jobdri_api.domain.user.entity.User; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java index a8aea923..d7120bd1 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PortOneClientTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java index dfea0c37..8505322b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/TossPayClientTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.payment.service; -import com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusResponse; +import com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusResponse; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.BeforeEach; @@ -113,7 +113,7 @@ void getPaymentStatusDoesNotRequireCallbackUrls() { when(restClient.post()).thenReturn(requestBodyUriSpec); when(requestBodyUriSpec.uri("/api/v2/status")).thenReturn(requestBodyUriSpec); when(requestBodyUriSpec.contentType(MediaType.APPLICATION_JSON)).thenReturn(requestBodyUriSpec); - when(requestBodyUriSpec.body(new com.jobdri.jobdri_api.domain.payment.dto.tosspay.TossPayStatusRequest( + when(requestBodyUriSpec.body(new com.jobdri.jobdri_api.domain.payment.dto.external.tosspay.TossPayStatusRequest( "test-api-key", "pay-token-1", "order-1" From 2856c46c932afb7afcff140e829a5b22cd1a37f4 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:27:37 +0900 Subject: [PATCH 12/55] =?UTF-8?q?[Fix]=20=EA=B2=B0=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=EC=9D=B4=EC=99=80=20=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EB=B3=B4=EA=B0=95=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 14 ++- .../gateway/PortOnePaymentGateway.java | 12 +- .../gateway/type/GatewayPaymentStatus.java | 1 + .../service/PaymentTransactionService.java | 63 ++++++----- ..._add_credit_transaction_paging_indexes.sql | 5 + src/main/resources/schema.sql | 6 + .../payment/service/PaymentServiceTest.java | 106 ++++++++++++++++++ 7 files changed, 174 insertions(+), 33 deletions(-) create mode 100644 src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java index 1c625f14..57d9425f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java @@ -234,6 +234,7 @@ public void completeByProviderStatus(String providerStatus, String providerTrans switch (getProviderOrDefault()) { case TOSS_PAY_DIRECT -> completeByTossPay(providerStatus); case PORTONE -> completeByPortOne(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 완료"); } } @@ -241,13 +242,18 @@ public void failByProviderStatus(String providerStatus, String providerTransacti switch (getProviderOrDefault()) { case TOSS_PAY_DIRECT -> failByTossPay(providerStatus); case PORTONE -> failByPortOne(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 실패"); } } public void markUnknownByProviderStatus(String providerStatus, String providerTransactionId) { switch (getProviderOrDefault()) { - case TOSS_PAY_DIRECT -> updateTossStatus(providerStatus); + case TOSS_PAY_DIRECT -> { + markTossPayUnknown(); + updateTossStatus(providerStatus); + } case PORTONE -> markPortOneUnknown(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 상태 미확정"); } } @@ -255,6 +261,7 @@ public void updateProviderStatus(String providerStatus, String providerTransacti switch (getProviderOrDefault()) { case TOSS_PAY_DIRECT -> updateTossStatus(providerStatus); case PORTONE -> updatePortOneStatus(providerStatus, providerTransactionId); + default -> throw unsupportedProvider("결제 상태 갱신"); } } @@ -262,6 +269,7 @@ public void refundByProviderStatus(String providerStatus, String refundReason) { switch (getProviderOrDefault()) { case TOSS_PAY_DIRECT -> refundByTossPay(providerStatus, refundReason); case PORTONE -> refundByPortOne(providerStatus, refundReason); + default -> throw unsupportedProvider("결제 환불"); } } @@ -295,4 +303,8 @@ public PaymentProviderType getProviderOrDefault() { public boolean isProvider(PaymentProviderType provider) { return getProviderOrDefault() == provider; } + + private IllegalStateException unsupportedProvider(String action) { + return new IllegalStateException("지원하지 않는 결제 제공자입니다. action=" + action + ", provider=" + getProviderOrDefault()); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java index f4ebeff1..13fdcc7a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/PortOnePaymentGateway.java @@ -21,6 +21,8 @@ @RequiredArgsConstructor public class PortOnePaymentGateway implements PaymentGateway { + public static final String CANCELED_EXTERNAL_STATUS = "CANCELLED"; + private final PortOneClient portOneClient; @Override @@ -77,18 +79,20 @@ public GatewayRefundResult refund(GatewayRefundCommand command) { command.reason() ); validateRefundResponse(response, command.amount()); - return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, "CANCELLED"); + return new GatewayRefundResult(type(), GatewayRefundStatus.SUCCEEDED, CANCELED_EXTERNAL_STATUS); } - private GatewayPaymentStatus mapStatus(String externalStatus) { + public static GatewayPaymentStatus mapStatus(String externalStatus) { if (externalStatus == null) { return GatewayPaymentStatus.UNKNOWN; } return switch (externalStatus.toUpperCase()) { - case "READY", "PENDING" -> GatewayPaymentStatus.PENDING; + case "READY", "PENDING", "VIRTUAL_ACCOUNT_ISSUED", "PAY_PENDING", "CANCEL_PENDING" -> + GatewayPaymentStatus.PENDING; case "PAID" -> GatewayPaymentStatus.COMPLETED; + case "PARTIAL_CANCELLED" -> GatewayPaymentStatus.UNKNOWN; case "FAILED" -> GatewayPaymentStatus.FAILED; - case "CANCELLED" -> GatewayPaymentStatus.CANCELED; + case CANCELED_EXTERNAL_STATUS -> GatewayPaymentStatus.CANCELED; default -> GatewayPaymentStatus.UNKNOWN; }; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java index 6ec3c4ed..4849b970 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/gateway/type/GatewayPaymentStatus.java @@ -5,6 +5,7 @@ public enum GatewayPaymentStatus { PROCESSING, APPROVED, COMPLETED, + REFUNDED, CANCELED, FAILED, UNKNOWN diff --git a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java index f70811a4..43972ed8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentTransactionService.java @@ -2,6 +2,7 @@ import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest; import com.jobdri.jobdri_api.domain.payment.dto.external.portone.PortOnePaymentResponse; +import com.jobdri.jobdri_api.domain.payment.gateway.PortOnePaymentGateway; import com.jobdri.jobdri_api.domain.payment.gateway.type.GatewayPaymentStatus; import com.jobdri.jobdri_api.domain.payment.gateway.result.GatewayPaymentSnapshot; import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse; @@ -216,8 +217,10 @@ public PaymentConfirmResponse applyTossPayStatus(String orderId, String payToken if (payment.getStatus() == PaymentStatus.FAILED) { throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "처리할 수 없는 결제 상태입니다."); } - if (payment.getStatus() == PaymentStatus.COMPLETED && normalizedStatus != GatewayPaymentStatus.CANCELED) { - return currentConfirmResponse(payment); + if (payment.getStatus() == PaymentStatus.COMPLETED + && normalizedStatus != GatewayPaymentStatus.CANCELED + && normalizedStatus != GatewayPaymentStatus.REFUNDED) { + return currentConfirmResponse(payment, userService.getUser(payment.getUser().getId())); } return applyGatewayPaymentTransition( @@ -370,41 +373,48 @@ private PaymentConfirmResponse applyGatewayPaymentTransition( String providerTransactionId, String chargeReferenceId ) { + User user = userService.getUser(payment.getUser().getId()); + if (payment.getStatus() == PaymentStatus.REFUNDED) { - return currentConfirmResponse(payment); + return currentConfirmResponse(payment, user); } - if (payment.getStatus() == PaymentStatus.COMPLETED) { + if (payment.getStatus() == PaymentStatus.COMPLETED + && normalizedStatus != GatewayPaymentStatus.REFUNDED) { payment.updateProviderStatus(providerStatus, providerTransactionId); - return currentConfirmResponse(payment); + return currentConfirmResponse(payment, user); } return switch (normalizedStatus) { case COMPLETED -> PaymentConfirmResponse.of( payment, - completeAndCharge(payment, providerStatus, providerTransactionId, chargeReferenceId) + completeAndCharge(user, payment, providerStatus, providerTransactionId, chargeReferenceId) + ); + case REFUNDED -> PaymentConfirmResponse.of( + payment, + refundAndRecover(user, payment, providerStatus) ); case FAILED, CANCELED -> { payment.failByProviderStatus(providerStatus, providerTransactionId); - yield currentConfirmResponse(payment); + yield currentConfirmResponse(payment, user); } case UNKNOWN -> { payment.markUnknownByProviderStatus(providerStatus, providerTransactionId); - yield currentConfirmResponse(payment); + yield currentConfirmResponse(payment, user); } default -> { payment.updateProviderStatus(providerStatus, providerTransactionId); - yield currentConfirmResponse(payment); + yield currentConfirmResponse(payment, user); } }; } private int completeAndCharge( + User user, Payment payment, String providerStatus, String providerTransactionId, String chargeReferenceId ) { - User user = userService.getUser(payment.getUser().getId()); payment.completeByProviderStatus(providerStatus, providerTransactionId); return creditService.charge( user, @@ -414,8 +424,18 @@ private int completeAndCharge( ); } - private PaymentConfirmResponse currentConfirmResponse(Payment payment) { - return PaymentConfirmResponse.of(payment, userService.getUser(payment.getUser().getId()).getCredit()); + private int refundAndRecover(User user, Payment payment, String providerStatus) { + payment.refundByProviderStatus(providerStatus, null); + return creditService.use( + user, + payment.getCreditAmount(), + "결제 환불 크레딧 회수", + refundCreditReferenceId(payment) + ); + } + + private PaymentConfirmResponse currentConfirmResponse(Payment payment, User user) { + return PaymentConfirmResponse.of(payment, user.getCredit()); } private String chargeReferenceId(Payment payment) { @@ -428,7 +448,7 @@ private String chargeReferenceId(Payment payment) { private GatewayPaymentSnapshot toGatewaySnapshot(PortOnePaymentResponse response) { return new GatewayPaymentSnapshot( PaymentProviderType.PORTONE, - normalizePortOneStatus(response.status()), + PortOnePaymentGateway.mapStatus(response.status()), response.id(), null, null, @@ -448,21 +468,8 @@ private GatewayPaymentStatus normalizeTossPayStatus(TossPayStatus tossPayStatus) return switch (tossPayStatus) { case PAY_COMPLETE -> GatewayPaymentStatus.COMPLETED; case PAY_CANCEL -> GatewayPaymentStatus.CANCELED; - case PAY_APPROVED, REFUND_SUCCESS -> GatewayPaymentStatus.APPROVED; - }; - } - - private GatewayPaymentStatus normalizePortOneStatus(String externalStatus) { - if (externalStatus == null || externalStatus.isBlank()) { - return GatewayPaymentStatus.UNKNOWN; - } - return switch (externalStatus.toUpperCase()) { - case "PAID" -> GatewayPaymentStatus.COMPLETED; - case "FAILED", "CANCELLED" -> GatewayPaymentStatus.FAILED; - case "PARTIAL_CANCELLED", "UNKNOWN" -> GatewayPaymentStatus.UNKNOWN; - case "READY", "PENDING", "VIRTUAL_ACCOUNT_ISSUED", "PAY_PENDING", "CANCEL_PENDING" -> - GatewayPaymentStatus.PENDING; - default -> GatewayPaymentStatus.UNKNOWN; + case PAY_APPROVED -> GatewayPaymentStatus.APPROVED; + case REFUND_SUCCESS -> GatewayPaymentStatus.REFUNDED; }; } diff --git a/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql b/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql new file mode 100644 index 00000000..6456a708 --- /dev/null +++ b/src/main/resources/db/migration/V202608111500__add_credit_transaction_paging_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created_at_id_desc + ON credit_transactions (user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_type_created_at_id_desc + ON credit_transactions (user_id, type, created_at DESC, id DESC); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index ce528de9..f5d2c800 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -161,3 +161,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_pay_token_unique CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_external_payment_id_unique ON payments (external_payment_id) WHERE external_payment_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_created_at_id_desc + ON credit_transactions (user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_type_created_at_id_desc + ON credit_transactions (user_id, type, created_at DESC, id DESC); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java index 53f9fffb..3cc2a262 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/payment/service/PaymentServiceTest.java @@ -32,14 +32,17 @@ import com.jobdri.jobdri_api.domain.user.repository.UserRepository; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -62,8 +65,13 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest +@AutoConfigureMockMvc @ActiveProfiles("test") class PaymentServiceTest { @@ -86,6 +94,9 @@ class PaymentServiceTest { @Autowired private CreditTransactionRepository creditTransactionRepository; + @Autowired + private MockMvc mockMvc; + @MockitoBean private TossPaymentClient tossPaymentClient; @@ -694,6 +705,42 @@ void tossPayCallbackIgnoresCancelAfterCompleted() { )).hasSize(1); } + @Test + @DisplayName("이미 완료된 결제에 REFUND_SUCCESS 콜백이 와도 환불 처리와 크레딧 회수는 한 번만 수행한다") + void tossPayCallbackRefundsCompletedPaymentOnce() { + User user = saveUser("payment-callback-refund-success@example.com"); + mockTossPayCreateSuccess(); + PaymentPrepareResponse prepared = paymentService.prepare(user, new PaymentPrepareRequest("ONE_TIME")); + mockTossPayStatus(prepared, TossPayStatus.PAY_COMPLETE, prepared.amount()); + paymentService.handleTossPayCallback(tossPayCompleteCallback(prepared)); + + mockTossPayStatus(prepared, TossPayStatus.REFUND_SUCCESS, prepared.amount()); + TossPayCallbackRequest refundCallback = new TossPayCallbackRequest( + "REFUND_SUCCESS", + savedPayToken(prepared), + prepared.orderId(), + "CARD", + prepared.amount(), + 0, + 0, + null, + null + ); + + paymentService.handleTossPayCallback(refundCallback); + paymentService.handleTossPayCallback(refundCallback); + + Payment payment = paymentRepository.findByOrderId(prepared.orderId()).orElseThrow(); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REFUNDED); + assertThat(payment.getTossStatus()).isEqualTo("REFUND_SUCCESS"); + assertThat(userRepository.findById(user.getId()).orElseThrow().getCredit()).isEqualTo(1); + assertThat(creditTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc( + user.getId(), + CreditTransactionType.USE + )).filteredOn(transaction -> transaction.getReferenceId().equals("PAYMENT_REFUND:TOSS_PAY_DIRECT:" + prepared.orderId())) + .hasSize(1); + } + @Test @DisplayName("토스페이 중간 상태 콜백은 상태만 저장하고 크레딧을 지급하지 않는다") void tossPayCallbackDoesNotChargeWhenStatusIsNotComplete() { @@ -1256,6 +1303,65 @@ void getTransactionsPageWithTypeFilter() { .containsExactly("charge-2", "charge-1"); } + @Test + @DisplayName("크레딧 거래 내역 페이지 조회는 page와 size 경계값을 보정한다") + void getTransactionsPageNormalizesBounds() { + User user = saveUser("payment-transactions-page-bounds@example.com"); + saveCreditTransaction(user, CreditTransactionType.CHARGE, 1, 2, "충전 1", "charge-1"); + saveCreditTransaction(user, CreditTransactionType.USE, -1, 1, "사용", "use-1"); + saveCreditTransaction(user, CreditTransactionType.COUPON, 1, 2, "쿠폰", "coupon-1"); + + Page negativePage = paymentService.getTransactionsPage(user, null, -1, 2); + Page zeroSize = paymentService.getTransactionsPage(user, null, 0, 0); + Page negativeSize = paymentService.getTransactionsPage(user, null, 0, -5); + Page oversized = paymentService.getTransactionsPage(user, null, 0, 999); + + assertThat(negativePage.getNumber()).isEqualTo(0); + assertThat(negativePage.getContent()).extracting("referenceId") + .containsExactly("coupon-1", "use-1"); + assertThat(zeroSize.getSize()).isEqualTo(1); + assertThat(zeroSize.getContent()).extracting("referenceId") + .containsExactly("coupon-1"); + assertThat(negativeSize.getSize()).isEqualTo(1); + assertThat(negativeSize.getContent()).extracting("referenceId") + .containsExactly("coupon-1"); + assertThat(oversized.getSize()).isEqualTo(100); + assertThat(oversized.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 API는 기본 page/size와 type 필터를 적용한다") + void getTransactionsPageEndpointUsesDefaultsAndTypeFilter() throws Exception { + User user = saveUser("payment-transactions-page-endpoint@example.com"); + for (int i = 1; i <= 21; i++) { + saveCreditTransaction(user, CreditTransactionType.CHARGE, i, i + 1, "충전 " + i, "charge-" + i); + } + saveCreditTransaction(user, CreditTransactionType.USE, -1, 21, "사용", "use-1"); + + mockMvc.perform(get("/api/payments/credits/me/transactions/page") + .param("type", "CHARGE") + .with(user(new UserDetailsImpl(userRepository.findById(user.getId()).orElseThrow())))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON2000")) + .andExpect(jsonPath("$.message").value("크레딧 거래 내역 페이징 조회에 성공했습니다.")) + .andExpect(jsonPath("$.result.number").value(0)) + .andExpect(jsonPath("$.result.size").value(20)) + .andExpect(jsonPath("$.result.totalElements").value(21)) + .andExpect(jsonPath("$.result.content.length()").value(20)) + .andExpect(jsonPath("$.result.content[0].type").value("CHARGE")) + .andExpect(jsonPath("$.result.content[0].referenceId").value("charge-21")); + } + + @Test + @DisplayName("크레딧 거래 내역 페이지 API는 인증이 없으면 401을 반환한다") + void getTransactionsPageEndpointRequiresAuthentication() throws Exception { + mockMvc.perform(get("/api/payments/credits/me/transactions/page")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4011")); + } + @Test @DisplayName("결제 주문 상태 조회는 소유자의 주문만 반환한다") void getOrderStatus() { From 428b0051d47dd57a7fa7df1ac59fcc1eb19a11e1 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:35:23 +0900 Subject: [PATCH 13/55] =?UTF-8?q?[Refactor]=20mock=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9D=B5=EC=8A=A4?= =?UTF-8?q?=ED=85=90=EC=85=98=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JobPostingExtensionIngestService.java | 17 +-- .../controller/MockApplyController.java | 107 ------------------ .../JobPostingExtensionIngestServiceTest.java | 72 ++---------- 3 files changed, 8 insertions(+), 188 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java index 4c7f9004..5bc1b274 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestService.java @@ -4,8 +4,6 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingIngestRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; -import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; -import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.domain.user.entity.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,8 +13,6 @@ public class JobPostingExtensionIngestService { private final JobPostingIngestService jobPostingIngestService; - private final JobPostingService jobPostingService; - private final MockApplyService mockApplyService; public JobPostingExtensionIngestResponse ingest(User user, JobPostingExtensionIngestRequest request) { JobPostingIngestResponse ingest = jobPostingIngestService.ingestAndCreate( @@ -24,22 +20,11 @@ public JobPostingExtensionIngestResponse ingest(User user, JobPostingExtensionIn new JobPostingIngestRequest(request.rawText(), null) ); - MockApplyCreateResponse mockApply = null; - if (ingest.isSavedToDatabase() && ingest.getSaved() != null) { - Long jobPostingId = ingest.getSaved().getJobPostingId(); - try { - mockApply = mockApplyService.createMockApplyFromJobPosting(user, jobPostingId); - } catch (RuntimeException e) { - jobPostingService.deleteJobPosting(user, jobPostingId); - throw e; - } - } - return JobPostingExtensionIngestResponse.of( request.sourceUrl(), request.sourceSite(), ingest, - mockApply + null ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index 3d0f9f5d..0128851e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -1,8 +1,6 @@ package com.jobdri.jobdri_api.domain.mockapply.controller; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateActualRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockFromJobPostingRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyUpdateNameRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; @@ -237,111 +235,6 @@ public ApiResponse createActualApply( ); } - @Operation( - summary = "저장된 공고 기반 MOCK 타입 모의 서류 지원 생성", - description = "AI 초안 생성 후 사용자가 수정하여 저장한 채용 공고 ID를 기준으로 로그인 사용자의 MOCK 타입 모의 서류 지원을 생성합니다." - ) - @ApiResponses(value = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "모의 서류 지원 생성 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 정보 누락", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "403", - description = "다른 사용자의 공고 접근", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4031\",\"message\":\"해당 공고에 접근할 수 없습니다.\",\"result\":null,\"error\":\"해당 공고에 접근할 수 없습니다.\"}") - ) - ) - }) - @PostMapping("/mock/from-job-posting") - public ApiResponse createMockApplyFromJobPosting( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody MockApplyCreateMockFromJobPostingRequest request - ) { - return ApiResponse.onSuccess( - "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApplyFromJobPosting( - userDetails.getUser(), - request.jobPostingId(), - request.sequence() - ) - ); - } - - @Operation( - summary = "가상 공고 기반 모의 서류 지원 생성", - description = "선택한 소분류를 기준으로 가상 채용 공고와 MOCK 타입 모의 서류 지원을 함께 생성합니다." - ) - @ApiResponses(value = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "모의 서류 지원 생성 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"모의 서류 지원이 생성되었습니다.\",\"result\":{\"jobPostingId\":1,\"mockApplyId\":10,\"applyType\":\"MOCK\",\"sequence\":1},\"error\":null}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = "요청값 검증 실패", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"REQ_4002\",\"message\":\"파라미터 형식이 잘못되었습니다.\",\"result\":null,\"error\":[\"[companyId] 회사 ID는 필수입니다. (입력값: null)\"]}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "401", - description = "인증 정보 누락", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(value = "{\"isSuccess\":false,\"code\":\"AUTH_4011\",\"message\":\"인증 정보가 누락되었습니다.\",\"result\":null,\"error\":\"인증 정보가 누락되었습니다.\"}") - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "회사 또는 직무 분류 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ApiResponse.class), - examples = { - @ExampleObject(name = "company_not_found", value = "{\"isSuccess\":false,\"code\":\"COMPANY_4041\",\"message\":\"해당 회사를 찾을 수 없습니다. companyId=999\",\"result\":null,\"error\":\"해당 회사를 찾을 수 없습니다. companyId=999\"}"), - @ExampleObject(name = "classification_not_found", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\",\"result\":null,\"error\":\"해당 소분류를 찾을 수 없습니다. detailClassificationId=999\"}"), - @ExampleObject(name = "middle_detail_mismatch", value = "{\"isSuccess\":false,\"code\":\"CLASSIFICATION_4041\",\"message\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\",\"result\":null,\"error\":\"해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=999, detailClassificationId=1\"}") - } - ) - ) - }) - @PostMapping("/mock") - public ApiResponse createMockApply( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody MockApplyCreateMockRequest request - ) { - return ApiResponse.onSuccess( - "모의 서류 지원이 생성되었습니다.", - mockApplyService.createMockApply(userDetails.getUser(), request) - ); - } - @Operation( summary = "모의 서류 지원 재도전", description = "기존 모의 서류 지원의 공고와 선택 문항을 복사해 새 회차의 모의 서류 지원을 생성합니다. 답변은 비워진 상태로 자소서 입력 단계부터 다시 진행합니다." diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java index 83150971..9c0154db 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingExtensionIngestServiceTest.java @@ -5,9 +5,6 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtensionIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; -import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; -import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; -import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; import com.jobdri.jobdri_api.domain.user.entity.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,9 +17,7 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,14 +27,8 @@ class JobPostingExtensionIngestServiceTest { @Mock private JobPostingIngestService jobPostingIngestService; - @Mock - private JobPostingService jobPostingService; - - @Mock - private MockApplyService mockApplyService; - @InjectMocks - private JobPostingExtensionIngestService jobPostingExtensionIngestService; + private JobPostingExtensionIngestService ingestService; private User user; @@ -50,8 +39,8 @@ void setUp() { } @Test - @DisplayName("익스텐션 공고 수집 성공 시 공고 저장 후 모의 서류 지원을 생성한다") - void ingestCreatesMockApplyWhenJobPostingSaved() { + @DisplayName("익스텐션 공고 수집 성공 시 저장만 수행하고 mock 지원은 생성하지 않는다") + void ingestDoesNotCreateMockApplyWhenJobPostingSaved() { JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( "https://www.wanted.co.kr/wd/123", "WANTED", @@ -77,14 +66,10 @@ void ingestCreatesMockApplyWhenJobPostingSaved() { null, saved ); - MockApplyCreateResponse mockApply = new MockApplyCreateResponse(10L, 20L, ApplyType.MOCK, 1); - when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - when(mockApplyService.createMockApplyFromJobPosting(user, 10L)) - .thenReturn(mockApply); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); ArgumentCaptor ingestRequestCaptor = ArgumentCaptor.forClass(JobPostingIngestRequest.class); verify(jobPostingIngestService).ingestAndCreate(eq(user), ingestRequestCaptor.capture()); @@ -92,7 +77,7 @@ void ingestCreatesMockApplyWhenJobPostingSaved() { assertThat(ingestRequestCaptor.getValue().imageObjectKey()).isNull(); assertThat(response.sourceUrl()).isEqualTo("https://www.wanted.co.kr/wd/123"); assertThat(response.sourceSite()).isEqualTo("WANTED"); - assertThat(response.mockApply()).isEqualTo(mockApply); + assertThat(response.mockApply()).isNull(); } @Test @@ -116,9 +101,8 @@ void ingestSkipsMockApplyWhenJobPostingNotSaved() { when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); - verify(mockApplyService, never()).createMockApplyFromJobPosting(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); assertThat(response.savedToDatabase()).isFalse(); assertThat(response.mockApply()).isNull(); } @@ -144,51 +128,9 @@ void ingestSkipsMockApplyWhenSavedJobPostingIsNull() { when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) .thenReturn(ingest); - JobPostingExtensionIngestResponse response = jobPostingExtensionIngestService.ingest(user, request); + JobPostingExtensionIngestResponse response = ingestService.ingest(user, request); - verify(mockApplyService, never()).createMockApplyFromJobPosting(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); assertThat(response.savedToDatabase()).isTrue(); assertThat(response.mockApply()).isNull(); } - - @Test - @DisplayName("모의 서류 지원 생성 실패 시 저장된 공고를 보상 삭제한다") - void ingestDeletesSavedJobPostingWhenMockApplyCreationFails() { - JobPostingExtensionIngestRequest request = new JobPostingExtensionIngestRequest( - "https://www.wanted.co.kr/wd/123", - "WANTED", - "채용 공고 원문" - ); - JobPostingResponse saved = JobPostingResponse.builder() - .jobPostingId(10L) - .userId(1L) - .companyId(2L) - .companyName("테스트 회사") - .detailClassificationId(3L) - .detailClassificationName("백엔드 개발") - .task("주요 업무") - .requirement("자격 요건") - .preferred("우대 사항") - .build(); - JobPostingIngestResponse ingest = new JobPostingIngestResponse( - true, - "저장 성공", - null, - null, - null, - null, - saved - ); - RuntimeException failure = new RuntimeException("mock apply create failed"); - - when(jobPostingIngestService.ingestAndCreate(eq(user), org.mockito.ArgumentMatchers.any(JobPostingIngestRequest.class))) - .thenReturn(ingest); - when(mockApplyService.createMockApplyFromJobPosting(user, 10L)) - .thenThrow(failure); - - assertThatThrownBy(() -> jobPostingExtensionIngestService.ingest(user, request)) - .isSameAs(failure); - - verify(jobPostingService).deleteJobPosting(user, 10L); - } } From 8f77a37ca54d62c3c9dbce6c0d3ad5a18829bd75 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:40:12 +0900 Subject: [PATCH 14/55] =?UTF-8?q?[Refactor]=20mock=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=A0=84=EC=9A=A9=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...kApplyCreateMockFromJobPostingRequest.java | 16 --- .../request/MockApplyCreateMockRequest.java | 27 ---- .../mockapply/service/MockApplyService.java | 73 ---------- .../service/MockApplyServiceTest.java | 132 ------------------ 4 files changed, 248 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java deleted file mode 100644 index af1d53fa..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockFromJobPostingRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.jobdri.jobdri_api.domain.mockapply.dto.request; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record MockApplyCreateMockFromJobPostingRequest( - @NotNull(message = "공고 ID는 필수입니다.") - Long jobPostingId, - - @Positive(message = "지원 순번은 1 이상이어야 합니다.") - Integer sequence -) { - public MockApplyCreateMockFromJobPostingRequest(Long jobPostingId) { - this(jobPostingId, null); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java deleted file mode 100644 index b4a567e2..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/request/MockApplyCreateMockRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.jobdri.jobdri_api.domain.mockapply.dto.request; - -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; - -public record MockApplyCreateMockRequest( - @NotNull(message = "회사 ID는 필수입니다.") - Long companyId, - - @NotNull(message = "중분류 ID는 필수입니다.") - Long middleClassificationId, - - @NotNull(message = "소분류 ID는 필수입니다.") - Long detailClassificationId, - - @Positive(message = "지원 순번은 1 이상이어야 합니다.") - Integer sequence -) { - public MockApplyCreateMockRequest(Long companyId, Long middleClassificationId, Long detailClassificationId) { - this(companyId, middleClassificationId, detailClassificationId, null); - } - - public JobPostingMockGenerateRequest toJobPostingMockGenerateRequest() { - return new JobPostingMockGenerateRequest(companyId, middleClassificationId, detailClassificationId); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index b7b15370..c39639cc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -7,19 +7,10 @@ import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingCreateRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockFromJobPostingRequest; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeItemResponse; @@ -71,10 +62,7 @@ public class MockApplyService { private final MockApplyRepository mockApplyRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final AnalysisRepository analysisRepository; - private final JobPostingRepository jobPostingRepository; - private final CompanyRepository companyRepository; private final QuestionRepository questionRepository; - private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; @@ -135,67 +123,6 @@ public MockApplyRetryResponse retryMockApply(User user, Long mockApplyId) { return MockApplyRetryResponse.of(sourceMockApply.getId(), retryMockApply); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { - return createMockApplyFromJobPosting(user, jobPostingId, null); - } - - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId, Integer sequence) { - User validatedUser = userService.validateUser(user); - JobPosting jobPosting = jobPostingService.getOwnedJobPosting(validatedUser, jobPostingId); - - MockApply mockApply = saveMockApplyWithSequence( - validatedUser, - jobPosting, - ApplyType.MOCK, - sequence - ); - return MockApplyCreateResponse.from(mockApply); - } - - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") - public MockApplyCreateResponse createMockApply(User user, MockApplyCreateMockRequest request) { - User validatedUser = userService.validateUser(user); - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.COMPANY_NOT_FOUND, - "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() - )); - - JobPostingMockGenerateResponse generated = - mockJobPostingGenerationService.generate(request.toJobPostingMockGenerateRequest()); - - JobPostingCreateRequest createRequest = new JobPostingCreateRequest( - JobPostingProfileColor.DEFAULT, - generated.jobTitle(), - company.getName(), - company.getSize(), - generated.jobTitle(), - request.detailClassificationId(), - generated.task(), - generated.requirement(), - generated.preferred() - ); - Long savedJobPostingId = jobPostingService.createJobPosting(validatedUser, createRequest).getJobPostingId(); - JobPosting savedJobPosting = jobPostingRepository.findById(savedJobPostingId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.JOB_POSTING_NOT_FOUND, - "생성된 모의 공고를 찾을 수 없습니다. jobPostingId=" + savedJobPostingId - )); - - MockApply mockApply = saveMockApplyWithSequence( - validatedUser, - savedJobPosting, - ApplyType.MOCK, - request.sequence() - ); - return MockApplyCreateResponse.from(mockApply); - } - public JobPostingResponse getMockApplyJobPosting(User user, Long mockApplyId) { User validatedUser = userService.validateUser(user); MockApply mockApply = getOwnedMockApply(validatedUser, mockApplyId); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 14fedcc8..f841a7df 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -17,12 +17,9 @@ import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.entity.CompanySize; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingProfileColor; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCompletedFilter; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeItemResponse; @@ -42,7 +39,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Page; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; @@ -59,8 +55,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; @SpringBootTest @ActiveProfiles("test") @@ -103,9 +97,6 @@ class MockApplyServiceTest { @Autowired private PlatformTransactionManager transactionManager; - @MockBean - private MockJobPostingGenerationService mockJobPostingGenerationService; - @Test @DisplayName("기존 공고를 기준으로 ACTUAL 타입 모의 서류 지원을 생성한다") void createActualApply() { @@ -191,67 +182,6 @@ void createActualApplyThrowsWhenRequestedSequenceDuplicated() { .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); } - @Test - @DisplayName("소분류를 기준으로 가상 공고와 MOCK 타입 모의 서류 지원을 생성한다") - void createMockApply() { - User user = saveUser("mock-apply@example.com"); - String companyName = "선택 기업 " + UUID.randomUUID(); - Company company = saveCompany(companyName, CompanySize.MEDIUM); - DetailClassification detailClassification = saveDetailClassification("프론트엔드 개발"); - Long middleClassificationId = detailClassification.getMiddleClassification().getId(); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest( - company.getId(), - middleClassificationId, - detailClassification.getId() - ); - when(mockJobPostingGenerationService.generate(any())) - .thenReturn(new JobPostingMockGenerateResponse( - companyName, - "프론트엔드 개발자", - "웹 프론트엔드 개발 및 운영", - "HTML/CSS/JavaScript 기본기", - "React 경험 우대", - "프론트엔드 직무 대상 모의 공고입니다.", - List.of("질문 1", "질문 2") - )); - - MockApplyCreateResponse response = mockApplyService.createMockApply(user, request); - - MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); - JobPosting jobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); - assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); - assertThat(response.sequence()).isEqualTo(1); - assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); - assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.APPLICATION_CREATED); - assertThat(mockApply.getSequence()).isEqualTo(1); - assertThat(jobPosting.getCompany().getId()).isEqualTo(company.getId()); - assertThat(jobPosting.getCompany().getName()).isEqualTo(companyName); - assertThat(jobPosting.getCompany().getSize()).isEqualTo(CompanySize.MEDIUM); - assertThat(jobPosting.getDetailClassification().getId()).isEqualTo(detailClassification.getId()); - assertThat(jobPosting.getTask()).isEqualTo("웹 프론트엔드 개발 및 운영"); - assertThat(jobPosting.getRequirement()).isEqualTo("HTML/CSS/JavaScript 기본기"); - assertThat(jobPosting.getPreferred()).isEqualTo("React 경험 우대"); - } - - @Test - @DisplayName("저장된 공고를 기준으로 MOCK 타입 모의 서류 지원을 생성한다") - void createMockApplyFromJobPosting() { - User user = saveUser("mock-from-job-posting@example.com"); - JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); - - MockApplyCreateResponse response = mockApplyService.createMockApplyFromJobPosting(user, jobPosting.getId()); - - MockApply mockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); - assertThat(response.jobPostingId()).isEqualTo(jobPosting.getId()); - assertThat(response.applyType()).isEqualTo(ApplyType.MOCK); - assertThat(response.sequence()).isEqualTo(1); - assertThat(mockApply.getUser().getId()).isEqualTo(user.getId()); - assertThat(mockApply.getJobPosting().getId()).isEqualTo(jobPosting.getId()); - assertThat(mockApply.getApplyType()).isEqualTo(ApplyType.MOCK); - assertThat(mockApply.getSequence()).isEqualTo(1); - } - @Test @DisplayName("기존 지원의 공고에 새 회차와 문항을 생성한다") void retryMockApply() { @@ -794,68 +724,6 @@ void createActualApplyThrowsWhenJobPostingNotFound() { .isEqualTo(GeneralErrorCode.JOB_POSTING_NOT_FOUND); } - @Test - @DisplayName("다른 사용자의 공고로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyFromJobPostingThrowsWhenForbidden() { - User owner = saveUser("mock-owner@example.com"); - User otherUser = saveUser("mock-other@example.com"); - JobPosting jobPosting = saveJobPosting(owner, "프론트엔드 개발"); - - assertThatThrownBy(() -> mockApplyService.createMockApplyFromJobPosting(otherUser, jobPosting.getId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.FORBIDDEN); - } - - @Test - @DisplayName("존재하지 않는 소분류 ID로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenDetailClassificationNotFound() { - User user = saveUser("missing-detail-classification@example.com"); - Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 1L, 9999L); - when(mockJobPostingGenerationService.generate(any())) - .thenThrow(new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류를 찾을 수 없습니다. detailClassificationId=9999" - )); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); - } - - @Test - @DisplayName("존재하지 않는 회사 ID로 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenCompanyNotFound() { - User user = saveUser("missing-company@example.com"); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(9999L, 1L, 1L); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.COMPANY_NOT_FOUND); - } - - @Test - @DisplayName("소분류가 중분류에 속하지 않으면 MOCK 타입 지원 생성 시 예외를 던진다") - void createMockApplyThrowsWhenMiddleClassificationMismatched() { - User user = saveUser("middle-mismatch@example.com"); - Company company = saveCompany("선택 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); - DetailClassification detailClassification = saveDetailClassification("데이터 분석"); - MockApplyCreateMockRequest request = new MockApplyCreateMockRequest(company.getId(), 9999L, detailClassification.getId()); - when(mockJobPostingGenerationService.generate(any())) - .thenThrow(new GeneralException( - GeneralErrorCode.CLASSIFICATION_NOT_FOUND, - "해당 소분류가 중분류에 속하지 않습니다. middleClassificationId=9999, detailClassificationId=" + detailClassification.getId() - )); - - assertThatThrownBy(() -> mockApplyService.createMockApply(user, request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.CLASSIFICATION_NOT_FOUND); - } - @Test @DisplayName("다른 사용자의 mockApplyId로 공고 조회 시 예외를 던진다") void getMockApplyJobPostingThrowsWhenForbidden() { From ee2beafd4fbcbd5192c0e1898a942c90a5f2cffd Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:43:00 +0900 Subject: [PATCH 15/55] =?UTF-8?q?[Refactor]=20mock=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=B0=B8=EC=A1=B0=20=EC=A0=95=EB=A6=AC=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MockJobPostingController.java | 91 ------------------- 1 file changed, 91 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java deleted file mode 100644 index 6fb856cf..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/MockJobPostingController.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.controller; - -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockQuestionResponse; -import com.jobdri.jobdri_api.domain.jobposting.service.MockJobPostingGenerationService; -import com.jobdri.jobdri_api.domain.jobposting.service.MockQuestionCacheService; -import com.jobdri.jobdri_api.domain.user.service.UserService; -import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.UserDetailsImpl; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/job-postings/mock") -@Tag(name = "Mock JobPosting", description = "모의 채용 공고 생성 및 추천 질문 API") -public class MockJobPostingController { - - private final MockJobPostingGenerationService mockJobPostingGenerationService; - private final MockQuestionCacheService mockQuestionCacheService; - private final UserService userService; - - @Operation( - summary = "모의 공고 생성", - description = "선택한 직무 중분류/소분류를 기반으로 기존 공고를 참고하여 가상의 모의 공고를 생성합니다." - ) - @PostMapping("/generate") - public ApiResponse generateMockJobPosting( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody JobPostingMockGenerateRequest request - ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "모의 공고 생성에 성공했습니다.", - mockJobPostingGenerationService.generate(request) - ); - } - - @Operation( - summary = "모의 공고 추천 질문 조회", - description = "선택한 회사/직무 기준으로 모의 공고 추천 질문을 조회합니다. 질문은 직무 기준 캐시를 재사용합니다." - ) - @GetMapping("/questions") - public ApiResponse getMockRecommendedQuestions( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @RequestParam Long companyId, - @RequestParam Long middleClassificationId, - @RequestParam Long detailClassificationId - ) { - validateAuthenticatedUser(userDetails); - JobPostingMockGenerateRequest request = new JobPostingMockGenerateRequest( - companyId, - middleClassificationId, - detailClassificationId - ); - return ApiResponse.onSuccess( - "모의 공고 추천 질문 조회에 성공했습니다.", - new JobPostingMockQuestionResponse(mockQuestionCacheService.getRecommendedQuestions(request)) - ); - } - - @Operation( - summary = "모의 공고 추천 질문 조회", - description = "선택한 회사/직무 기준으로 모의 공고 추천 질문을 조회합니다. 하위 호환을 위해 POST 요청도 지원합니다." - ) - @PostMapping("/questions") - public ApiResponse getMockRecommendedQuestionsByPost( - @AuthenticationPrincipal UserDetailsImpl userDetails, - @Valid @RequestBody JobPostingMockGenerateRequest request - ) { - validateAuthenticatedUser(userDetails); - return ApiResponse.onSuccess( - "모의 공고 추천 질문 조회에 성공했습니다.", - new JobPostingMockQuestionResponse(mockQuestionCacheService.getRecommendedQuestions(request)) - ); - } - - private com.jobdri.jobdri_api.domain.user.entity.User validateAuthenticatedUser(UserDetailsImpl userDetails) { - return userService.validateUser(userDetails == null ? null : userDetails.getUser()); - } -} From f7c57ddb273ce4e328bdc00f3c47ca6486c59894 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:44:09 +0900 Subject: [PATCH 16/55] =?UTF-8?q?[Refactor]=20mock=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20DTO=20=EB=B0=8F=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/JobPostingAiAsyncService.java | 36 -------------- .../MockJobPostingGenerationService.java | 48 ------------------- 2 files changed, 84 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java deleted file mode 100644 index 5e7f7b52..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiAsyncService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.service; - -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -public class JobPostingAiAsyncService { - - private final JobPostingAiService jobPostingAiService; - private final MockQuestionCacheService mockQuestionCacheService; - - @Async("llmAsyncExecutor") - public CompletableFuture generateMockJobPosting( - JobPostingMockGenerateRequest request, - Company company - ) { - return CompletableFuture.completedFuture( - jobPostingAiService.generateMockJobPosting(request, company) - ); - } - - @Async("llmAsyncExecutor") - public CompletableFuture> getRecommendedQuestions(JobPostingMockGenerateRequest request) { - return CompletableFuture.completedFuture( - mockQuestionCacheService.getRecommendedQuestions(request) - ); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java deleted file mode 100644 index 0bd85acb..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/MockJobPostingGenerationService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.jobdri.jobdri_api.domain.jobposting.service; - -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.dto.request.JobPostingMockGenerateRequest; -import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingMockGenerateResponse; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.concurrent.CompletableFuture; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class MockJobPostingGenerationService { - - private final CompanyRepository companyRepository; - private final JobPostingAiAsyncService jobPostingAiAsyncService; - - public JobPostingMockGenerateResponse generate(JobPostingMockGenerateRequest request) { - Company company = companyRepository.findById(request.companyId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.COMPANY_NOT_FOUND, - "해당 회사를 찾을 수 없습니다. companyId=" + request.companyId() - )); - - CompletableFuture generatedPostingFuture = - jobPostingAiAsyncService.generateMockJobPosting(request, company); - CompletableFuture> recommendedQuestionsFuture = - jobPostingAiAsyncService.getRecommendedQuestions(request); - - CompletableFuture.allOf(generatedPostingFuture, recommendedQuestionsFuture).join(); - JobPostingMockGenerateResponse generatedPosting = generatedPostingFuture.join(); - - return new JobPostingMockGenerateResponse( - generatedPosting.companyName(), - generatedPosting.jobTitle(), - generatedPosting.task(), - generatedPosting.requirement(), - generatedPosting.preferred(), - generatedPosting.summary(), - recommendedQuestionsFuture.join() - ); - } -} From c2e31951f4451721ae5b5bc6ff9b5d1daf80506e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 13:54:49 +0900 Subject: [PATCH 17/55] =?UTF-8?q?[Test]=20analysis=20async=20facade=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=ED=9A=8C=EA=B7=80=20=EB=B3=B4=EA=B0=95=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncFacadeServiceTest.java | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index 122c0e59..d069f316 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -1,11 +1,17 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -146,6 +152,108 @@ void submitPrefersActiveTaskOverCachedResult() { verify(analysisService, never()).hasReusableAnalysis(user, 10L); } + @Test + @DisplayName("성공한 task 상태 조회는 분석 결과를 함께 반환한다") + void getTaskReturnsResultWhenSucceeded() { + User user = User.signup("테스트 사용자", "analysis-async-status-success@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(10L) + .status("SUCCEEDED") + .message("분석이 완료되었습니다.") + .build(); + AnalysisResponse result = new AnalysisResponse( + 10L, + 100L, + MockApplyStatus.COMPLETED, + 1, + 80, + 80, + 80, + 80, + "완료된 분석입니다.", + java.util.List.of(), + java.util.List.of(), + java.util.List.of(), + java.util.List.of() + ); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + when(analysisService.getAnalysis(user, 10L)).thenReturn(result); + + AnalysisAsyncStatusResponse response = analysisAsyncFacadeService.getTask(user, 10L, "task-1"); + + assertThat(response.taskId()).isEqualTo("task-1"); + assertThat(response.status()).isEqualTo("SUCCEEDED"); + assertThat(response.result()).isEqualTo(result); + verify(analysisService, times(1)).getAnalysis(user, 10L); + } + + @Test + @DisplayName("진행 중 task 상태 조회는 분석 결과를 조회하지 않는다") + void getTaskReturnsStatusWithoutResultWhenInProgress() { + User user = User.signup("테스트 사용자", "analysis-async-status-running@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(10L) + .status("RUNNING") + .message("분석 중입니다.") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + + AnalysisAsyncStatusResponse response = analysisAsyncFacadeService.getTask(user, 10L, "task-1"); + + assertThat(response).isEqualTo(status); + verify(analysisService, never()).getAnalysis(user, 10L); + } + + @Test + @DisplayName("task 상태 조회 시 요청 mockApplyId가 다르면 예외를 던진다") + void getTaskThrowsWhenMockApplyIdDoesNotMatch() { + User user = User.signup("테스트 사용자", "analysis-async-status-forbidden@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() + .taskId("task-1") + .mockApplyId(99L) + .status("RUNNING") + .message("분석 중입니다.") + .build(); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.getTaskStatus(1L, "task-1")).thenReturn(status); + + assertThatThrownBy(() -> analysisAsyncFacadeService.getTask(user, 10L, "task-1")) + .isInstanceOf(GeneralException.class) + .extracting("code") + .isEqualTo(GeneralErrorCode.FORBIDDEN); + verify(analysisService, never()).getAnalysis(user, 99L); + } + + @Test + @DisplayName("task 취소는 검증된 사용자 기준으로 task service에 위임한다") + void cancelDelegatesToTaskService() { + User user = User.signup("테스트 사용자", "analysis-async-cancel@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncCancelResponse cancelResponse = new AnalysisAsyncCancelResponse( + "task-1", + "CANCELLED", + "분석 작업이 취소되었습니다." + ); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.cancelTask(1L, 10L, "task-1")).thenReturn(cancelResponse); + + AnalysisAsyncCancelResponse response = analysisAsyncFacadeService.cancel(user, 10L, "task-1"); + + assertThat(response).isEqualTo(cancelResponse); + verify(analysisAsyncTaskService, times(1)).cancelTask(1L, 10L, "task-1"); + } + @Test @DisplayName("재시도 횟수가 maxRetryCount에 도달하면 task를 FAILED로 전환한다") void retryAtLimitMarksTaskFailed() { From 6c43e09198bfa04636b873597eb5a90b07a9570b Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:02:26 +0900 Subject: [PATCH 18/55] =?UTF-8?q?[Refactor]=20Analysis=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=80=EC=9E=A5/=EC=9D=91=EB=8B=B5=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/core/AnalysisCreditService.java | 34 + .../AnalysisResultPersistenceService.java | 723 ++++++++++++++++++ .../service/core/AnalysisService.java | 705 +---------------- 3 files changed, 766 insertions(+), 696 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java new file mode 100644 index 00000000..f7a7fc7e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java @@ -0,0 +1,34 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.payment.service.CreditService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AnalysisCreditService { + + private final CreditService creditService; + + @Transactional(readOnly = true) + public String createSyncReferenceId(Long mockApplyId, String inputFingerprint) { + return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; + } + + @Transactional(readOnly = true) + public String createAsyncReferenceId(String taskId) { + return "analysisTaskId=" + taskId; + } + + @Transactional + public void deduct(User user, String referenceId) { + creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); + } + + @Transactional + public void refund(User user, String referenceId) { + creditService.refund(user, 1, "자소서 분석 크레딧 환불", referenceId); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java new file mode 100644 index 00000000..ff676429 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -0,0 +1,723 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.COMPLETENESS_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.IMPACT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.JOB_FIT_WEIGHT; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; +import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisResultPersistenceService { + private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { + }; + private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { + }; + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final AnalysisRepository analysisRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final ObjectMapper objectMapper; + + @Transactional + public AnalysisResponse finalizeAnalysis( + MockApply mockApply, + List questions, + List payloadSnapshots, + AnalysisLlmResponse llmResponse, + String inputFingerprint + ) { + VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payloadSnapshots); + validateRequiredScores(llmResponse); + int jobFit = validateScore("jobFit", llmResponse.jobFit()); + int impact = validateScore("impact", llmResponse.impact()); + int completeness = validateScore("completeness", llmResponse.completeness()); + List keyStrengths = buildHighlights(llmResponse.keyStrengths()); + List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); + List missingKeywords = buildMissingKeywords( + mockApply.getJobPosting(), + answerSnapshot.combinedAnswers(), + llmResponse + ); + replaceExistingAnalysis(mockApply); + + Analysis analysis = analysisRepository.save(Analysis.create( + mockApply, + calculateScore(jobFit, impact, completeness), + jobFit, + impact, + completeness, + normalizeFeedback(llmResponse.feedback()), + serializeMissingKeywords(missingKeywords), + serializeHighlights(keyStrengths, "keyStrengths"), + serializeHighlights(keyWeaknesses, "keyWeaknesses"), + inputFingerprint + )); + + List questionAnalyses = buildQuestionAnalyses( + analysis, + questions, + answerSnapshot.answerByQuestionId(), + llmResponse + ); + questionAnalysisRepository.saveAll(questionAnalyses); + mockApply.updateStatus(MockApplyStatus.COMPLETED); + + return toResponse(mockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + } + + @Transactional + public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { + Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_NOT_FOUND, + "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() + )); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + sanitizeAndPersistAnalysisPayload(analysis) + ); + } + + private void replaceExistingAnalysis(MockApply mockApply) { + Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); + if (existingAnalysis.isEmpty()) { + return; + } + + Analysis analysis = existingAnalysis.get(); + mockApply.clearAnalysis(); + questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); + analysisRepository.delete(analysis); + analysisRepository.flush(); + } + + private VerifiedAnswerSnapshot verifyAnswerSnapshot( + List databaseQuestions, + List payloadSnapshots + ) { + String databaseFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprintFromQuestions(databaseQuestions); + String payloadFingerprint = analysisInputFingerprintProvider + .createAnswerFingerprint(payloadSnapshots); + if (!databaseFingerprint.equals(payloadFingerprint)) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." + ); + } + + List immutableSnapshots = List.copyOf(payloadSnapshots); + Map answerByQuestionId = new LinkedHashMap<>(); + for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { + if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { + continue; + } + if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() + ); + } + } + return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); + } + + private record VerifiedAnswerSnapshot( + List answers, + Map answerByQuestionId + ) { + private String combinedAnswers() { + return answers.stream() + .map(AnalysisExecutionPayload.AnswerSnapshot::answer) + .filter(StringUtils::hasText) + .collect(Collectors.joining("\n")); + } + } + + private List buildQuestionAnalyses( + Analysis analysis, + List questions, + Map answerByQuestionId, + AnalysisLlmResponse llmResponse + ) { + Map questionMap = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + List result = new ArrayList<>(); + Map analysisCountByQuestionId = new HashMap<>(); + Map nextSearchIndexByQuestionId = new HashMap<>(); + Set seenSentences = new HashSet<>(); + Set fabricatedQuestionIds = new HashSet<>(); + Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); + + if (llmResponse.questionAnalyses() == null) { + return result; + } + + for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { + continue; + } + + Question question = questionMap.get(item.questionId()); + if (question == null) { + continue; + } + + String answer = answerByQuestionId.get(item.questionId()); + if (!StringUtils.hasText(answer)) { + continue; + } + QuestionAnalysisStatus status = parseStatus(item.status()); + if (status == null || status == QuestionAnalysisStatus.MISSING) { + continue; + } + if (status == QuestionAnalysisStatus.PROVEN + && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( + item.sentence(), + item.reason() + )) { + continue; + } + int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); + if (currentCount >= MAX_ANALYSES_PER_QUESTION) { + continue; + } + String sentence = item.sentence(); + if (status != QuestionAnalysisStatus.PROVEN + && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { + continue; + } + String dedupeKey = question.getId() + ":" + sentence.trim(); + if (!seenSentences.add(dedupeKey)) { + continue; + } + int start = findNextSentenceStart( + answer, + sentence, + nextSearchIndexByQuestionId.getOrDefault(question.getId(), 0) + ); + if (start < 0) { + continue; + } + if (status == QuestionAnalysisStatus.FABRICATED + && !fabricatedQuestionIds.add(question.getId())) { + continue; + } + nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); + analysisCountByQuestionId.put(question.getId(), currentCount + 1); + + result.add(QuestionAnalysis.create( + question, + analysis, + sentence, + defaultString(item.reason()), + normalizeImprovement(sentence, answer, item.improvement(), status), + status, + start, + start + sentence.length() + )); + } + + return result; + } + + private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { + if (llmResponse == null || llmResponse.keyStrengths() == null) { + return Set.of(); + } + return llmResponse.keyStrengths().stream() + .filter(item -> item != null && StringUtils.hasText(item.quote())) + .map(item -> normalizeKeyword(item.quote())) + .collect(Collectors.toSet()); + } + + private AnalysisResponse toResponse( + MockApply mockApply, + Analysis analysis, + List questions, + List questionAnalyses, + AnalysisResultPayload resultPayload + ) { + Map questionById = questions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + Map> analysesByQuestionId = questionAnalyses.stream() + .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( + questionAnalysis, + questionById.get(questionAnalysis.getQuestion().getId()) + )) + .collect(Collectors.groupingBy( + questionAnalysis -> questionAnalysis.getQuestion().getId(), + Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) + )); + + List questionResponses = questions.stream() + .sorted(Comparator.comparing(Question::getId)) + .map(question -> AnalysisQuestionResponse.of( + question, + analysesByQuestionId.getOrDefault(question.getId(), List.of()) + )) + .toList(); + + return AnalysisResponse.of( + analysis, + mockApply.getStatus(), + mockApplyRepository.calculateSequence(mockApply), + resultPayload.keyStrengths(), + resultPayload.keyWeaknesses(), + resultPayload.missingKeywords(), + questionResponses + ); + } + + private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { + if (questionAnalysis == null || question == null) { + return false; + } + if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { + return false; + } + String answer = question.getAnswer(); + String sentence = questionAnalysis.getSentence(); + int start = questionAnalysis.getStart(); + int end = questionAnalysis.getEnd(); + if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { + return false; + } + if (start < 0 || end <= start || end > answer.length()) { + return false; + } + return answer.substring(start, end).equals(sentence); + } + + private AnalysisResultPayload analysisResultPayload(Analysis analysis) { + List keyStrengths = + readHighlights(analysis, analysis == null ? null : analysis.getKeyStrengthsJson(), "keyStrengths"); + return new AnalysisResultPayload( + keyStrengths, + removeOverlappingHighlights( + readHighlights(analysis, analysis == null ? null : analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + keyStrengths + ), + readMissingKeywords(analysis) + ); + } + + private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis) { + AnalysisResultPayload payload = analysisResultPayload(analysis); + if (analysis != null) { + analysis.updateHighlightsJson( + serializeHighlights(payload.keyStrengths(), "keyStrengths"), + serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses") + ); + } + return payload; + } + + private List buildHighlights(List items) { + return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); + } + + private List buildNonOverlappingHighlights( + List items, + List existingHighlights + ) { + return sanitizeHighlights( + removeOverlappingRawHighlights(items, existingHighlights), + AnalysisLlmResponse.HighlightItem::title, + AnalysisLlmResponse.HighlightItem::quote + ); + } + + private List buildMissingKeywords( + JobPosting jobPosting, + String combinedAnswers, + AnalysisLlmResponse llmResponse + ) { + if (llmResponse == null || llmResponse.missingKeywords() == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + if (item == null || !StringUtils.hasText(item.keyword())) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + Optional source = MissingKeywordSource.from(item.source()); + if (source.isEmpty()) { + continue; + } + if (!AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source.get(), + jobPosting == null ? "" : jobPosting.getTask(), + jobPosting == null ? "" : jobPosting.getRequirement() + )) { + continue; + } + if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, source.get())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); + } + + private String serializeMissingKeywords(List missingKeywords) { + try { + return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); + return "[]"; + } + } + + private String serializeHighlights(List highlights, String fieldName) { + try { + return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); + return "[]"; + } + } + + private List readHighlights(Analysis analysis, String json, String fieldName) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + + try { + List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); + return sanitizeStoredHighlights(highlights); + } catch (Exception e) { + log.warn( + "Failed to deserialize {}. analysisId={}, fallback to empty array.", + fieldName, + analysis == null ? null : analysis.getId(), + e + ); + return List.of(); + } + } + + private List readMissingKeywords(Analysis analysis) { + if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { + return List.of(); + } + + try { + List missingKeywords = objectMapper.readValue( + analysis.getMissingKeywordsJson(), + MISSING_KEYWORDS_TYPE + ); + return sanitizeStoredMissingKeywords(missingKeywords); + } catch (Exception e) { + log.warn( + "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", + analysis.getId(), + e + ); + return List.of(); + } + } + + private List sanitizeStoredMissingKeywords(List missingKeywords) { + if (missingKeywords == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenKeywords = new HashSet<>(); + + for (MissingKeywordResponse item : missingKeywords) { + if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { + continue; + } + + String keyword = item.keyword().trim(); + if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(keyword); + if (!seenKeywords.add(dedupeKey)) { + continue; + } + + result.add(new MissingKeywordResponse(keyword, item.source())); + if (result.size() >= MAX_MISSING_KEYWORDS) { + break; + } + } + + return result; + } + + private List sanitizeStoredHighlights(List highlights) { + return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); + } + + private List sanitizeHighlights( + List items, + Function titleExtractor, + Function quoteExtractor + ) { + if (items == null) { + return List.of(); + } + + List result = new ArrayList<>(); + Set seenHighlights = new HashSet<>(); + + for (T item : items) { + if (item == null) { + continue; + } + + String rawTitle = titleExtractor.apply(item); + String rawQuote = quoteExtractor.apply(item); + if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { + continue; + } + + String title = rawTitle.trim(); + String quote = rawQuote.trim(); + if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { + continue; + } + + String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); + if (!seenHighlights.add(dedupeKey)) { + continue; + } + + result.add(new AnalysisHighlightResponse(title, quote)); + if (result.size() >= MAX_HIGHLIGHTS) { + break; + } + } + + return result; + } + + private List removeOverlappingHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private List removeOverlappingRawHighlights( + List highlights, + List existingHighlights + ) { + if (highlights == null || highlights.isEmpty()) { + return List.of(); + } + Set existingQuotes = normalizedHighlightQuotes(existingHighlights); + if (existingQuotes.isEmpty()) { + return highlights; + } + return highlights.stream() + .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) + .toList(); + } + + private Set normalizedHighlightQuotes(List highlights) { + if (highlights == null || highlights.isEmpty()) { + return Set.of(); + } + return highlights.stream() + .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) + .map(highlight -> normalizeKeyword(highlight.quote())) + .collect(Collectors.toSet()); + } + + private record AnalysisResultPayload( + List keyStrengths, + List keyWeaknesses, + List missingKeywords + ) { + } + + private void validateRequiredScores(AnalysisLlmResponse llmResponse) { + if (llmResponse == null + || llmResponse.jobFit() == null + || llmResponse.impact() == null + || llmResponse.completeness() == null) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." + ); + } + } + + private int validateScore(String fieldName, Integer score) { + if (score == null || score < MIN_SCORE || score > MAX_SCORE) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." + ); + } + return score; + } + + private int calculateScore(int jobFit, int impact, int completeness) { + return (int) Math.round( + jobFit * JOB_FIT_WEIGHT + + impact * IMPACT_WEIGHT + + completeness * COMPLETENESS_WEIGHT + ); + } + + private int findNextSentenceStart(String answer, String sentence, int fromIndex) { + int start = answer.indexOf(sentence, Math.max(0, fromIndex)); + if (start >= 0) { + return start; + } + return answer.indexOf(sentence); + } + + private String normalizeFeedback(String feedback) { + if (StringUtils.hasText(feedback)) { + return feedback; + } + return "자소서 분석 결과를 확인해주세요."; + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private String normalizeImprovement( + String sentence, + String answer, + String improvement, + QuestionAnalysisStatus status + ) { + return AnalysisSanitizationRules.normalizeImprovement( + sentence, + answer, + improvement, + status == QuestionAnalysisStatus.PROVEN + ); + } + + private QuestionAnalysisStatus parseStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + + String normalizedStatus = status.trim().toUpperCase(); + if ("GOOD".equals(normalizedStatus)) { + return QuestionAnalysisStatus.PROVEN; + } + if ("NEEDS_IMPROVEMENT".equals(normalizedStatus)) { + return QuestionAnalysisStatus.MENTIONED; + } + if ("RISK".equals(normalizedStatus)) { + return QuestionAnalysisStatus.FABRICATED; + } + + try { + return QuestionAnalysisStatus.valueOf(normalizedStatus); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 7f279a68..e47f2a04 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -1,35 +1,21 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; -import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; import com.jobdri.jobdri_api.domain.payment.service.CreditService; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -41,53 +27,26 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.COMPLETENESS_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.IMPACT_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.JOB_FIT_WEIGHT; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHTS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_QUOTE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_HIGHLIGHT_TITLE_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORDS; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MAX_SCORE; -import static com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants.MIN_SCORE; @Service @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) -// 자소서 분석의 핵심 비즈니스 로직과 결과 저장을 담당하는 메인 서비스다. +// 자소서 분석 실행 흐름을 조율하는 오케스트레이션 서비스다. public class AnalysisService { - private static final TypeReference> MISSING_KEYWORDS_TYPE = new TypeReference<>() { - }; - private static final TypeReference> HIGHLIGHTS_TYPE = new TypeReference<>() { - }; private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; - private final QuestionAnalysisRepository questionAnalysisRepository; private final JobPostingService jobPostingService; private final AnalysisAiClient analysisAiClient; private final CreditService creditService; - private final ObjectMapper objectMapper; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final CorpusRetrievalService corpusRetrievalService; private final JobPostingRagContextAssembler jobPostingRagContextAssembler; + private final AnalysisResultPersistenceService analysisResultPersistenceService; @Transactional @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") @@ -106,8 +65,7 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { try { AnalysisLlmResponse llmResponse = executeAnalysis(payload); - AnalysisResponse response = finalizeAnalysis(user, mockApplyId, payload, llmResponse); - return response; + return finalizeAnalysis(user, mockApplyId, payload, llmResponse); } catch (RuntimeException e) { refundAnalysisCredit(user, referenceId); throw e; @@ -235,86 +193,25 @@ public AnalysisResponse finalizeAnalysis( ) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payload.answerSnapshots()); - validateRequiredScores(llmResponse); - int jobFit = validateScore("jobFit", llmResponse.jobFit()); - int impact = validateScore("impact", llmResponse.impact()); - int completeness = validateScore("completeness", llmResponse.completeness()); - List keyStrengths = buildHighlights(llmResponse.keyStrengths()); - List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); - List missingKeywords = buildMissingKeywords( - mockApply.getJobPosting(), - answerSnapshot.combinedAnswers(), - llmResponse - ); - replaceExistingAnalysis(mockApply); - - Analysis analysis = analysisRepository.save(Analysis.create( + return analysisResultPersistenceService.finalizeAnalysis( mockApply, - calculateScore(jobFit, impact, completeness), - jobFit, - impact, - completeness, - normalizeFeedback(llmResponse.feedback()), - serializeMissingKeywords(missingKeywords), - serializeHighlights(keyStrengths, "keyStrengths"), - serializeHighlights(keyWeaknesses, "keyWeaknesses"), - inputFingerprint - )); - - List questionAnalyses = buildQuestionAnalyses( - analysis, questions, - answerSnapshot.answerByQuestionId(), - llmResponse + payload.answerSnapshots(), + llmResponse, + inputFingerprint ); - questionAnalysisRepository.saveAll(questionAnalyses); - mockApply.updateStatus(MockApplyStatus.COMPLETED); - - return toResponse(mockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); } @Transactional public AnalysisResponse getAnalysis(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_NOT_FOUND, - "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() - )); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List questionAnalyses = - questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - - return toResponse( - mockApply, - analysis, - questions, - questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis) - ); + return analysisResultPersistenceService.getPersistedAnalysis(getOwnedMockApply(user, mockApplyId)); } @Transactional public AnalysisResponse getAnalysisByJobPostingSequence(User user, Long jobPostingId, int sequence) { JobPosting jobPosting = jobPostingService.getOwnedJobPosting(user, jobPostingId); MockApply mockApply = resolveMockApplyBySequence(jobPosting, sequence); - Analysis analysis = analysisRepository.findByMockApplyId(mockApply.getId()) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_NOT_FOUND, - "해당 모의 서류 지원의 분석 결과를 찾을 수 없습니다. mockApplyId=" + mockApply.getId() - )); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List questionAnalyses = - questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); - - return toResponse( - mockApply, - analysis, - questions, - questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis) - ); + return analysisResultPersistenceService.getPersistedAnalysis(mockApply); } @Transactional(readOnly = true) @@ -384,50 +281,6 @@ private MockApply lockOwnedMockApply(User user, Long mockApplyId) { return mockApply; } - private void replaceExistingAnalysis(MockApply mockApply) { - Optional existingAnalysis = analysisRepository.findByMockApplyId(mockApply.getId()); - if (existingAnalysis.isEmpty()) { - return; - } - - Analysis analysis = existingAnalysis.get(); - mockApply.clearAnalysis(); - questionAnalysisRepository.deleteAllByAnalysisId(analysis.getId()); - analysisRepository.delete(analysis); - analysisRepository.flush(); - } - - private VerifiedAnswerSnapshot verifyAnswerSnapshot( - List databaseQuestions, - List payloadSnapshots - ) { - String databaseFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprintFromQuestions(databaseQuestions); - String payloadFingerprint = analysisInputFingerprintProvider - .createAnswerFingerprint(payloadSnapshots); - if (!databaseFingerprint.equals(payloadFingerprint)) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 실행 이후 자소서 답변이 변경되어 결과를 저장할 수 없습니다." - ); - } - - List immutableSnapshots = List.copyOf(payloadSnapshots); - Map answerByQuestionId = new LinkedHashMap<>(); - for (AnalysisExecutionPayload.AnswerSnapshot snapshot : immutableSnapshots) { - if (snapshot == null || snapshot.questionId() == null || !StringUtils.hasText(snapshot.answer())) { - continue; - } - if (answerByQuestionId.putIfAbsent(snapshot.questionId(), snapshot.answer()) != null) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석 답변 snapshot에 중복된 questionId가 있습니다. questionId=" + snapshot.questionId() - ); - } - } - return new VerifiedAnswerSnapshot(immutableSnapshots, Map.copyOf(answerByQuestionId)); - } - private AnalysisResponse reuseExistingAnalysisIfSameInput(User user, Long mockApplyId, String inputFingerprint) { return analysisRepository.findByMockApplyId(mockApplyId) .filter(analysis -> inputFingerprint.equals(analysis.getInputFingerprint())) @@ -439,459 +292,6 @@ private String analysisCreditReferenceId(Long mockApplyId, String inputFingerpri return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; } - private record VerifiedAnswerSnapshot( - List answers, - Map answerByQuestionId - ) { - private String combinedAnswers() { - return answers.stream() - .map(AnalysisExecutionPayload.AnswerSnapshot::answer) - .filter(StringUtils::hasText) - .collect(Collectors.joining("\n")); - } - } - - private List buildQuestionAnalyses( - Analysis analysis, - List questions, - Map answerByQuestionId, - AnalysisLlmResponse llmResponse - ) { - Map questionMap = questions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - List result = new ArrayList<>(); - Map analysisCountByQuestionId = new HashMap<>(); - Map nextSearchIndexByQuestionId = new HashMap<>(); - Set seenSentences = new HashSet<>(); - Set fabricatedQuestionIds = new HashSet<>(); - Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); - - if (llmResponse.questionAnalyses() == null) { - return result; - } - - for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { - if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { - continue; - } - - Question question = questionMap.get(item.questionId()); - if (question == null) { - continue; - } - - String answer = answerByQuestionId.get(item.questionId()); - if (!StringUtils.hasText(answer)) { - continue; - } - QuestionAnalysisStatus status = parseStatus(item.status()); - if (status == null || status == QuestionAnalysisStatus.MISSING) { - continue; - } - if (status == QuestionAnalysisStatus.PROVEN - && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { - continue; - } - if (status == QuestionAnalysisStatus.FABRICATED - && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( - item.sentence(), - item.reason() - )) { - continue; - } - int currentCount = analysisCountByQuestionId.getOrDefault(question.getId(), 0); - if (currentCount >= MAX_ANALYSES_PER_QUESTION) { - continue; - } - String sentence = item.sentence(); - if (status != QuestionAnalysisStatus.PROVEN - && keyStrengthQuotes.contains(normalizeKeyword(sentence))) { - continue; - } - String dedupeKey = question.getId() + ":" + sentence.trim(); - if (!seenSentences.add(dedupeKey)) { - continue; - } - int start = findNextSentenceStart( - answer, - sentence, - nextSearchIndexByQuestionId.getOrDefault(question.getId(), 0) - ); - if (start < 0) { - continue; - } - if (status == QuestionAnalysisStatus.FABRICATED - && !fabricatedQuestionIds.add(question.getId())) { - continue; - } - nextSearchIndexByQuestionId.put(question.getId(), start + sentence.length()); - analysisCountByQuestionId.put(question.getId(), currentCount + 1); - - result.add(QuestionAnalysis.create( - question, - analysis, - sentence, - defaultString(item.reason()), - normalizeImprovement(sentence, answer, item.improvement(), status), - status, - start, - start + sentence.length() - )); - } - - return result; - } - - private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { - if (llmResponse == null || llmResponse.keyStrengths() == null) { - return Set.of(); - } - return llmResponse.keyStrengths().stream() - .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) - .collect(Collectors.toSet()); - } - - private AnalysisResponse toResponse( - MockApply mockApply, - Analysis analysis, - List questions, - List questionAnalyses, - AnalysisResultPayload resultPayload - ) { - Map questionById = questions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - Map> analysesByQuestionId = questionAnalyses.stream() - .filter(questionAnalysis -> isValidQuestionAnalysisForResponse( - questionAnalysis, - questionById.get(questionAnalysis.getQuestion().getId()) - )) - .collect(Collectors.groupingBy( - questionAnalysis -> questionAnalysis.getQuestion().getId(), - Collectors.mapping(QuestionAnalysisResponse::from, Collectors.toList()) - )); - - List questionResponses = questions.stream() - .sorted(Comparator.comparing(Question::getId)) - .map(question -> AnalysisQuestionResponse.of( - question, - analysesByQuestionId.getOrDefault(question.getId(), List.of()) - )) - .toList(); - - return AnalysisResponse.of( - analysis, - mockApply.getStatus(), - mockApplyRepository.calculateSequence(mockApply), - resultPayload.keyStrengths(), - resultPayload.keyWeaknesses(), - resultPayload.missingKeywords(), - questionResponses - ); - } - - private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnalysis, Question question) { - if (questionAnalysis == null || question == null) { - return false; - } - if (questionAnalysis.getStatus() == QuestionAnalysisStatus.MISSING) { - return false; - } - String answer = question.getAnswer(); - String sentence = questionAnalysis.getSentence(); - int start = questionAnalysis.getStart(); - int end = questionAnalysis.getEnd(); - if (!StringUtils.hasText(answer) || !StringUtils.hasText(sentence)) { - return false; - } - if (start < 0 || end <= start || end > answer.length()) { - return false; - } - return answer.substring(start, end).equals(sentence); - } - - private AnalysisResultPayload analysisResultPayload(Analysis analysis) { - List keyStrengths = - readHighlights(analysis, analysis == null ? null : analysis.getKeyStrengthsJson(), "keyStrengths"); - return new AnalysisResultPayload( - keyStrengths, - removeOverlappingHighlights( - readHighlights(analysis, analysis == null ? null : analysis.getKeyWeaknessesJson(), "keyWeaknesses"), - keyStrengths - ), - readMissingKeywords(analysis) - ); - } - - private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis) { - AnalysisResultPayload payload = analysisResultPayload(analysis); - if (analysis != null) { - analysis.updateHighlightsJson( - serializeHighlights(payload.keyStrengths(), "keyStrengths"), - serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses") - ); - } - return payload; - } - - private List buildHighlights(List items) { - return sanitizeHighlights(items, AnalysisLlmResponse.HighlightItem::title, AnalysisLlmResponse.HighlightItem::quote); - } - - private List buildNonOverlappingHighlights( - List items, - List existingHighlights - ) { - return sanitizeHighlights( - removeOverlappingRawHighlights(items, existingHighlights), - AnalysisLlmResponse.HighlightItem::title, - AnalysisLlmResponse.HighlightItem::quote - ); - } - - private List buildMissingKeywords( - JobPosting jobPosting, - String combinedAnswers, - AnalysisLlmResponse llmResponse - ) { - if (llmResponse == null || llmResponse.missingKeywords() == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { - if (item == null || !StringUtils.hasText(item.keyword())) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - Optional source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } - if (!AnalysisSanitizationRules.isValidMissingKeyword( - keyword, - source.get(), - jobPosting == null ? "" : jobPosting.getTask(), - jobPosting == null ? "" : jobPosting.getRequirement() - )) { - continue; - } - if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, combinedAnswers)) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private String normalizeKeyword(String keyword) { - return keyword == null ? "" : keyword.replaceAll("\\s+", "").toLowerCase(); - } - - private String serializeMissingKeywords(List missingKeywords) { - try { - return objectMapper.writeValueAsString(missingKeywords == null ? List.of() : missingKeywords); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize missingKeywords. Fallback to empty array.", e); - return "[]"; - } - } - - private String serializeHighlights(List highlights, String fieldName) { - try { - return objectMapper.writeValueAsString(highlights == null ? List.of() : highlights); - } catch (JsonProcessingException e) { - log.warn("Failed to serialize {}. Fallback to empty array.", fieldName, e); - return "[]"; - } - } - - private List readHighlights(Analysis analysis, String json, String fieldName) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - - try { - List highlights = objectMapper.readValue(json, HIGHLIGHTS_TYPE); - return sanitizeStoredHighlights(highlights); - } catch (Exception e) { - log.warn( - "Failed to deserialize {}. analysisId={}, fallback to empty array.", - fieldName, - analysis == null ? null : analysis.getId(), - e - ); - return List.of(); - } - } - - private List readMissingKeywords(Analysis analysis) { - if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { - return List.of(); - } - - try { - List missingKeywords = objectMapper.readValue( - analysis.getMissingKeywordsJson(), - MISSING_KEYWORDS_TYPE - ); - return sanitizeStoredMissingKeywords(missingKeywords); - } catch (Exception e) { - log.warn( - "Failed to deserialize missingKeywords. analysisId={}, fallback to empty array.", - analysis.getId(), - e - ); - return List.of(); - } - } - - private List sanitizeStoredMissingKeywords(List missingKeywords) { - if (missingKeywords == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenKeywords = new HashSet<>(); - - for (MissingKeywordResponse item : missingKeywords) { - if (item == null || !StringUtils.hasText(item.keyword()) || item.source() == null) { - continue; - } - - String keyword = item.keyword().trim(); - if (keyword.length() > MAX_MISSING_KEYWORD_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(keyword); - if (!seenKeywords.add(dedupeKey)) { - continue; - } - - result.add(new MissingKeywordResponse(keyword, item.source())); - if (result.size() >= MAX_MISSING_KEYWORDS) { - break; - } - } - - return result; - } - - private List sanitizeStoredHighlights(List highlights) { - return sanitizeHighlights(highlights, AnalysisHighlightResponse::title, AnalysisHighlightResponse::quote); - } - - private List sanitizeHighlights( - List items, - Function titleExtractor, - Function quoteExtractor - ) { - if (items == null) { - return List.of(); - } - - List result = new ArrayList<>(); - Set seenHighlights = new HashSet<>(); - - for (T item : items) { - if (item == null) { - continue; - } - - String rawTitle = titleExtractor.apply(item); - String rawQuote = quoteExtractor.apply(item); - if (!StringUtils.hasText(rawTitle) || !StringUtils.hasText(rawQuote)) { - continue; - } - - String title = rawTitle.trim(); - String quote = rawQuote.trim(); - if (title.length() > MAX_HIGHLIGHT_TITLE_LENGTH || quote.length() > MAX_HIGHLIGHT_QUOTE_LENGTH) { - continue; - } - - String dedupeKey = normalizeKeyword(title) + ":" + normalizeKeyword(quote); - if (!seenHighlights.add(dedupeKey)) { - continue; - } - - result.add(new AnalysisHighlightResponse(title, quote)); - if (result.size() >= MAX_HIGHLIGHTS) { - break; - } - } - - return result; - } - - private List removeOverlappingHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private List removeOverlappingRawHighlights( - List highlights, - List existingHighlights - ) { - if (highlights == null || highlights.isEmpty()) { - return List.of(); - } - Set existingQuotes = normalizedHighlightQuotes(existingHighlights); - if (existingQuotes.isEmpty()) { - return highlights; - } - return highlights.stream() - .filter(highlight -> highlight != null && !existingQuotes.contains(normalizeKeyword(highlight.quote()))) - .toList(); - } - - private Set normalizedHighlightQuotes(List highlights) { - if (highlights == null || highlights.isEmpty()) { - return Set.of(); - } - return highlights.stream() - .filter(highlight -> highlight != null && StringUtils.hasText(highlight.quote())) - .map(highlight -> normalizeKeyword(highlight.quote())) - .collect(Collectors.toSet()); - } - - private record AnalysisResultPayload( - List keyStrengths, - List keyWeaknesses, - List missingKeywords - ) { - } - private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( @@ -905,91 +305,4 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } - - private void validateRequiredScores(AnalysisLlmResponse llmResponse) { - if (llmResponse == null - || llmResponse.jobFit() == null - || llmResponse.impact() == null - || llmResponse.completeness() == null) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답에 필수 점수 필드가 누락되었습니다." - ); - } - } - - private int validateScore(String fieldName, Integer score) { - if (score == null || score < MIN_SCORE || score > MAX_SCORE) { - throw new GeneralException( - GeneralErrorCode.SERVICE_UNAVAILABLE, - "자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다." - ); - } - return score; - } - - private int calculateScore(int jobFit, int impact, int completeness) { - return (int) Math.round( - jobFit * JOB_FIT_WEIGHT - + impact * IMPACT_WEIGHT - + completeness * COMPLETENESS_WEIGHT - ); - } - - private int findNextSentenceStart(String answer, String sentence, int fromIndex) { - int start = answer.indexOf(sentence, Math.max(0, fromIndex)); - if (start >= 0) { - return start; - } - return answer.indexOf(sentence); - } - - private String normalizeFeedback(String feedback) { - if (StringUtils.hasText(feedback)) { - return feedback; - } - return "자소서 분석 결과를 확인해주세요."; - } - - private String defaultString(String value) { - return value == null ? "" : value; - } - - private String normalizeImprovement( - String sentence, - String answer, - String improvement, - QuestionAnalysisStatus status - ) { - return AnalysisSanitizationRules.normalizeImprovement( - sentence, - answer, - improvement, - status == QuestionAnalysisStatus.PROVEN - ); - } - - private QuestionAnalysisStatus parseStatus(String status) { - if (!StringUtils.hasText(status)) { - return null; - } - - String normalizedStatus = status.trim().toUpperCase(); - if ("GOOD".equals(normalizedStatus)) { - return QuestionAnalysisStatus.PROVEN; - } - if ("NEEDS_IMPROVEMENT".equals(normalizedStatus)) { - return QuestionAnalysisStatus.MENTIONED; - } - if ("RISK".equals(normalizedStatus)) { - return QuestionAnalysisStatus.FABRICATED; - } - - try { - return QuestionAnalysisStatus.valueOf(normalizedStatus); - } catch (IllegalArgumentException e) { - return null; - } - } - } From dc212cb465faad529163d75769470f2a064862ac Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:05:34 +0900 Subject: [PATCH 19/55] =?UTF-8?q?[Refactor]=20Analysis=20=ED=81=AC?= =?UTF-8?q?=EB=A0=88=EB=94=A7=20=EC=A0=95=EC=B1=85=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncSweepService.java | 6 ++--- .../async/AnalysisAsyncTaskService.java | 4 +++- .../async/AnalysisWorkerBridgeService.java | 8 ++++--- .../service/core/AnalysisService.java | 24 ++++--------------- .../async/AnalysisAsyncFacadeServiceTest.java | 2 -- .../AnalysisWorkerBridgeServiceTest.java | 13 +++++++--- 6 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index 9183603e..83354b78 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -5,7 +5,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -25,7 +25,7 @@ public class AnalysisAsyncSweepService { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisService analysisService; + private final AnalysisCreditService analysisCreditService; private final UserService userService; private final TransactionTemplate transactionTemplate; private final AnalysisQueueProperties analysisQueueProperties; @@ -98,7 +98,7 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { } User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); analysisAsyncTaskService.markCreditReleased(task.getTaskId()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index 012eaddc..ba304e7e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -12,6 +12,7 @@ import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -58,6 +59,7 @@ public class AnalysisAsyncTaskService { private final AsyncMetricsRecorder asyncMetricsRecorder; private final AnalysisQueueProperties analysisQueueProperties; private final AnalysisService analysisService; + private final AnalysisCreditService analysisCreditService; private final UserService userService; private final AsyncProgressCalculator asyncProgressCalculator; @@ -260,7 +262,7 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { return; } User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); task.markCreditReleased(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index 322fdb6c..ed85cfb1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -14,6 +14,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; @@ -45,6 +46,7 @@ public class AnalysisWorkerBridgeService { private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisService analysisService; + private final AnalysisCreditService analysisCreditService; private final UserService userService; private final WorkerTaskResultService workerTaskResultService; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; @@ -306,8 +308,8 @@ private void reserveCreditIfNeeded(AnalysisAsyncTask task) { } User user = userService.getUser(task.getUserId()); - String creditReferenceId = "analysisTaskId=" + task.getTaskId(); - analysisService.deductAnalysisCredit(user, creditReferenceId); + String creditReferenceId = analysisCreditService.createAsyncReferenceId(task.getTaskId()); + analysisCreditService.deduct(user, creditReferenceId); analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); } @@ -323,7 +325,7 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { return; } User user = userService.getUser(task.getUserId()); - analysisService.refundAnalysisCredit(user, task.getCreditReferenceId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); analysisAsyncTaskService.markCreditReleased(task.getTaskId()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index e47f2a04..6748fd8e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -17,7 +17,6 @@ import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.payment.service.CreditService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -41,7 +40,7 @@ public class AnalysisService { private final AnalysisRepository analysisRepository; private final JobPostingService jobPostingService; private final AnalysisAiClient analysisAiClient; - private final CreditService creditService; + private final AnalysisCreditService analysisCreditService; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final CorpusRetrievalService corpusRetrievalService; @@ -60,14 +59,14 @@ public AnalysisResponse analyze(User user, Long mockApplyId) { return cachedResponse; } - String referenceId = analysisCreditReferenceId(mockApplyId, inputFingerprint); - deductAnalysisCredit(user, referenceId); + String referenceId = analysisCreditService.createSyncReferenceId(mockApplyId, inputFingerprint); + analysisCreditService.deduct(user, referenceId); try { AnalysisLlmResponse llmResponse = executeAnalysis(payload); return finalizeAnalysis(user, mockApplyId, payload, llmResponse); } catch (RuntimeException e) { - refundAnalysisCredit(user, referenceId); + analysisCreditService.refund(user, referenceId); throw e; } } @@ -79,16 +78,6 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { answeredQuestionsOrThrow(questions); } - @Transactional - public void deductAnalysisCredit(User user, String referenceId) { - creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId); - } - - @Transactional - public void refundAnalysisCredit(User user, String referenceId) { - creditService.refund(user, 1, "자소서 분석 크레딧 환불", referenceId); - } - @Transactional(readOnly = true) public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { MockApply mockApply = getOwnedMockApply(user, mockApplyId); @@ -287,11 +276,6 @@ private AnalysisResponse reuseExistingAnalysisIfSameInput(User user, Long mockAp .map(analysis -> getAnalysis(user, mockApplyId)) .orElse(null); } - - private String analysisCreditReferenceId(Long mockApplyId, String inputFingerprint) { - return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; - } - private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index d069f316..742d303c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -68,7 +68,6 @@ void submitReturnsExistingTaskWhenCreateConflicts() { assertThat(response.taskId()).isEqualTo(existingTask.getTaskId()); assertThat(response.status()).isEqualTo("PENDING"); - verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); verify(analysisAsyncProcessor, never()).process(eq(existingTask.getTaskId()), eq(1L), eq(10L), eq(3)); } @@ -110,7 +109,6 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { assertThat(response.cached()).isFalse(); assertThat(response.resultAvailable()).isFalse(); verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); - verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index 06351ae8..f20a5a36 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -10,6 +10,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; @@ -60,6 +61,9 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisService analysisService; + @Mock + private AnalysisCreditService analysisCreditService; + @Mock private UserService userService; @@ -128,7 +132,7 @@ void getContextRejectsCancelledTaskWithoutCreditReservation() { assertThatThrownBy(() -> analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L)) .isInstanceOf(GeneralException.class); - verify(analysisService, never()).deductAnalysisCredit(any(), anyString()); + verify(analysisCreditService, never()).deduct(any(), anyString()); verify(analysisAsyncTaskService, never()).markCreditReserved(anyString(), anyString()); } @@ -186,9 +190,12 @@ void getContextReservesCreditBeforePreparingExecution() { when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId())) + .thenReturn("analysisTaskId=" + task.getTaskId()); var context = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); - verify(analysisService).deductAnalysisCredit(user, "analysisTaskId=" + task.getTaskId()); + verify(analysisCreditService).createAsyncReferenceId(task.getTaskId()); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); verify(analysisService).prepareAnalysisExecution(user, 10L); assertThat(context.corpusReferences()).hasSize(1); @@ -229,7 +236,7 @@ void getContextDoesNotReserveCreditTwice() { analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); - verify(analysisService, never()).deductAnalysisCredit(eq(user), anyString()); + verify(analysisCreditService, never()).deduct(eq(user), anyString()); verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } From e09888d4e02dfc8ed8f3e13b4a93b08f0478f022 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:08:42 +0900 Subject: [PATCH 20/55] =?UTF-8?q?[Refactor]=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=A4=80=EB=B9=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/AnalysisPreparationResult.java | 39 ++++++ .../core/AnalysisPreparationService.java | 132 ++++++++++++++++++ .../service/core/AnalysisService.java | 69 +-------- 3 files changed, 174 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java new file mode 100644 index 00000000..3c8b354b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java @@ -0,0 +1,39 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; + +import java.util.List; + +public record AnalysisPreparationResult( + Long userId, + Long mockApplyId, + JobPosting jobPosting, + List questions, + List answeredQuestions, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + RetrievalContext retrievalContext, + List similarJobPostings +) { + public AnalysisPreparationResult { + questions = questions == null ? List.of() : List.copyOf(questions); + answeredQuestions = answeredQuestions == null ? List.of() : List.copyOf(answeredQuestions); + similarJobPostings = similarJobPostings == null ? List.of() : List.copyOf(similarJobPostings); + } + + public AnalysisExecutionPayload toExecutionPayload() { + return new AnalysisExecutionPayload( + userId, + mockApplyId, + jobPosting, + questions, + answeredQuestions, + jobCategoryEvaluationCriteria, + retrievalContext, + similarJobPostings + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java new file mode 100644 index 00000000..fe8f3d0a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java @@ -0,0 +1,132 @@ +package com.jobdri.jobdri_api.domain.analysis.service.core; + +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisPreparationService { + + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final CorpusRetrievalService corpusRetrievalService; + private final JobPostingRagContextAssembler jobPostingRagContextAssembler; + + public AnalysisPreparationResult prepare(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = answeredQuestionsOrThrow(questions); + return prepare( + user, + mockApply, + questions, + answeredQuestions, + retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), + jobPostingRagContextAssembler.assemble(mockApply.getJobPosting().getId()) + ); + } + + public AnalysisPreparationResult prepare( + User user, + Long mockApplyId, + List similarJobPostings + ) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List answeredQuestions = answeredQuestionsOrThrow(questions); + return prepare( + user, + mockApply, + questions, + answeredQuestions, + new RetrievalContext(List.of(), List.of()), + similarJobPostings + ); + } + + private AnalysisPreparationResult prepare( + User user, + MockApply mockApply, + List questions, + List answeredQuestions, + RetrievalContext retrievalContext, + List similarJobPostings + ) { + // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. + mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); + mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); + JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider + .findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) + .orElse(null); + + return new AnalysisPreparationResult( + user.getId(), + mockApply.getId(), + mockApply.getJobPosting(), + questions, + answeredQuestions, + evaluationCriteria, + retrievalContext, + similarJobPostings + ); + } + + private List answeredQuestionsOrThrow(List questions) { + List answeredQuestions = questions.stream() + .filter(question -> StringUtils.hasText(question.getAnswer())) + .toList(); + if (answeredQuestions.isEmpty()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "분석할 자소서 답변이 1개 이상 필요합니다." + ); + } + return answeredQuestions; + } + + private RetrievalContext retrieveAnalysisReferences(JobPosting jobPosting, List answeredQuestions) { + try { + return corpusRetrievalService.retrieveForAnalysis(jobPosting, answeredQuestions); + } catch (Exception exception) { + log.warn("자소서 분석 Curated Corpus retrieval 실패. fallback without references. message={}", exception.getMessage()); + log.debug("analysis Curated Corpus retrieval exception", exception); + return new RetrievalContext(List.of(), List.of()); + } + } + + private MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 6748fd8e..c691212a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -8,11 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; -import com.jobdri.jobdri_api.domain.analysis.service.retrieval.JobPostingRagContextAssembler; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; @@ -41,10 +37,8 @@ public class AnalysisService { private final JobPostingService jobPostingService; private final AnalysisAiClient analysisAiClient; private final AnalysisCreditService analysisCreditService; - private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; - private final CorpusRetrievalService corpusRetrievalService; - private final JobPostingRagContextAssembler jobPostingRagContextAssembler; + private final AnalysisPreparationService analysisPreparationService; private final AnalysisResultPersistenceService analysisResultPersistenceService; @Transactional @@ -80,17 +74,7 @@ public void validateAnalysisRequest(User user, Long mockApplyId) { @Transactional(readOnly = true) public AnalysisExecutionPayload prepareAnalysisExecution(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); - return prepareAnalysisExecution( - user, - mockApply, - questions, - answeredQuestions, - retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), - jobPostingRagContextAssembler.assemble(mockApply.getJobPosting().getId()) - ); + return analysisPreparationService.prepare(user, mockApplyId).toExecutionPayload(); } @Transactional(readOnly = true) @@ -99,44 +83,7 @@ public AnalysisExecutionPayload prepareAnalysisExecution( Long mockApplyId, List similarJobPostings ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); - return prepareAnalysisExecution( - user, - mockApply, - questions, - answeredQuestions, - new RetrievalContext(List.of(), List.of()), - similarJobPostings - ); - } - - private AnalysisExecutionPayload prepareAnalysisExecution( - User user, - MockApply mockApply, - List questions, - List answeredQuestions, - RetrievalContext retrievalContext, - List similarJobPostings - ) { - // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); - JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider - .findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) - .orElse(null); - - return new AnalysisExecutionPayload( - user.getId(), - mockApply.getId(), - mockApply.getJobPosting(), - List.copyOf(questions), - List.copyOf(answeredQuestions), - evaluationCriteria, - retrievalContext, - similarJobPostings - ); + return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } private List answeredQuestionsOrThrow(List questions) { @@ -246,16 +193,6 @@ private MockApply resolveMockApplyBySequence(JobPosting jobPosting, int sequence ); } - private RetrievalContext retrieveAnalysisReferences(JobPosting jobPosting, List answeredQuestions) { - try { - return corpusRetrievalService.retrieveForAnalysis(jobPosting, answeredQuestions); - } catch (Exception exception) { - log.warn("자소서 분석 Curated Corpus retrieval 실패. fallback without references. message={}", exception.getMessage()); - log.debug("analysis Curated Corpus retrieval exception", exception); - return new RetrievalContext(List.of(), List.of()); - } - } - private MockApply lockOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findByIdForUpdate(mockApplyId) .orElseThrow(() -> new GeneralException( From c526bf22963d6466885c462d0f85e8c7d7bd62d6 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:39:34 +0900 Subject: [PATCH 21/55] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncTaskService.java | 2 - .../service/core/AnalysisCreditService.java | 2 - .../core/AnalysisPreparationService.java | 72 ++++++++------- .../AnalysisResultPersistenceService.java | 53 +++++++---- .../service/core/AnalysisService.java | 87 ++++++++++--------- .../async/AnalysisAsyncFacadeServiceTest.java | 18 ++-- .../AnalysisWorkerBridgeServiceTest.java | 45 ++++++++-- 7 files changed, 179 insertions(+), 100 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index ba304e7e..b84c2868 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -13,7 +13,6 @@ import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; @@ -58,7 +57,6 @@ public class AnalysisAsyncTaskService { private final NotificationService notificationService; private final AsyncMetricsRecorder asyncMetricsRecorder; private final AnalysisQueueProperties analysisQueueProperties; - private final AnalysisService analysisService; private final AnalysisCreditService analysisCreditService; private final UserService userService; private final AsyncProgressCalculator asyncProgressCalculator; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java index f7a7fc7e..2b14be7a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java @@ -12,12 +12,10 @@ public class AnalysisCreditService { private final CreditService creditService; - @Transactional(readOnly = true) public String createSyncReferenceId(Long mockApplyId, String inputFingerprint) { return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; } - @Transactional(readOnly = true) public String createAsyncReferenceId(String taskId) { return "analysisTaskId=" + taskId; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java index fe8f3d0a..b375fdca 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java @@ -16,6 +16,7 @@ import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -35,16 +36,12 @@ public class AnalysisPreparationService { private final JobPostingRagContextAssembler jobPostingRagContextAssembler; public AnalysisPreparationResult prepare(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); + PreparedMockApplyContext context = prepareMockApplyContext(user, mockApplyId); return prepare( - user, - mockApply, - questions, - answeredQuestions, - retrieveAnalysisReferences(mockApply.getJobPosting(), answeredQuestions), - jobPostingRagContextAssembler.assemble(mockApply.getJobPosting().getId()) + user.getId(), + context, + retrieveAnalysisReferences(context.mockApply().getJobPosting(), context.answeredQuestions()), + jobPostingRagContextAssembler.assemble(context.mockApply().getJobPosting().getId()) ); } @@ -53,46 +50,56 @@ public AnalysisPreparationResult prepare( Long mockApplyId, List similarJobPostings ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - List answeredQuestions = answeredQuestionsOrThrow(questions); + PreparedMockApplyContext context = prepareMockApplyContext(user, mockApplyId); return prepare( - user, - mockApply, - questions, - answeredQuestions, + user.getId(), + context, new RetrievalContext(List.of(), List.of()), similarJobPostings ); } private AnalysisPreparationResult prepare( - User user, - MockApply mockApply, - List questions, - List answeredQuestions, + Long userId, + PreparedMockApplyContext context, RetrievalContext retrievalContext, List similarJobPostings ) { - // Initialize hierarchy before leaving the read transaction so detached payload can be used safely. - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName(); - mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(); + initializePreparationHierarchy(context.mockApply()); JobCategoryEvaluationCriteria evaluationCriteria = jobCategoryEvaluationCriteriaProvider - .findByMiddleName(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) + .findByMiddleName(context.mockApply().getJobPosting().getDetailClassification().getMiddleClassification().getMiddleName()) .orElse(null); return new AnalysisPreparationResult( - user.getId(), - mockApply.getId(), - mockApply.getJobPosting(), - questions, - answeredQuestions, + userId, + context.mockApply().getId(), + context.mockApply().getJobPosting(), + context.questions(), + context.answeredQuestions(), evaluationCriteria, retrievalContext, similarJobPostings ); } + private PreparedMockApplyContext prepareMockApplyContext(User user, Long mockApplyId) { + MockApply mockApply = getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + return new PreparedMockApplyContext( + mockApply, + questions, + answeredQuestionsOrThrow(questions) + ); + } + + private void initializePreparationHierarchy(MockApply mockApply) { + Hibernate.initialize(mockApply.getJobPosting()); + Hibernate.initialize(mockApply.getJobPosting().getCompany()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification().getMiddleClassification()); + Hibernate.initialize(mockApply.getJobPosting().getDetailClassification().getMiddleClassification().getClassification()); + } + private List answeredQuestionsOrThrow(List questions) { List answeredQuestions = questions.stream() .filter(question -> StringUtils.hasText(question.getAnswer())) @@ -129,4 +136,11 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } + + private record PreparedMockApplyContext( + MockApply mockApply, + List questions, + List answeredQuestions + ) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java index ff676429..683247cf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -79,6 +79,11 @@ public AnalysisResponse finalizeAnalysis( AnalysisLlmResponse llmResponse, String inputFingerprint ) { + MockApply lockedMockApply = mockApplyRepository.findByIdForUpdate(mockApply.getId()) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApply.getId() + )); VerifiedAnswerSnapshot answerSnapshot = verifyAnswerSnapshot(questions, payloadSnapshots); validateRequiredScores(llmResponse); int jobFit = validateScore("jobFit", llmResponse.jobFit()); @@ -87,14 +92,14 @@ public AnalysisResponse finalizeAnalysis( List keyStrengths = buildHighlights(llmResponse.keyStrengths()); List keyWeaknesses = buildNonOverlappingHighlights(llmResponse.keyWeaknesses(), keyStrengths); List missingKeywords = buildMissingKeywords( - mockApply.getJobPosting(), + lockedMockApply.getJobPosting(), answerSnapshot.combinedAnswers(), llmResponse ); - replaceExistingAnalysis(mockApply); + replaceExistingAnalysis(lockedMockApply); Analysis analysis = analysisRepository.save(Analysis.create( - mockApply, + lockedMockApply, calculateScore(jobFit, impact, completeness), jobFit, impact, @@ -113,9 +118,10 @@ public AnalysisResponse finalizeAnalysis( llmResponse ); questionAnalysisRepository.saveAll(questionAnalyses); - mockApply.updateStatus(MockApplyStatus.COMPLETED); + lockedMockApply.updateStatus(MockApplyStatus.COMPLETED); + mockApplyRepository.flush(); - return toResponse(mockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); + return toResponse(lockedMockApply, analysis, questions, questionAnalyses, analysisResultPayload(analysis)); } @Transactional @@ -134,7 +140,22 @@ public AnalysisResponse getPersistedAnalysis(MockApply mockApply) { analysis, questions, questionAnalyses, - sanitizeAndPersistAnalysisPayload(analysis) + sanitizeAndPersistAnalysisPayload(analysis, true) + ); + } + + @Transactional(readOnly = true) + public AnalysisResponse getPersistedAnalysis(MockApply mockApply, Analysis analysis) { + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + List questionAnalyses = + questionAnalysisRepository.findAllByAnalysisIdOrderByQuestionIdAscIdAsc(analysis.getId()); + + return toResponse( + mockApply, + analysis, + questions, + questionAnalyses, + sanitizeAndPersistAnalysisPayload(analysis, false) ); } @@ -354,25 +375,25 @@ private boolean isValidQuestionAnalysisForResponse(QuestionAnalysis questionAnal } private AnalysisResultPayload analysisResultPayload(Analysis analysis) { - List keyStrengths = - readHighlights(analysis, analysis == null ? null : analysis.getKeyStrengthsJson(), "keyStrengths"); + List keyStrengths = readHighlights(analysis, analysis.getKeyStrengthsJson(), "keyStrengths"); return new AnalysisResultPayload( keyStrengths, removeOverlappingHighlights( - readHighlights(analysis, analysis == null ? null : analysis.getKeyWeaknessesJson(), "keyWeaknesses"), + readHighlights(analysis, analysis.getKeyWeaknessesJson(), "keyWeaknesses"), keyStrengths ), readMissingKeywords(analysis) ); } - private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis) { + private AnalysisResultPayload sanitizeAndPersistAnalysisPayload(Analysis analysis, boolean persistIfChanged) { AnalysisResultPayload payload = analysisResultPayload(analysis); - if (analysis != null) { - analysis.updateHighlightsJson( - serializeHighlights(payload.keyStrengths(), "keyStrengths"), - serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses") - ); + String sanitizedKeyStrengthsJson = serializeHighlights(payload.keyStrengths(), "keyStrengths"); + String sanitizedKeyWeaknessesJson = serializeHighlights(payload.keyWeaknesses(), "keyWeaknesses"); + if (persistIfChanged + && (!sanitizedKeyStrengthsJson.equals(analysis.getKeyStrengthsJson()) + || !sanitizedKeyWeaknessesJson.equals(analysis.getKeyWeaknessesJson()))) { + analysis.updateHighlightsJson(sanitizedKeyStrengthsJson, sanitizedKeyWeaknessesJson); } return payload; } @@ -486,7 +507,7 @@ private List readHighlights(Analysis analysis, String } private List readMissingKeywords(Analysis analysis) { - if (analysis == null || !StringUtils.hasText(analysis.getMissingKeywordsJson())) { + if (!StringUtils.hasText(analysis.getMissingKeywordsJson())) { return List.of(); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index c691212a..fa9360b5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -1,6 +1,5 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; @@ -18,11 +17,15 @@ import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; @Service @Slf4j @@ -30,6 +33,7 @@ @Transactional(readOnly = true) // 자소서 분석 실행 흐름을 조율하는 오케스트레이션 서비스다. public class AnalysisService { + private static final ConcurrentMap ANALYSIS_LOCKS = new ConcurrentHashMap<>(); private final MockApplyRepository mockApplyRepository; private final QuestionRepository questionRepository; @@ -40,36 +44,47 @@ public class AnalysisService { private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final AnalysisPreparationService analysisPreparationService; private final AnalysisResultPersistenceService analysisResultPersistenceService; + private final ObjectProvider selfProvider; - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "ANALYSIS_RUN", targetType = "MOCK_APPLY", targetId = "#arg1") public AnalysisResponse analyze(User user, Long mockApplyId) { - validateAnalysisRequest(user, mockApplyId); - AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); - String inputFingerprint = analysisInputFingerprintProvider.create(payload); - lockOwnedMockApply(user, mockApplyId); - AnalysisResponse cachedResponse = reuseExistingAnalysisIfSameInput(user, mockApplyId, inputFingerprint); - if (cachedResponse != null) { - return cachedResponse; - } - - String referenceId = analysisCreditService.createSyncReferenceId(mockApplyId, inputFingerprint); - analysisCreditService.deduct(user, referenceId); - + ReentrantLock analysisLock = ANALYSIS_LOCKS.computeIfAbsent(mockApplyId, ignored -> new ReentrantLock()); + analysisLock.lock(); try { - AnalysisLlmResponse llmResponse = executeAnalysis(payload); - return finalizeAnalysis(user, mockApplyId, payload, llmResponse); - } catch (RuntimeException e) { - analysisCreditService.refund(user, referenceId); - throw e; + AnalysisService self = selfProvider.getObject(); + AnalysisExecutionPayload payload = prepareAnalysisExecution(user, mockApplyId); + String inputFingerprint = analysisInputFingerprintProvider.create(payload); + AnalysisResponse cachedResponse = self.lockAndReuseExistingAnalysis(user, mockApplyId, inputFingerprint); + if (cachedResponse != null) { + return cachedResponse; + } + + String referenceId = analysisCreditService.createSyncReferenceId(mockApplyId, inputFingerprint); + boolean creditDeducted = false; + + try { + AnalysisLlmResponse llmResponse = executeAnalysis(payload); + analysisCreditService.deduct(user, referenceId); + creditDeducted = true; + return self.finalizeAnalysis(user, mockApplyId, payload, llmResponse); + } catch (RuntimeException e) { + if (creditDeducted) { + analysisCreditService.refund(user, referenceId); + } + throw e; + } + } finally { + analysisLock.unlock(); + if (!analysisLock.hasQueuedThreads()) { + ANALYSIS_LOCKS.remove(mockApplyId, analysisLock); + } } } @Transactional(readOnly = true) public void validateAnalysisRequest(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - answeredQuestionsOrThrow(questions); + analysisPreparationService.prepare(user, mockApplyId); } @Transactional(readOnly = true) @@ -86,23 +101,16 @@ public AnalysisExecutionPayload prepareAnalysisExecution( return analysisPreparationService.prepare(user, mockApplyId, similarJobPostings).toExecutionPayload(); } - private List answeredQuestionsOrThrow(List questions) { - List answeredQuestions = questions.stream() - .filter(question -> StringUtils.hasText(question.getAnswer())) - .toList(); - if (answeredQuestions.isEmpty()) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "분석할 자소서 답변이 1개 이상 필요합니다." - ); - } - return answeredQuestions; - } - public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { return analysisAiClient.analyze(payload); } + @Transactional + public AnalysisResponse lockAndReuseExistingAnalysis(User user, Long mockApplyId, String inputFingerprint) { + MockApply mockApply = lockOwnedMockApply(user, mockApplyId); + return reuseExistingAnalysisIfSameInput(mockApply, inputFingerprint); + } + @Transactional public AnalysisResponse finalizeAnalysis( User user, @@ -207,12 +215,13 @@ private MockApply lockOwnedMockApply(User user, Long mockApplyId) { return mockApply; } - private AnalysisResponse reuseExistingAnalysisIfSameInput(User user, Long mockApplyId, String inputFingerprint) { - return analysisRepository.findByMockApplyId(mockApplyId) + private AnalysisResponse reuseExistingAnalysisIfSameInput(MockApply mockApply, String inputFingerprint) { + return analysisRepository.findByMockApplyId(mockApply.getId()) .filter(analysis -> inputFingerprint.equals(analysis.getInputFingerprint())) - .map(analysis -> getAnalysis(user, mockApplyId)) + .map(analysis -> analysisResultPersistenceService.getPersistedAnalysis(mockApply, analysis)) .orElse(null); } + private MockApply getOwnedMockApply(User user, Long mockApplyId) { MockApply mockApply = mockApplyRepository.findById(mockApplyId) .orElseThrow(() -> new GeneralException( diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index 742d303c..6706eff9 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -15,6 +15,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -30,6 +32,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -189,16 +192,17 @@ void getTaskReturnsResultWhenSucceeded() { verify(analysisService, times(1)).getAnalysis(user, 10L); } - @Test - @DisplayName("진행 중 task 상태 조회는 분석 결과를 조회하지 않는다") - void getTaskReturnsStatusWithoutResultWhenInProgress() { - User user = User.signup("테스트 사용자", "analysis-async-status-running@example.com", "encoded-password"); + @ParameterizedTest + @ValueSource(strings = {"RUNNING", "PENDING", "FAILED", "CANCELLED"}) + @DisplayName("비완료 task 상태 조회는 분석 결과를 조회하지 않는다") + void getTaskReturnsStatusWithoutResultWhenNotSucceeded(String taskStatus) { + User user = User.signup("테스트 사용자", "analysis-async-status-" + taskStatus.toLowerCase() + "@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); AnalysisAsyncStatusResponse status = AnalysisAsyncStatusResponse.builder() .taskId("task-1") .mockApplyId(10L) - .status("RUNNING") - .message("분석 중입니다.") + .status(taskStatus) + .message("분석 상태입니다.") .build(); when(userService.validateUser(user)).thenReturn(user); @@ -229,7 +233,7 @@ void getTaskThrowsWhenMockApplyIdDoesNotMatch() { .isInstanceOf(GeneralException.class) .extracting("code") .isEqualTo(GeneralErrorCode.FORBIDDEN); - verify(analysisService, never()).getAnalysis(user, 99L); + verifyNoInteractions(analysisService); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index f20a5a36..d41cab02 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -45,6 +45,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -192,15 +193,23 @@ void getContextReservesCreditBeforePreparingExecution() { when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); when(analysisCreditService.createAsyncReferenceId(task.getTaskId())) .thenReturn("analysisTaskId=" + task.getTaskId()); - var context = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + when(analysisInputFingerprintProvider.create(payload)).thenReturn("initial-fingerprint"); + doAnswer(invocation -> { + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", invocation.getArgument(1, String.class)); + return null; + }).when(analysisAsyncTaskService).markCreditReserved(eq(task.getTaskId()), anyString()); + + var firstContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); + var secondContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); verify(analysisCreditService).createAsyncReferenceId(task.getTaskId()); verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); - verify(analysisService).prepareAnalysisExecution(user, 10L); - assertThat(context.corpusReferences()).hasSize(1); - assertThat(context.corpusReferences().getFirst().corpusId()).isEqualTo(11L); - assertThat(context.similarJobPostings()).containsExactly(similarContext); + verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); + assertThat(firstContext).isEqualTo(secondContext); + assertThat(firstContext.corpusReferences()).hasSize(1); + assertThat(firstContext.corpusReferences().getFirst().corpusId()).isEqualTo(11L); + assertThat(firstContext.similarJobPostings()).containsExactly(similarContext); } @Test @@ -241,6 +250,32 @@ void getContextDoesNotReserveCreditTwice() { verify(analysisService).prepareAnalysisExecution(user, 10L); } + @Test + @DisplayName("실패 처리를 재시도해도 예약된 크레딧 환불은 한 번만 수행한다") + void failTaskReleasesReservedCreditOnlyOnce() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); + User user = User.signup("테스트 사용자", "analysis-worker-refund@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + doAnswer(invocation -> { + task.markCreditReleased(); + return null; + }).when(analysisAsyncTaskService).markCreditReleased(task.getTaskId()); + doAnswer(invocation -> { + task.markFailed(FailureReason.INTERNAL_ERROR, "error", 1); + return null; + }).when(analysisAsyncTaskService).markFailed(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1); + + analysisWorkerBridgeService.failTask(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + analysisWorkerBridgeService.failTask(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + + verify(analysisCreditService, times(1)).refund(user, "analysisTaskId=" + task.getTaskId()); + verify(analysisAsyncTaskService, times(1)).markCreditReleased(task.getTaskId()); + } + @Test @DisplayName("완료 시 최초 worker context snapshot과 fingerprint를 재사용한다") void completeTaskReusesInitialExecutionSnapshot() { From fcdeb86b9885f263632e526ce6fa74cfdc7ea2bb Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:45:59 +0900 Subject: [PATCH 22/55] =?UTF-8?q?[Refactor]=20analysis=20AI=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20port=20=EB=8F=84=EC=9E=85=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/AnalysisGenerator.java | 8 ++++++++ .../service/ai/AnalysisAiGenerator.java | 18 ++++++++++++++++++ .../analysis/service/core/AnalysisService.java | 6 +++--- 3 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java new file mode 100644 index 00000000..44a90372 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.application.port; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; + +public interface AnalysisGenerator { + AnalysisLlmResponse analyze(AnalysisExecutionPayload payload); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java new file mode 100644 index 00000000..99d5646b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java @@ -0,0 +1,18 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnalysisAiGenerator implements AnalysisGenerator { + private final AnalysisAiClient analysisAiClient; + + @Override + public AnalysisLlmResponse analyze(AnalysisExecutionPayload payload) { + return analysisAiClient.analyze(payload); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index fa9360b5..057004c3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -6,7 +6,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; @@ -39,7 +39,7 @@ public class AnalysisService { private final QuestionRepository questionRepository; private final AnalysisRepository analysisRepository; private final JobPostingService jobPostingService; - private final AnalysisAiClient analysisAiClient; + private final AnalysisGenerator analysisGenerator; private final AnalysisCreditService analysisCreditService; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final AnalysisPreparationService analysisPreparationService; @@ -102,7 +102,7 @@ public AnalysisExecutionPayload prepareAnalysisExecution( } public AnalysisLlmResponse executeAnalysis(AnalysisExecutionPayload payload) { - return analysisAiClient.analyze(payload); + return analysisGenerator.analyze(payload); } @Transactional From 0eae3beed465b1ecb86631bc09e9d76308756c8b Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 14:56:27 +0900 Subject: [PATCH 23/55] =?UTF-8?q?[Refactor]=20AnalysisAiClient=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=20=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/OpenAiAnalysisAdapter.java | 51 ++ .../analysis/policy/AnalysisPromptPolicy.java | 239 ++++++ .../analysis/service/ai/AnalysisAiClient.java | 476 +---------- .../service/ai/AnalysisImprovementRules.java | 2 +- .../service/ai/AnalysisPromptBuilder.java | 770 ++++++++++++++++++ .../service/ai/AnalysisResponseParser.java | 103 +++ .../service/ai/AnalysisAiClientTest.java | 20 +- 7 files changed, 1199 insertions(+), 462 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java new file mode 100644 index 00000000..640f4370 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java @@ -0,0 +1,51 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.ai; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisResponseParser; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.StructuredResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OpenAiAnalysisAdapter { + private final OpenAIClient openAIClient; + private final LlmConcurrencyLimiter llmConcurrencyLimiter; + private final AsyncMetricsRecorder asyncMetricsRecorder; + private final AnalysisResponseParser analysisResponseParser; + + @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") + private String analysisModel = "gpt-4o-mini"; + + public T createStructuredResponse(String operationName, String prompt, Class responseType) { + var params = ResponseCreateParams.builder() + .model(analysisModel) + .input(prompt) + .temperature(0.2) + .text(responseType) + .build(); + long startedAt = System.nanoTime(); + try { + StructuredResponse response = llmConcurrencyLimiter.execute( + operationName, + () -> openAIClient.responses().create(params) + ); + asyncMetricsRecorder.recordLlmRequest(operationName, "success", elapsedMillis(startedAt)); + return analysisResponseParser.extractStructuredContent(response); + } catch (RuntimeException e) { + asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); + throw e; + } catch (Error e) { + asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); + throw e; + } + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java new file mode 100644 index 00000000..610f1c3f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/policy/AnalysisPromptPolicy.java @@ -0,0 +1,239 @@ +package com.jobdri.jobdri_api.domain.analysis.policy; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisImprovementRules; + +public final class AnalysisPromptPolicy { + private AnalysisPromptPolicy() { + } + + public static final String OUTPUT_SCHEMA = """ + [출력 규칙] + - Structured Output 스키마에 맞는 JSON object만 반환한다. + - jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 + - feedback: 한 줄 피드백 + - keyStrengths: 없으면 [] + - keyWeaknesses: 없으면 [] + - missingKeywords: 없으면 [] + - questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 + - improvement: 안전한 개선문을 만들 수 없으면 null + - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. + - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. + - Structured Output은 JSON 형식과 타입만 보장한다. + - sentence 원문 존재 여부, status/reason 정합성, improvement 사실 생성 여부, missingKeyword JD 근거 여부는 서버가 다시 검증한다. + """; + + public static final String EVALUATION_CRITERIA = """ + [평가 절차] + 최종 JSON을 작성하기 전에 내부적으로 다음 순서를 따른다. + 내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다. + 별도의 reasoning 필드나 analysis 필드를 API 응답에 추가하지 않는다. + 모델의 상세 사고과정을 로그나 DB에 저장하지 않는다. + 1. 문장 유형을 경험/성과, 포부/계획, 지원동기, 역량/자격으로 판단한다. + 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. + 3. preference만 근거인 경우 첨삭 대상에서 제외한다. + 4. 문장 유형에 맞는 평가 기준만 적용한다. + 5. 문항을 대표해 평가할 문장인지 판단한다. + 6. proven, mentioned 또는 fabricated 상태를 결정한다. + 7. reason이 status 및 문장 유형과 일치하는지 확인한다. + 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. + 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. + 10. 최종 JSON만 반환한다. + + [문장 유형 구분] + - 경험/성과: 과거에 수행한 행동, 역할, 문제 해결, 결과, 수치, 산출물이 드러나는 문장 + - 포부/계획: 입사 후 하겠다, 기여하겠다, 성장하겠다처럼 미래 실행 의지를 말하는 문장 + - 지원동기: 회사/직무를 선택한 이유, 관심, 가치관, 동기를 설명하는 문장 + - 역량/자격: 보유 기술, 자격, 면허, 전공, 경력, 교육 이수 여부를 설명하는 문장 + + [포부/계획 문장 평가 규칙] + - "~하겠습니다", "~되겠습니다", "~기여하겠습니다", "~노력하겠습니다", "~성장하고 싶습니다" 등 미래 시점 문장은 포부/계획으로 우선 판단한다. + - 포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다. + - 포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다. + - 포부/계획 문장을 "성과 수치가 부족하다"는 이유로 questionAnalyses에 포함하지 않는다. + - 포부/계획 문장의 reason에는 "성과 수치가 부족", "정량적 결과가 부족", "Before-After가 부족", "과거 성과가 드러나지 않음"을 사용하지 않는다. + - 포부가 너무 추상적일 때만 실행 대상, 방법, 단계, 직무 연결성을 보완하도록 reason을 작성한다. + + [문장 유형별 평가 기준] + - 경험/성과: 과거 수행 행동, 역할, 문제, 결과를 평가한다. 모든 경험에 반드시 수치를 요구하지 않는다. + - 경험/성과: 과정, 역할, 산출물, 검증 결과 중 하나 이상이 충분히 구체적이면 인정한다. + - 경험/성과: 이미 수치나 명확한 결과가 있으면 "성과 수치 부족" reason을 생성하지 않는다. + - 포부/계획: 미래 시점 문장에는 과거 성과 수치나 Before-After를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 평가한다. + - 지원동기: 회사 또는 직무를 선택한 이유가 개인 경험·관심과 연결되는지 평가하며, 지원동기를 성과 문장처럼 평가하지 않는다. + - 지원동기: 해당 회사만의 이유가 없는 일반론인지 확인한다. + - 역량/자격: 보유 기술이나 역량의 실제 사용 맥락을 평가한다. + - 역량/자격: 자격증이나 전공 자체를 문장 첨삭의 핵심 문제로 삼지 않는다. + - 역량/자격: 해당 기술을 실제로 어떻게 사용했는지가 있으면 그 근거를 평가한다. + + [JD 반영 우선순위] + - 판단 우선순위는 mainTask > qualification >>> preference다. + - mainTask와 qualification은 직무 적합성 판단의 핵심 기준이다. + - preference는 reason과 점수에 보조적으로만 반영한다. + - preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다. + - preference만 근거로 jobFit을 크게 감점하지 않는다. + + [점수 산정 규칙] + - Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다. + - 점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다. + - 예시의 수치나 특정 고정 점수를 추론해서 사용하지 않는다. + - 서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다. + + [jobFit 평가 기준] + JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. + 체크 항목: + - 필수 자격요건 매칭 + - 우대사항 반영 + - 주요 업무 연관성 + - 직무 키워드 활용 + - 암묵적 직무 역량 충족 + 점수 구간: + - 85~100: 필수 자격요건 대부분 proven, 직무 키워드가 풍부하고 업무 경험과 JD가 직접 연결됨 + - 70~84: 주요 자격요건이 증명되고 일부 우대사항도 반영됨 + - 55~69: 자격요건 일부만 증명되고 JD와 간접적으로 연결됨 + - 40~54: 자격요건 증명이 거의 없고 직무 관련성이 낮음 + - 40 미만: JD와 자기소개서가 거의 무관함 + + [impact 평가 기준] + 주장을 뒷받침하는 근거가 얼마나 구체적이고 설득력 있는지 평가한다. + 체크 항목: + - 정량적 성과 + - STAR 구조 활용 + - 주장과 근거의 연결 + - Before-After 비교 + - 차별적 경험 + 점수 구간: + - 85~100: 주요 주장에 정량 성과 또는 구체적 에피소드가 있고 STAR 구조가 명확함 + - 70~84: 핵심 주장 대부분에 근거가 있고 일부 수치와 STAR 구조가 존재함 + - 55~69: 경험은 있으나 근거가 모호하고 수치가 거의 없음 + - 40~54: 대부분 근거 없는 주장과 추상 표현으로 구성됨 + - 40 미만: 구체적 근거가 전혀 없음 + 감점 금지: + - 문제 상황 설명에 수치가 없다는 이유만으로 감점하지 않는다. + - 과정이나 학습 경험에 정량 지표가 없다는 이유만으로 감점하지 않는다. + - STAR 구조를 형식적으로 완벽하게 따르지 않는다는 이유만으로 감점하지 않는다. + 단, 전체 맥락과 행동, 결과는 이해 가능해야 한다. + + [completeness 평가 기준] + 질문 적합성, 논리 흐름, 문장 표현 품질을 종합 평가한다. + 체크 항목: + - 질문 적합성 + - 문단 구조와 흐름 + - 논리적 일관성 + - 문장 가독성 + - 설득력 있는 마무리 + 점수 구간: + - 85~100: 모든 문항에 정확히 답하고 논리 흐름과 마무리가 매우 자연스러움 + - 70~84: 대부분 적절하게 답하고 전반적으로 읽기 좋음 + - 55~69: 일부 동문서답, 논리 비약, 반복 표현 또는 마무리 부족이 있음 + - 40~54: 질문과 답변 불일치가 많고 구조와 논리 문제가 큼 + - 40 미만: 대부분 질문 의도와 무관하거나 미완성임 + """; + + public static final String STATUS_AND_WRITING_RULES = """ + [status 판정 기준] + - questionAnalyses의 허용 status는 proven, mentioned, fabricated다. + - proven: JD와 관련된 구체적인 행동, 근거 또는 결과가 충분히 드러난 문장 + - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 + - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 + - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 + + [status 중요 규칙] + - 충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다. + - proven 문장의 improvement는 null로 반환한다. + - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. + - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. + - missing은 관련 언급이 전혀 없을 때만 사용한다. + - fabricated는 단순히 근거가 부족하다는 이유로 사용하지 않는다. + - fabricated는 명시적 사실과 직접 충돌하거나 지원자가 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성을 만들기 위해 억지로 fabricated를 생성하지 않는다. + - 구체적인 경험이나 수치가 부족하다는 이유만으로 fabricated를 사용하지 않는다. + + [sentence 규칙] + - questionAnalyses의 sentence는 반드시 해당 questionId의 answer에 실제 포함된 정확한 substring이어야 한다. + - 원문에 없는 문장을 생성하지 않는다. + - sentence를 요약하거나 수정하지 않는다. + - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. + - answer가 비어 있지 않은 모든 문항은 가장 평가 가치가 큰 실제 문장을 최소 1개 반환한다. + - questionAnalyses는 비어 있지 않은 문항마다 1~3개 반환한다. + - 구체적인 강점 문장은 proven, 보완이 필요한 문장은 mentioned 또는 fabricated로 반환한다. + - 실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. + - 동일한 문제를 반복하는 문장은 하나만 선택한다. + - answer가 비어 있는 문항만 분석을 반환하지 않는다. + - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. + - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. + - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. + - missing은 questionAnalyses에 억지로 넣지 않는다. + + [소제목 처리 규칙] + - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. + - 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다. + - 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다. + - 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다. + - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다. + - 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다. + + [missingKeywords 규칙] + - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. + - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. + - JD 원문에 없는 SQL, Python, AWS, 대용량 트래픽 같은 키워드를 생성하지 않는다. + - 자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부, 나이, 국적, 졸업 여부 같은 정형 자격요건은 missingKeywords에 넣지 않는다. + - preference에만 존재하는 항목은 원칙적으로 missingKeywords에서 제외한다. + - mainTask와 qualification에서 근거를 찾지 못한 경우 preference만으로 문장을 문제 삼지 않는다. + - reason에서 preference를 핵심 결격 사유처럼 표현하지 않는다. + - preference가 없다는 이유만으로 mentioned를 생성하지 않는다. + - questionAnalyses와 분리해서 missingKeywords에만 넣는다. + - 최대 3개만 반환한다. + - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + - 우선순위는 주요 업무(mainTask) > 자격요건(qualification) >>> 우대사항(preference)다. + - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. + - 반드시 실제 입력 JD에 들어간 표현을 유지한다. + - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. + - source는 qualification, preference, mainTask 중 하나만 사용한다. + + [핵심 강점/약점 작성 규칙] + - keyStrengths와 keyWeaknesses는 각각 최대 3개만 반환한다. + - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. + - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. + - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. + - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. + - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. + - proven 문장은 keyStrengths의 quote와 questionAnalyses의 sentence에 함께 사용할 수 있다. + - mentioned 또는 fabricated 문장은 keyStrengths와 중복하지 않는다. + - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. + - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. + - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. + - quote는 너무 길게 붙이지 말고 사용자가 근거를 확인할 수 있는 핵심 구절만 사용한다. + - 적절한 강점이나 약점이 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. + + [reason 작성 규칙] + - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. + - 가능하면 JD의 어떤 업무, 자격요건, 우대사항과 관련된 문제인지 설명한다. + - 1~2문장으로 간결하게 작성한다. + - 사용자의 경험을 부정하거나 비난하지 않는다. + - "잘못되었다"보다 "근거가 부족하다", "결과가 드러나지 않는다"처럼 진단형 표현을 사용한다. + - 단순 문법 교정보다 직무 적합성, 구체성, 논리 완성도 관점의 이유를 우선한다. + + [improvement 작성 규칙] + - improvement는 첨삭 조언이 아니라 사용자가 sentence를 대체해 사용할 수 있는 완성된 한국어 문장이어야 한다. + - improvement는 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 하며 첨삭 행위를 설명하는 메타 문장이 아니어야 한다. + - 개선이 필요하지 않으면 improvement는 null로 반환한다. + - 원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다. + - 반드시 한국어 평서문으로 작성한다. + - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. + - "구체적으로 작성했습니다", "명확히 설명했습니다" 같은 메타 조언을 improvement로 반환하지 않는다. + - 원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다. + - 답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다. + - JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다. + - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. + - 원문이 과거 경험이면 개선문도 과거 경험을 유지한다. + - 원문이 포부이면 개선문도 포부를 유지한다. + - 새로운 경험이나 계획을 추가하지 않는다. + - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. + - 원래 경험과 맥락을 최대한 유지한다. + - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. + - 너무 길거나 과도하게 화려한 문장으로 만들지 않는다. + - 금지 표현: %s + """.formatted(AnalysisImprovementRules.bannedPhrasesText()); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index dd248b9b..a50386cb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -10,6 +10,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; @@ -300,6 +301,9 @@ public class AnalysisAiClient { private final FewShotProperties fewShotProperties; private final AsyncMetricsRecorder asyncMetricsRecorder; private final ObjectMapper objectMapper; + private final AnalysisPromptBuilder analysisPromptBuilder; + private final AnalysisResponseParser analysisResponseParser; + private final OpenAiAnalysisAdapter openAiAnalysisAdapter; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") private String analysisModel; @@ -450,7 +454,7 @@ private AnalysisAiCallResult analyzeSinglePass( buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), AnalysisLlmResponse.class ); - response = sanitizeSinglePassSubheadings(promptInput, response); + response = analysisResponseParser.sanitizeSinglePassSubheadings(promptInput, response); return AnalysisAiCallResult.singlePass(response, elapsedMillis(startedAt)); } @@ -582,27 +586,7 @@ private AnalysisAiCallResult analyzeHybridExact( } private T createStructuredResponse(String operationName, String prompt, Class responseType) { - var params = ResponseCreateParams.builder() - .model(analysisModel) - .input(prompt) - .temperature(0.2) - .text(responseType) - .build(); - long startedAt = System.nanoTime(); - try { - StructuredResponse response = llmConcurrencyLimiter.execute( - operationName, - () -> openAIClient.responses().create(params) - ); - asyncMetricsRecorder.recordLlmRequest(operationName, "success", elapsedMillis(startedAt)); - return extractStructuredContent(response); - } catch (RuntimeException e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; - } catch (Error e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; - } + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); } String buildPrompt( @@ -611,11 +595,7 @@ String buildPrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - return buildPrompt( - AnalysisPromptInput.from(jobPosting, questions), - referenceContext, - jobCategoryEvaluationCriteria - ); + return analysisPromptBuilder.buildPrompt(jobPosting, questions, referenceContext, jobCategoryEvaluationCriteria); } String buildPrompt( @@ -623,7 +603,7 @@ String buildPrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); + return analysisPromptBuilder.buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); } String buildCandidatePrompt( @@ -631,178 +611,7 @@ String buildCandidatePrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria ) { - String questionText = formatQuestions(promptInput); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 자기소개서 분석을 위한 1차 후보 판정기다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - reasoning, analysis 같은 별도 필드를 추가하지 않는다. - - [1차 출력 필드] - - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 최종 PROVEN 문장 후보. 없으면 []. - - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 문항별 최대 3개. - - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. - - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. - - analysisCandidates.candidateId는 각 후보마다 고유한 문자열로 반드시 채운다. - - analysisCandidates.contextBefore/contextAfter는 sentence 앞뒤 문맥을 짧게 넣는다. 서버가 원문 기준으로 다시 검증한다. - - [허용값] - - sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY - - relatedSource: MAIN_TASK, QUALIFICATION, PREFERENCE, NONE - - status: MENTIONED, FABRICATED - - issueType: LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE, LACK_OF_JOB_CONNECTION, ABSTRACT_PLAN, GENERIC_MOTIVATION, UNSUPPORTED_CLAIM, DIRECT_CONTRADICTION - - [1차 판정 절차] - 1. 답변을 문장 단위로 확인한다. - 2. 각 문장의 유형을 분류한다. - 3. mainTask와 qualification의 직접 근거를 찾는다. - 4. preference만 근거인 후보는 제외한다. - 5. 문장이 이미 충분히 구체적인지 판단한다. - 6. 충분한 문장은 strengthCandidates로 분류한다. - 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. - 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. - 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. - 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 평가 근거가 있으면 문항별 1~3개를 반환한다. - - [1차 후보 규칙] - - 후보 수보다 정확도를 우선한다. - - 후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다. - - 애매한 문장은 후보로 만들지 않는다. - - 각 문장을 앞뒤 문맥과 함께 판단한다. - - 문장 자체 또는 주변 문맥에 충분한 근거가 있으면 제외한다. - - 대부분의 답변에서 후보가 0개 또는 1개일 수 있다. - - 2개는 서로 다른 명확한 문제가 있을 때만 허용한다. - - 3개는 독립적이고 중대한 문제가 정확히 3개 있을 때만 허용한다. - - 개수를 채우기 위해 후보를 생성하지 않는다. - - 대표 문장 하나만 기계적으로 고르지도 않는다. - - questionId는 입력된 questionId 중 하나만 사용한다. - - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. - - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. - - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. - - strengthCandidates로 검증된 문장은 서버가 status=PROVEN, improvement=null인 questionAnalyses로 변환한다. - - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. - - 지원동기는 수치 부족으로 분류하지 않는다. - - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. - - FABRICATED는 JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. - - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. - - improvement를 생성하지 않는다. - - [소제목 처리 규칙] - - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. - - 소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다. - - 소제목에 행동, 역할, 방법, 결과가 없다는 이유로 LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE 후보를 만들지 않는다. - - 소제목 자체의 구체성을 평가하거나 점수 근거로 사용하지 않는다. - - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용하고, 후보 판정은 본문 문장을 기준으로 한다. - - 소제목이 문단을 요약하는지는 문단 이해에만 참고하며, 소제목 문구 자체를 첨삭 후보로 만들지 않는다. - - [strengthCandidates 생성 규칙] - - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. - - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. - - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. - - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. - - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. - - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. - - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. - - [missingKeywordCandidates 생성 규칙] - - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. - - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. - - keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다. - - 유사 JD, preferences, 유사 자소서 문항, 직무 일반 지식, 모델의 추론에서 keyword를 만들지 않는다. - - JD에 없는 개념으로 확장하거나 일반화하지 않는다. - - "상세페이지 제작"을 "UX/UI 인터페이스 설계"로 바꾸지 않는다. - - "브랜드 운영"을 "브랜드 BI/CI 수립 경험"으로 바꾸지 않는다. - - "사업 및 행정지원"을 "복지 프로그램 기획"으로 바꾸지 않는다. - - keyword는 가능하면 JD 원문 표현을 유지한다. 추상화하거나 더 넓은 상위 개념으로 바꾸지 않는다. - - relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다. - - relatedRequirement에 JD 원문에 없는 새 표현을 만들지 않는다. - - 답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다. - - "행정 업무", "행정 지원", "사업 및 행정지원"처럼 같은 역량이 답변에 입증되어 있으면 누락으로 만들지 않는다. - - [missingKeywordCandidates Negative Examples] - - Example 1 - JD main_tasks: 상세페이지 제작 - JD qualifications: 포토샵 - Answer: 포토샵으로 상세페이지를 제작했습니다. - missingKeywordCandidates: [] - 이유: 이미 답변에 있으며, 상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다. - - Example 2 - JD main_tasks: 브랜드 운영 - Answer: 브랜드를 운영했습니다. - missingKeywordCandidates: [] - 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. - - Example 3 - JD main_tasks: 사업 및 행정지원 - Answer: 총무부에서 사업 및 행정지원 업무와 예산 관리를 담당했습니다. - missingKeywordCandidates: [] - 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. - - [strengthCandidates Positive Examples] - - Example 1 - JD main_tasks: 상세페이지 제작 - JD qualifications: 포토샵·일러스트 활용 - JD preferences: 라이노·키샷 우대 - Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. - strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. - 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. - - Example 2 - JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 - JD preferences: 더존 활용 우대 - Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. - strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. - 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. - - [문장 유형별 후보 기준] - - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. - - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. - - PLAN: 과거 성과나 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성만 본다. - - MOTIVATION: 수치를 요구하지 않고 회사·직무 선택 이유와 개인 경험의 연결성을 본다. - - COMPETENCY: 자격증 또는 역량 언급 자체를 문제로 보지 않고 실제 활용 맥락이 필요한 경우에만 후보화한다. - - FABRICATED는 명시적 사실 충돌이 있을 때만 사용한다. 단순 근거 부족은 MENTIONED다. - - [1차 few-shot 후보 개수 분포] - - 예시 A: 충분히 구체적인 성과 문장은 strengthCandidates 1개, analysisCandidates 0개. - - 예시 B: 주변 문맥에 방법과 결과가 이어지면 analysisCandidates 0개. - - 예시 C: 추상적인 포부 문장 1개만 명확하면 analysisCandidates 1개. - - 예시 D: 서로 다른 명확한 문제가 2개일 때만 analysisCandidates 2개. - - 3개 후보 예시는 사용하지 않는다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText - ); + return analysisPromptBuilder.buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); } String buildFinalPrompt( @@ -811,162 +620,7 @@ String buildFinalPrompt( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, AnalysisCandidateResponse candidates ) { - String questionText = formatQuestions(promptInput); - String candidateJson = writeJson(candidates); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - - %s - - [2차 출력 필드] - - decisions: 검증된 analysisCandidates 각각에 대해 승인 또는 거절 결정을 반환한다. - - strengths: 검증된 strengthCandidates 범위 안에서 최종 keyStrengths 후보를 반환한다. - - missingKeywords: 검증된 missingKeywordCandidates 범위 안에서 최종 missingKeywords 후보를 반환한다. - - jobFit, impact, completeness, feedback을 함께 반환한다. - - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 검증된 strengths는 PROVEN으로, accepted decision은 MENTIONED 또는 FABRICATED로 최종 questionAnalyses에 변환한다. - - 최종 questionAnalyses 개수 제한은 전체 합계가 아니라 questionId별 1~3개로 적용한다. - - [rejectionCode 허용값] - - ALREADY_SPECIFIC - - CONTEXT_PROVIDES_EVIDENCE - - WRONG_SENTENCE_TYPE_CRITERIA - - PREFERENCE_ONLY - - NOT_JOB_RELEVANT - - DUPLICATE_ISSUE - - UNSUPPORTED_JUDGMENT - - NOT_ACTIONABLE - - INVALID_SOURCE - - NONE - - [2차 후보 검증 절차] - 1. sentence가 원문에 존재하는지 확인한다. - 2. 앞뒤 문맥을 확인한다. - 3. mainTask 또는 qualification과 직접 관련 있는지 확인한다. - 4. preference-only인지 확인한다. - 5. sentenceType이 맞는지 확인한다. - 6. 해당 문장 유형에 맞는 평가 기준이 적용됐는지 확인한다. - 7. 주변 문맥에 이미 역할·방법·성과가 제공되는지 확인한다. - 8. 실제로 사용자에게 첨삭 가치가 있는지 확인한다. - 9. 승인 또는 거절 결정한다. - 10. 승인된 후보에 대해서만 reason과 improvement를 작성한다. - - 내부 판단 과정은 출력하지 않는다. - - [후보 유지 조건] - - 2차 단계의 역할은 새로운 분석 생성이 아니라 1차 후보가 실제 수정 대상인지 검증하는 것이다. - - 확실하지 않으면 무조건 제거하지 않는다. - - mentioned는 문장 자체는 사실일 수 있지만 구체적인 행동, 역할, 성과/결과, 문제 해결 과정, JD 요구 역량 연결, 기여 범위 중 하나가 부족한 경우 유지한다. - - mentioned는 추상적인 표현만 있거나 주장에 비해 근거가 부족하거나 지원자 기여와 팀 성과가 구분되지 않는 경우 유지한다. - - 단순히 숫자가 없다는 이유만으로 제거하거나 유지하지 않는다. - - fabricated는 존재하지 않는 경험을 사실처럼 기술, 실제 역할보다 과장된 역할 주장, 성과 수치나 책임 범위 임의 확대, 앞뒤 문장과 명백히 모순되는 경우에만 유지한다. - - 근거가 부족하다는 이유만으로 fabricated를 사용하지 않는다. - - [후보 제거 조건] - - 문장이 이미 충분히 구체적이고 행동, 역할, 결과, JD 연결성이 충분할 때만 제거한다. - - 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다. - - 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다. - - 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다. - - 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다. - - 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다. - - [decision 정합성] - - accepted=true이면 rejectionCode=NONE, status는 MENTIONED 또는 FABRICATED, reason은 사용자 노출 가능한 최종 사유다. - - accepted=false이면 rejectionCode는 NONE이 아니어야 하고, improvement는 반드시 null이다. - - accepted=false의 reason은 내부 거절 근거이며 최종 API에는 노출되지 않는다. - - 후보가 이미 충분하면 ALREADY_SPECIFIC으로 거절한다. - - 주변 문맥이 근거를 제공하면 CONTEXT_PROVIDES_EVIDENCE로 거절한다. - - 문장 유형에 맞지 않는 기준이 적용됐으면 WRONG_SENTENCE_TYPE_CRITERIA로 거절한다. - - 1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다. - - [문장 유형별 첨삭] - - 경험/성과: 행동, 역할, 방법, 결과 중 실제 부족한 요소만 보완한다. - - 경험/성과: 이미 수치와 방법이 있으면 부족하다고 평가하지 않는다. - - 포부/계획: 과거 성과 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 중심으로 reason을 작성한다. - - 지원동기: 수치를 요구하지 않고 회사 또는 직무 선택 이유와 개인 경험의 연결성을 평가한다. - - 역량/자격: 자격증이나 전공 자체보다 실제 활용 맥락을 평가한다. - - 역량/자격: 정형 자격요건은 missingKeywords에서 제외한다. - - [improvement 안전 규칙] - - 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 한다. - - 첨삭 행위를 설명하는 메타 문장을 금지한다. - - 원문과 실질적으로 동일한 문장을 금지한다. - - 답변의 다른 문장 복사를 금지한다. - - 원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다. - - JD 문구를 지원자의 실제 경험처럼 변환하지 않는다. - - 과거 문장을 미래 포부로 변경하지 않는다. - - 미래 포부를 과거 경험으로 변경하지 않는다. - - 원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다. - - 금지 예시: 구체적으로 설명했습니다. 경험을 추가하면 좋겠습니다. 성과를 강조했습니다. 구체적인 계획을 추가하겠습니다. 명확하게 작성할 수 있습니다. - - 금지 패턴: 추가해 보, 추가하면 좋, 설명해 보, 구체적으로 작성, 구체적으로 설명, 강조하겠, 추가하겠, 명확히 작성, 작성할 수 있, 설명할 수 있, 보완하겠, 드러내겠, 제시하겠. - - [keyStrengths 복구 규칙] - - 검증된 strengthCandidates 안에서 strengths를 구성한다. - - 충분히 구체적이고 JD와 직접 연결된 문장을 strengthCandidates에서 적극 활용한다. - - 수치, 도구, 역할, 결과가 구체적인 문장은 문제 후보보다 strength 후보를 우선한다. - - quote는 답변 원문 exact substring이어야 한다. - - mainTask 또는 qualification 연결을 우선한다. - - preference-only 강점은 제외한다. - - 최소 개수를 강제하지 않고 없으면 []를 반환한다. - - [missingKeywords 복구 규칙] - - 검증된 missingKeywordCandidates 범위 안에서만 missingKeywords를 정리한다. - - questionAnalyses 후보 개수와 독립적으로 판단한다. - - 누락 키워드가 없어도 문장 첨삭은 존재할 수 있다. - - 누락 키워드가 있어도 문장 자체는 정상일 수 있다. - - 문장 분석이 0건이라고 missingKeywords를 비우지 않는다. - - mainTask와 qualification만 사용하고 preference는 제외한다. - - 정형 자격요건은 제외하고 경험형 요구사항은 유지 가능하다. - - 새 keyword를 임의 생성하지 않는다. - - [점수 산정] - - 1차 후보 개수와 점수를 연결하지 않는다. - - questionAnalyses가 0개여도 무조건 고득점으로 처리하지 않는다. - - questionAnalyses가 많다고 기계적으로 감점하지 않는다. - - 점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다. - - Few-shot의 점수나 고정 숫자 예시는 사용하지 않는다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [검증된 1차 후보] - %s - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - OUTPUT_SCHEMA, - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - candidateJson, - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText - ); + return analysisPromptBuilder.buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, candidates); } String buildRecheckPrompt( @@ -976,110 +630,12 @@ String buildRecheckPrompt( AnalysisCandidateResponse candidates, CandidateReviewResponse reviewResponse ) { - String questionText = formatQuestions(promptInput); - String candidateJson = writeJson(candidates); - String reviewJson = writeJson(reviewResponse); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - return """ - [시스템 지시] - 너는 자기소개서 two-pass 분석의 재검증기다. - 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. - 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. - - [재검증 목적] - 1차 후보가 하나 이상 있었지만 2차 검증 후 accepted 후보가 0개다. - 바로 빈 배열로 확정하지 말고 다음 질문만 판단한다. - - 1차 후보 중 사용자가 실제로 수정하면 도움이 되는 문장이 정말 하나도 없는가? - - [decision] - - NO_CORRECTION_NEEDED: 후보들이 모두 실제 첨삭 대상이 아니다. - - KEEP_BEST_CANDIDATE: 가장 명확한 수정 필요성이 있는 후보 1건만 유지한다. - - [KEEP_BEST_CANDIDATE 선택 기준] - - 단순히 첫 번째 후보를 선택하지 않는다. - - problemClarity, jobRelevance, evidenceGap, improvementUsefulness, fabricationConfidence를 1~5로 내부 평가한다. - - KEEP_BEST_CANDIDATE는 problemClarity >= 4, jobRelevance >= 4, improvementUsefulness >= 4일 때만 선택한다. - - 문제 명확성, JD 관련성, 근거 부족 정도, 안전한 개선 가능성이 높은 후보를 선택한다. - - questionTypeMatched, contextConsistent, reasonSpecific, improvementActionable은 모두 true여야 한다. - - status는 MENTIONED 또는 FABRICATED만 사용한다. - - MENTIONED: 관련 경험이나 의도는 있으나 행동, 역할, 결과, 문제 해결 과정, JD 연결, 기여 범위 중 하나가 부족하다. - - FABRICATED: 원문 또는 제공 정보와 명백히 충돌할 때만 사용한다. - - FABRICATED는 fabricationConfidence >= 4이고 directContradiction=true일 때만 선택한다. - - 근거 부족만으로 FABRICATED를 사용하지 않는다. - - 원문에 없는 사실, 수치, 경험, 계획을 추가하지 않는다. - - [재검증 입력 확인] - - 문항 원문, 전체 답변, JD 주요 업무, 자격 요건, 우대 사항을 함께 확인한다. - - 후보 문장, 1차 status, 1차 reasonBasis, 2차 제거 decision, 2차 제거 reason을 함께 확인한다. - - 후보 문장 하나만 보고 복원하지 않는다. - - 후보가 문항 의도에 맞는지 확인한다. - - 전체 답변 문맥에서 실제 수정이 필요한지 확인한다. - - JD 요구사항과 직접 관련되는지 확인한다. - - reason이 원문에서 확인 가능한지 확인한다. - - improvement가 실제로 더 유용한지 확인한다. - - [NO_CORRECTION_NEEDED 판단 기준] - - 모든 후보 문장이 이미 충분히 구체적이다. - - 후보가 단순 문체 선호 차이이거나 질문 의도와 무관하다. - - 후보가 한 줄 전체가 대괄호로 감싸진 소제목이다. - - 다짐/포부 문장을 성과 부족으로 잘못 평가했다. - - 자격증, 학력, 기술명 등 단순 정량 키워드 부재만 문제 삼았다. - - 안전하고 실질적인 개선문을 만들 수 없고 reason도 사용자에게 도움이 되지 않는다. - - [improvement] - - 안전한 교체 문장을 만들 수 없으면 null이다. - - 첨삭 행위를 설명하는 메타 문장을 쓰지 않는다. - - 원문과 같은 문장, 다른 원문 문장 복사, JD 경험 생성, 시제 변경을 금지한다. - - KEEP_BEST_CANDIDATE를 선택했다면 improvement는 reason에서 지적한 문제를 실제로 개선해야 한다. - - 더 구체적으로 작성하겠습니다, 직무 역량을 강화하겠습니다, 성과를 명확히 보여주었습니다 같은 문장은 improvementActionable=false다. - - [문항 유형별 복원 기준] - - 지원 동기: 직무 선택 이유, 본인 경험과 직무 연결, 지원자 근거가 부족할 때만 복원한다. 과거 성과 수치나 STAR 구조가 없다는 이유로 복원하지 않는다. - - 포부/다짐: 실행 방향, 직무 연결, 성장 또는 기여 계획이 지나치게 추상적일 때만 복원한다. 과거 성과, 정량 수치, 과거 행동 근거가 없다는 이유로 복원하지 않는다. - - 경험/성과: 역할, 행동 과정, 결과, 개인 기여가 부족할 때 복원할 수 있다. - - 협업/갈등: 갈등 원인, 조율 행동, 결과, 상호작용이 부족할 때 복원할 수 있다. - - [채용 공고] - 회사명: %s - 직무명: %s - - %s - - - %s - - - %s - - - [검증된 1차 후보] - %s - - [2차 검증 결과] - %s - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - """.formatted( - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - candidateJson, - reviewJson, - formatJobPostingReferences(referenceContext.jobPostingReferences()), - formatQuestionReferences(referenceContext.questionReferences()), - jobCategoryCriteriaSection, - questionText + return analysisPromptBuilder.buildRecheckPrompt( + promptInput, + referenceContext, + jobCategoryEvaluationCriteria, + candidates, + reviewResponse ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java index 15da87d0..ec28a1d8 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisImprovementRules.java @@ -28,7 +28,7 @@ public final class AnalysisImprovementRules { private AnalysisImprovementRules() { } - static String bannedPhrasesText() { + public static String bannedPhrasesText() { return String.join(", ", BANNED_PHRASES); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java new file mode 100644 index 00000000..7f0e1e44 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java @@ -0,0 +1,770 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.policy.AnalysisPromptPolicy; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; +import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; +import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AnalysisPromptBuilder { + private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; + private static final int MAX_REFERENCE_FIELD_LENGTH = 300; + private static final int MAX_CRITERIA_ITEMS = 5; + + private final FewShotPromptProvider fewShotPromptProvider; + private final FewShotSearchService fewShotSearchService; + private final FewShotProperties fewShotProperties; + private final ObjectMapper objectMapper; + + String buildPrompt( + JobPosting jobPosting, + List questions, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildPrompt( + AnalysisPromptInput.from(jobPosting, questions), + referenceContext, + jobCategoryEvaluationCriteria + ); + } + + String buildPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return buildSinglePassPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria); + } + + String buildCandidatePrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = formatQuestions(promptInput); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 자기소개서 분석을 위한 1차 후보 판정기다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + reasoning, analysis 같은 별도 필드를 추가하지 않는다. + + [1차 출력 필드] + - strengthCandidates: 충분히 구체적이고 mainTask 또는 qualification과 직접 연결된 최종 PROVEN 문장 후보. 없으면 []. + - analysisCandidates: 실제 첨삭이 필요한 명확한 문장 후보. status는 MENTIONED 또는 FABRICATED만 사용. 문항별 최대 3개. + - missingKeywordCandidates: sentence가 없는 누락 역량 후보. 없으면 []. + - 점수 필드, feedback, improvement, keyWeaknesses는 1차 출력에 존재하지 않는다. + - analysisCandidates.candidateId는 각 후보마다 고유한 문자열로 반드시 채운다. + - analysisCandidates.contextBefore/contextAfter는 sentence 앞뒤 문맥을 짧게 넣는다. 서버가 원문 기준으로 다시 검증한다. + + [허용값] + - sentenceType: EXPERIENCE, PLAN, MOTIVATION, COMPETENCY + - relatedSource: MAIN_TASK, QUALIFICATION, PREFERENCE, NONE + - status: MENTIONED, FABRICATED + - issueType: LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE, LACK_OF_JOB_CONNECTION, ABSTRACT_PLAN, GENERIC_MOTIVATION, UNSUPPORTED_CLAIM, DIRECT_CONTRADICTION + + [1차 판정 절차] + 1. 답변을 문장 단위로 확인한다. + 2. 각 문장의 유형을 분류한다. + 3. mainTask와 qualification의 직접 근거를 찾는다. + 4. preference만 근거인 후보는 제외한다. + 5. 문장이 이미 충분히 구체적인지 판단한다. + 6. 충분한 문장은 strengthCandidates로 분류한다. + 7. 보완이 필요한 문장만 analysisCandidates로 분류한다. + 8. 실제 충돌이 있는 경우에만 FABRICATED를 사용한다. + 9. 누락 요구사항은 sentence가 아니라 missingKeywordCandidates로 분리한다. + 10. 대표 1개만 기계적으로 고르지 말고, 독립적인 평가 근거가 있으면 문항별 1~3개를 반환한다. + + [1차 후보 규칙] + - 후보 수보다 정확도를 우선한다. + - 후보 개수 제한은 전체 답변 합계가 아니라 questionId별로 독립 적용한다. + - 애매한 문장은 후보로 만들지 않는다. + - 각 문장을 앞뒤 문맥과 함께 판단한다. + - 문장 자체 또는 주변 문맥에 충분한 근거가 있으면 제외한다. + - 대부분의 답변에서 후보가 0개 또는 1개일 수 있다. + - 2개는 서로 다른 명확한 문제가 있을 때만 허용한다. + - 3개는 독립적이고 중대한 문제가 정확히 3개 있을 때만 허용한다. + - 개수를 채우기 위해 후보를 생성하지 않는다. + - 대표 문장 하나만 기계적으로 고르지도 않는다. + - questionId는 입력된 questionId 중 하나만 사용한다. + - sentence와 quote는 해당 answer에 실제 포함된 정확한 부분 문자열만 사용한다. + - preference-only 후보는 strengthCandidates, analysisCandidates, missingKeywordCandidates에서 제외한다. + - 충분히 좋은 문장은 analysisCandidates에 넣지 않는다. + - strengthCandidates로 검증된 문장은 서버가 status=PROVEN, improvement=null인 questionAnalyses로 변환한다. + - 포부/계획 문장에는 과거 성과 수치나 Before-After를 요구하지 않는다. + - 지원동기는 수치 부족으로 분류하지 않는다. + - MISSING은 analysisCandidates에 넣지 않고 missingKeywordCandidates로만 분리한다. + - FABRICATED는 JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. + - status 다양성이나 개수를 채우기 위해 후보를 만들지 않는다. + - improvement를 생성하지 않는다. + + [소제목 처리 규칙] + - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. + - 소제목은 sentenceType을 분류하지 않고 strengthCandidates 또는 analysisCandidates에 넣지 않는다. + - 소제목에 행동, 역할, 방법, 결과가 없다는 이유로 LACK_OF_ACTION, LACK_OF_METHOD, LACK_OF_RESULT, LACK_OF_ROLE 후보를 만들지 않는다. + - 소제목 자체의 구체성을 평가하거나 점수 근거로 사용하지 않는다. + - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 문맥으로만 사용하고, 후보 판정은 본문 문장을 기준으로 한다. + - 소제목이 문단을 요약하는지는 문단 이해에만 참고하며, 소제목 문구 자체를 첨삭 후보로 만들지 않는다. + + [strengthCandidates 생성 규칙] + - 반드시 수치 성과가 있어야만 strengthCandidates가 되는 것은 아니다. + - JD와 직접 연결된다면 구체적인 도구·기술 사용 경험은 strengthCandidates가 될 수 있다. + - 실제 업무 또는 프로젝트 수행 경험, 업무 전 과정을 수행한 경험은 strengthCandidates가 될 수 있다. + - 문제를 발견하고 해결한 과정, 수치로 표현되지 않더라도 확인 가능한 결과는 strengthCandidates가 될 수 있다. + - 자격 취득 자체가 아니라 직무와 연결되는 실습·적용 경험은 strengthCandidates가 될 수 있다. + - JD 우대사항과 직접 연결되는 경험도 strengthCandidates가 될 수 있으나 preference-only 강점을 과대평가하지 않는다. + - 추상적인 포부, 근거 없는 자기평가, 단순 성격 표현, JD와 무관한 경험, 답변에 없는 추론 강점, 동일 근거의 중복 강점은 생성하지 않는다. + + [missingKeywordCandidates 생성 규칙] + - missingKeywordCandidates는 recall보다 precision을 우선한다. 확실한 누락 역량이 없으면 []를 반환한다. + - 개수를 채우기 위해 missingKeywordCandidates를 생성하지 않는다. 애매하면 생성하지 않는다. + - keyword는 main_tasks 또는 qualifications에 명시적으로 존재하거나 직접적으로 동일한 의미인 항목만 사용한다. + - 유사 JD, preferences, 유사 자소서 문항, 직무 일반 지식, 모델의 추론에서 keyword를 만들지 않는다. + - JD에 없는 개념으로 확장하거나 일반화하지 않는다. + - "상세페이지 제작"을 "UX/UI 인터페이스 설계"로 바꾸지 않는다. + - "브랜드 운영"을 "브랜드 BI/CI 수립 경험"으로 바꾸지 않는다. + - "사업 및 행정지원"을 "복지 프로그램 기획"으로 바꾸지 않는다. + - keyword는 가능하면 JD 원문 표현을 유지한다. 추상화하거나 더 넓은 상위 개념으로 바꾸지 않는다. + - relatedRequirement는 main_tasks 또는 qualifications의 실제 문장을 그대로 복사하거나 조사/공백 정도만 최소 수정한다. + - relatedRequirement에 JD 원문에 없는 새 표현을 만들지 않는다. + - 답변 전체에 keyword와 직접 동일한 역량, 수행 경험, 도구 사용, 업무 수행이 이미 충분히 있으면 missingKeywordCandidates에 넣지 않는다. + - "행정 업무", "행정 지원", "사업 및 행정지원"처럼 같은 역량이 답변에 입증되어 있으면 누락으로 만들지 않는다. + + [missingKeywordCandidates Negative Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵 + Answer: 포토샵으로 상세페이지를 제작했습니다. + missingKeywordCandidates: [] + 이유: 이미 답변에 있으며, 상세페이지 제작을 UX/UI 인터페이스 설계 같은 JD 밖 개념으로 확장하지 않는다. + - Example 2 + JD main_tasks: 브랜드 운영 + Answer: 브랜드를 운영했습니다. + missingKeywordCandidates: [] + 이유: 브랜드 운영은 이미 답변에 있으며, BI/CI 구축 경험으로 일반화하지 않는다. + - Example 3 + JD main_tasks: 사업 및 행정지원 + Answer: 총무부에서 사업 및 행정지원 업무와 예산 관리를 담당했습니다. + missingKeywordCandidates: [] + 이유: 사업 및 행정지원 역량이 이미 답변에 충분히 존재한다. + + [strengthCandidates Positive Examples] + - Example 1 + JD main_tasks: 상세페이지 제작 + JD qualifications: 포토샵·일러스트 활용 + JD preferences: 라이노·키샷 우대 + Answer: 포토샵과 일러스트로 상세페이지를 제작했고, 1인 제품 브랜드를 직접 기획·운영했습니다. 라이노와 Fusion360으로 제품을 설계한 뒤 키샷으로 렌더링하고 실제 제품 제작까지 완료했습니다. + strengthCandidates: 포토샵과 일러스트 상세페이지 제작 경험, 1인 제품 브랜드 기획·운영 경험, 라이노·Fusion360·키샷 기반 제품 제작 경험 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 수치 성과가 없어도 도구, 수행 업무, 제작 완료 결과가 확인 가능하다. + - Example 2 + JD main_tasks: 엑셀 고급 활용, 4대보험 신고, 회계 업무 + JD preferences: 더존 활용 우대 + Answer: 급여 정산표를 만들어 함수로 자동 비교했고, 전표 입력과 계정 분류를 실습했습니다. 4대보험 자료 작성 방법을 학습하고 더존 프로그램 입력 기준을 비교했으며, 5만 원 시재 차이 원인을 거래 자료 대조로 추적해 수정 요청을 주 3~4건에서 1~2건으로 줄였습니다. + strengthCandidates: 엑셀 자동 비교, 회계 전표·계정 분류 실습, 4대보험 자료 작성 학습, 더존 입력 기준 비교, 시재 차이 원인 추적과 수정 요청 감소 중 JD와 직접 연결되는 quote를 사용한다. + 이유: 직무 도구, 실무 처리 과정, 문제 해결, 개선 결과가 확인 가능하다. + + [문장 유형별 후보 기준] + - EXPERIENCE: 역할, 행동, 방법, 결과, 직무 연결성 중 실제로 부족한 요소가 있어야 한다. + - EXPERIENCE: 주변 문장에 역할, 방법, 성과가 이어지면 부족하다고 판단하지 않는다. + - PLAN: 과거 성과나 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성만 본다. + - MOTIVATION: 수치를 요구하지 않고 회사·직무 선택 이유와 개인 경험의 연결성을 본다. + - COMPETENCY: 자격증 또는 역량 언급 자체를 문제로 보지 않고 실제 활용 맥락이 필요한 경우에만 후보화한다. + - FABRICATED는 명시적 사실 충돌이 있을 때만 사용한다. 단순 근거 부족은 MENTIONED다. + + [1차 few-shot 후보 개수 분포] + - 예시 A: 충분히 구체적인 성과 문장은 strengthCandidates 1개, analysisCandidates 0개. + - 예시 B: 주변 문맥에 방법과 결과가 이어지면 analysisCandidates 0개. + - 예시 C: 추상적인 포부 문장 1개만 명확하면 analysisCandidates 1개. + - 예시 D: 서로 다른 명확한 문제가 2개일 때만 analysisCandidates 2개. + - 3개 후보 예시는 사용하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildFinalPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + AnalysisCandidateResponse candidates + ) { + String questionText = formatQuestions(promptInput); + String candidateJson = writeJson(candidates); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + + %s + + [2차 출력 필드] + - decisions: 검증된 analysisCandidates 각각에 대해 승인 또는 거절 결정을 반환한다. + - strengths: 검증된 strengthCandidates 범위 안에서 최종 keyStrengths 후보를 반환한다. + - missingKeywords: 검증된 missingKeywordCandidates 범위 안에서 최종 missingKeywords 후보를 반환한다. + - jobFit, impact, completeness, feedback을 함께 반환한다. + - 기존 최종 AnalysisLlmResponse를 직접 만들지 않는다. 서버가 검증된 strengths는 PROVEN으로, accepted decision은 MENTIONED 또는 FABRICATED로 최종 questionAnalyses에 변환한다. + - 최종 questionAnalyses 개수 제한은 전체 합계가 아니라 questionId별 1~3개로 적용한다. + + [rejectionCode 허용값] + - ALREADY_SPECIFIC + - CONTEXT_PROVIDES_EVIDENCE + - WRONG_SENTENCE_TYPE_CRITERIA + - PREFERENCE_ONLY + - NOT_JOB_RELEVANT + - DUPLICATE_ISSUE + - UNSUPPORTED_JUDGMENT + - NOT_ACTIONABLE + - INVALID_SOURCE + - NONE + + [2차 후보 검증 절차] + 1. sentence가 원문에 존재하는지 확인한다. + 2. 앞뒤 문맥을 확인한다. + 3. mainTask 또는 qualification과 직접 관련 있는지 확인한다. + 4. preference-only인지 확인한다. + 5. sentenceType이 맞는지 확인한다. + 6. 해당 문장 유형에 맞는 평가 기준이 적용됐는지 확인한다. + 7. 주변 문맥에 이미 역할·방법·성과가 제공되는지 확인한다. + 8. 실제로 사용자에게 첨삭 가치가 있는지 확인한다. + 9. 승인 또는 거절 결정한다. + 10. 승인된 후보에 대해서만 reason과 improvement를 작성한다. + - 내부 판단 과정은 출력하지 않는다. + + [후보 유지 조건] + - 2차 단계의 역할은 새로운 분석 생성이 아니라 1차 후보가 실제 수정 대상인지 검증하는 것이다. + - 확실하지 않으면 무조건 제거하지 않는다. + - mentioned는 문장 자체는 사실일 수 있지만 구체적인 행동, 역할, 성과/결과, 문제 해결 과정, JD 요구 역량 연결, 기여 범위 중 하나가 부족한 경우 유지한다. + - mentioned는 추상적인 표현만 있거나 주장에 비해 근거가 부족하거나 지원자 기여와 팀 성과가 구분되지 않는 경우 유지한다. + - 단순히 숫자가 없다는 이유만으로 제거하거나 유지하지 않는다. + - fabricated는 존재하지 않는 경험을 사실처럼 기술, 실제 역할보다 과장된 역할 주장, 성과 수치나 책임 범위 임의 확대, 앞뒤 문장과 명백히 모순되는 경우에만 유지한다. + - 근거가 부족하다는 이유만으로 fabricated를 사용하지 않는다. + + [후보 제거 조건] + - 문장이 이미 충분히 구체적이고 행동, 역할, 결과, JD 연결성이 충분할 때만 제거한다. + - 단순 문체 선호 차이이거나 후보 분석이 자기소개서 질문 의도와 무관할 때 제거한다. + - 다짐이나 포부 문장을 성과 문장처럼 잘못 평가한 경우 제거한다. + - 자격증, 학력, 기술명 등 단순 정량 키워드가 없다는 이유만으로 문제 삼은 경우 제거한다. + - 한 줄 전체가 대괄호로 감싸진 소제목을 본문 문장처럼 평가한 후보는 NOT_ACTIONABLE로 거절한다. + - 수정안이 원문보다 실질적으로 개선되지 않으면 accepted=true로 두더라도 improvement는 null로 둔다. + + [decision 정합성] + - accepted=true이면 rejectionCode=NONE, status는 MENTIONED 또는 FABRICATED, reason은 사용자 노출 가능한 최종 사유다. + - accepted=false이면 rejectionCode는 NONE이 아니어야 하고, improvement는 반드시 null이다. + - accepted=false의 reason은 내부 거절 근거이며 최종 API에는 노출되지 않는다. + - 후보가 이미 충분하면 ALREADY_SPECIFIC으로 거절한다. + - 주변 문맥이 근거를 제공하면 CONTEXT_PROVIDES_EVIDENCE로 거절한다. + - 문장 유형에 맞지 않는 기준이 적용됐으면 WRONG_SENTENCE_TYPE_CRITERIA로 거절한다. + - 1차에 없는 새로운 questionAnalysis를 임의로 추가하지 않는다. + + [문장 유형별 첨삭] + - 경험/성과: 행동, 역할, 방법, 결과 중 실제 부족한 요소만 보완한다. + - 경험/성과: 이미 수치와 방법이 있으면 부족하다고 평가하지 않는다. + - 포부/계획: 과거 성과 수치를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 중심으로 reason을 작성한다. + - 지원동기: 수치를 요구하지 않고 회사 또는 직무 선택 이유와 개인 경험의 연결성을 평가한다. + - 역량/자격: 자격증이나 전공 자체보다 실제 활용 맥락을 평가한다. + - 역량/자격: 정형 자격요건은 missingKeywords에서 제외한다. + + [improvement 안전 규칙] + - 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 한다. + - 첨삭 행위를 설명하는 메타 문장을 금지한다. + - 원문과 실질적으로 동일한 문장을 금지한다. + - 답변의 다른 문장 복사를 금지한다. + - 원문에 없는 수치, 도구, 경험, 직무 수행, 계획 추가를 금지한다. + - JD 문구를 지원자의 실제 경험처럼 변환하지 않는다. + - 과거 문장을 미래 포부로 변경하지 않는다. + - 미래 포부를 과거 경험으로 변경하지 않는다. + - 원문 사실만으로 안전한 개선이 불가능하면 null을 반환한다. + - 금지 예시: 구체적으로 설명했습니다. 경험을 추가하면 좋겠습니다. 성과를 강조했습니다. 구체적인 계획을 추가하겠습니다. 명확하게 작성할 수 있습니다. + - 금지 패턴: 추가해 보, 추가하면 좋, 설명해 보, 구체적으로 작성, 구체적으로 설명, 강조하겠, 추가하겠, 명확히 작성, 작성할 수 있, 설명할 수 있, 보완하겠, 드러내겠, 제시하겠. + + [keyStrengths 복구 규칙] + - 검증된 strengthCandidates 안에서 strengths를 구성한다. + - 충분히 구체적이고 JD와 직접 연결된 문장을 strengthCandidates에서 적극 활용한다. + - 수치, 도구, 역할, 결과가 구체적인 문장은 문제 후보보다 strength 후보를 우선한다. + - quote는 답변 원문 exact substring이어야 한다. + - mainTask 또는 qualification 연결을 우선한다. + - preference-only 강점은 제외한다. + - 최소 개수를 강제하지 않고 없으면 []를 반환한다. + + [missingKeywords 복구 규칙] + - 검증된 missingKeywordCandidates 범위 안에서만 missingKeywords를 정리한다. + - questionAnalyses 후보 개수와 독립적으로 판단한다. + - 누락 키워드가 없어도 문장 첨삭은 존재할 수 있다. + - 누락 키워드가 있어도 문장 자체는 정상일 수 있다. + - 문장 분석이 0건이라고 missingKeywords를 비우지 않는다. + - mainTask와 qualification만 사용하고 preference는 제외한다. + - 정형 자격요건은 제외하고 경험형 요구사항은 유지 가능하다. + - 새 keyword를 임의 생성하지 않는다. + + [점수 산정] + - 1차 후보 개수와 점수를 연결하지 않는다. + - questionAnalyses가 0개여도 무조건 고득점으로 처리하지 않는다. + - questionAnalyses가 많다고 기계적으로 감점하지 않는다. + - 점수는 JD 전체와 답변 전체를 기준으로 독립적으로 산정한다. + - Few-shot의 점수나 고정 숫자 예시는 사용하지 않는다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [검증된 1차 후보] + %s + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + AnalysisPromptPolicy.OUTPUT_SCHEMA, + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + candidateJson, + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildRecheckPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + AnalysisCandidateResponse candidates, + com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse reviewResponse + ) { + String questionText = formatQuestions(promptInput); + String candidateJson = writeJson(candidates); + String reviewJson = writeJson(reviewResponse); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + return """ + [시스템 지시] + 너는 자기소개서 two-pass 분석의 재검증기다. + 반드시 Structured Output 스키마에 맞는 JSON object만 반환한다. + 내부 판단 과정이나 chain-of-thought를 출력하지 않는다. + + [재검증 목적] + 1차 후보가 하나 이상 있었지만 2차 검증 후 accepted 후보가 0개다. + 바로 빈 배열로 확정하지 말고 다음 질문만 판단한다. + - 1차 후보 중 사용자가 실제로 수정하면 도움이 되는 문장이 정말 하나도 없는가? + + [decision] + - NO_CORRECTION_NEEDED: 후보들이 모두 실제 첨삭 대상이 아니다. + - KEEP_BEST_CANDIDATE: 가장 명확한 수정 필요성이 있는 후보 1건만 유지한다. + + [KEEP_BEST_CANDIDATE 선택 기준] + - 단순히 첫 번째 후보를 선택하지 않는다. + - problemClarity, jobRelevance, evidenceGap, improvementUsefulness, fabricationConfidence를 1~5로 내부 평가한다. + - KEEP_BEST_CANDIDATE는 problemClarity >= 4, jobRelevance >= 4, improvementUsefulness >= 4일 때만 선택한다. + - 문제 명확성, JD 관련성, 근거 부족 정도, 안전한 개선 가능성이 높은 후보를 선택한다. + - questionTypeMatched, contextConsistent, reasonSpecific, improvementActionable은 모두 true여야 한다. + - status는 MENTIONED 또는 FABRICATED만 사용한다. + - MENTIONED: 관련 경험이나 의도는 있으나 행동, 역할, 결과, 문제 해결 과정, JD 연결, 기여 범위 중 하나가 부족하다. + - FABRICATED: 원문 또는 제공 정보와 명백히 충돌할 때만 사용한다. + - FABRICATED는 fabricationConfidence >= 4이고 directContradiction=true일 때만 선택한다. + - 근거 부족만으로 FABRICATED를 사용하지 않는다. + - 원문에 없는 사실, 수치, 경험, 계획을 추가하지 않는다. + + [재검증 입력 확인] + - 문항 원문, 전체 답변, JD 주요 업무, 자격 요건, 우대 사항을 함께 확인한다. + - 후보 문장, 1차 status, 1차 reasonBasis, 2차 제거 decision, 2차 제거 reason을 함께 확인한다. + - 후보 문장 하나만 보고 복원하지 않는다. + - 후보가 문항 의도에 맞는지 확인한다. + - 전체 답변 문맥에서 실제 수정이 필요한지 확인한다. + - JD 요구사항과 직접 관련되는지 확인한다. + - reason이 원문에서 확인 가능한지 확인한다. + - improvement가 실제로 더 유용한지 확인한다. + + [NO_CORRECTION_NEEDED 판단 기준] + - 모든 후보 문장이 이미 충분히 구체적이다. + - 후보가 단순 문체 선호 차이이거나 질문 의도와 무관하다. + - 후보가 한 줄 전체가 대괄호로 감싸진 소제목이다. + - 다짐/포부 문장을 성과 부족으로 잘못 평가했다. + - 자격증, 학력, 기술명 등 단순 정량 키워드 부재만 문제 삼았다. + - 안전하고 실질적인 개선문을 만들 수 없고 reason도 사용자에게 도움이 되지 않는다. + + [improvement] + - 안전한 교체 문장을 만들 수 없으면 null이다. + - 첨삭 행위를 설명하는 메타 문장을 쓰지 않는다. + - 원문과 같은 문장, 다른 원문 문장 복사, JD 경험 생성, 시제 변경을 금지한다. + - KEEP_BEST_CANDIDATE를 선택했다면 improvement는 reason에서 지적한 문제를 실제로 개선해야 한다. + - 더 구체적으로 작성하겠습니다, 직무 역량을 강화하겠습니다, 성과를 명확히 보여주었습니다 같은 문장은 improvementActionable=false다. + + [문항 유형별 복원 기준] + - 지원 동기: 직무 선택 이유, 본인 경험과 직무 연결, 지원자 근거가 부족할 때만 복원한다. 과거 성과 수치나 STAR 구조가 없다는 이유로 복원하지 않는다. + - 포부/다짐: 실행 방향, 직무 연결, 성장 또는 기여 계획이 지나치게 추상적일 때만 복원한다. 과거 성과, 정량 수치, 과거 행동 근거가 없다는 이유로 복원하지 않는다. + - 경험/성과: 역할, 행동 과정, 결과, 개인 기여가 부족할 때 복원할 수 있다. + - 협업/갈등: 갈등 원인, 조율 행동, 결과, 상호작용이 부족할 때 복원할 수 있다. + + [채용 공고] + 회사명: %s + 직무명: %s + + %s + + + %s + + + %s + + + [검증된 1차 후보] + %s + + [2차 검증 결과] + %s + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + """.formatted( + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + candidateJson, + reviewJson, + formatJobPostingReferences(referenceContext.jobPostingReferences()), + formatQuestionReferences(referenceContext.questionReferences()), + jobCategoryCriteriaSection, + questionText + ); + } + + String buildSinglePassPrompt( + AnalysisPromptInput promptInput, + RetrievalContext referenceContext, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + String questionText = formatQuestions(promptInput); + String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); + String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); + String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); + String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); + + return """ + [시스템 지시] + 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. + 반드시 JSON만 출력한다. + 자소서 원문에 없는 sentence를 만들지 않는다. + sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. + 한국어 사용자 노출 라벨을 만들거나 추정하지 않는다. + + %s + + %s + + %s + + [Few-shot 예시] + %s + + [채용 공고] + 회사명: %s + 직무명: %s + 아래 JD 영역의 역할: + - 는 최우선 업무 기준이다. + - 는 핵심 자격요건 기준이다. + - 는 보조 기준이며 단독 결격 사유로 사용하지 않는다. + + + %s + + + + %s + + + + %s + + + [유사 JD 검색 결과] + %s + + [유사 자소서 문항 검색 결과] + %s + + %s + + [자소서 문항과 답변] + %s + + [출력 전 자체 검증] + - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. + - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. + - questionAnalyses의 status는 proven, mentioned, fabricated 중 하나만 사용한다. + - sentence는 answer에 포함된 정확한 substring만 사용한다. + - missing은 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. + - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. + - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. + - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. + - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. + - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. + - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. + - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. + - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. + - 한 줄 전체가 대괄호로 감싸진 소제목을 questionAnalyses 또는 keyStrengths에 포함하지 않았는지 확인한다. + - jobFit, impact, completeness는 0~100 정수로 출력한다. + - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. + """.formatted( + AnalysisPromptPolicy.OUTPUT_SCHEMA, + AnalysisPromptPolicy.EVALUATION_CRITERIA, + AnalysisPromptPolicy.STATUS_AND_WRITING_RULES, + fewShotPromptBlock, + defaultString(promptInput.companyName()), + defaultString(promptInput.jobName()), + defaultString(promptInput.mainTasks()), + defaultString(promptInput.qualifications()), + defaultString(promptInput.preferences()), + similarJobPostingText, + similarQuestionText, + jobCategoryCriteriaSection, + questionText + ); + } + + private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { + if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { + return fewShotPromptProvider.getPrompt(); + } + try { + List selectedFewShots = fewShotSearchService.searchRelevantFewShots( + FewShotSearchQuery.from(promptInput), + fewShotProperties.getSearch().getTopK() + ); + if (selectedFewShots.isEmpty()) { + log.warn( + "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", + promptInput.caseId(), + fewShotProperties.getDatasetVersion() + ); + return fewShotPromptProvider.getPrompt(); + } + log.debug( + "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", + promptInput.caseId(), + selectedFewShots.stream().map(item -> item.fewShotCase().id()).toList(), + selectedFewShots.stream().map(item -> item.fewShotCase().source()).toList(), + selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), + fewShotProperties.getDatasetVersion() + ); + return fewShotPromptProvider.buildPromptBlock(selectedFewShots); + } catch (Exception e) { + log.warn( + "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", + promptInput.caseId(), + fewShotProperties.getDatasetVersion(), + e.getClass().getSimpleName(), + e.getMessage() + ); + log.debug("dynamic few-shot selection exception", e); + return fewShotPromptProvider.getPrompt(); + } + } + + private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { + if (criteria == null) { + return ""; + } + + return """ + [직무별 보조 평가 기준] + 중분류: %s + 주의: + - 이 직무별 기준은 실제 JD를 대체하지 않는다. + - 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다. + - 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다. + - missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다. + - 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다. + - 원문에 없는 수치, 도구, 경험을 만들어내지 않는다. + 핵심 역량: %s + 관련 행동: %s + 관련 키워드: %s + 좋은 근거 예시: %s + 누락 키워드 문구 예시: %s + """.formatted( + defaultString(criteria.jobCategoryMiddle()), + formatCriteriaList(criteria.coreCompetencies()), + formatCriteriaList(criteria.relatedActions()), + formatCriteriaList(criteria.relatedKeywords()), + truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH), + formatCriteriaList(criteria.missingKeywordExamples()) + ).trim(); + } + + private String formatCriteriaList(List values) { + if (values == null || values.isEmpty()) { + return "없음"; + } + String formatted = values.stream() + .filter(value -> value != null && !value.isBlank()) + .limit(MAX_CRITERIA_ITEMS) + .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) + .collect(Collectors.joining("\n")); + return formatted.isBlank() ? "없음" : "\n" + formatted; + } + + private String formatJobPostingReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 주요 업무: %s + 자격 요건: %s + 우대 사항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + + private String formatQuestionReferences(List references) { + if (references == null || references.isEmpty()) { + return "없음"; + } + String formatted = references.stream() + .map(reference -> """ + - 회사명: %s + 직무명: %s + 문항 유형: %s + 글자 수 제한: %s + 문항: %s + 거리: %.4f + """.formatted( + truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), + truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH), + reference.charLimit() == null ? "" : reference.charLimit(), + truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH), + reference.distance() + )) + .reduce("", (left, right) -> left + "\n" + right) + .trim(); + return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); + } + + private String formatQuestions(AnalysisPromptInput promptInput) { + return promptInput.questions().stream() + .map(question -> """ + - questionId: %d + question: %s + answer: %s + """.formatted( + question.questionId(), + defaultString(question.question()), + defaultString(question.answer()) + )) + .collect(Collectors.joining("\n")); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value == null ? List.of() : value); + } catch (JsonProcessingException e) { + return "[]"; + } + } + + private String defaultString(String value) { + return value == null ? "" : value; + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength) + "..."; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java new file mode 100644 index 00000000..ed6cb1d2 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java @@ -0,0 +1,103 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai; + +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.openai.models.responses.StructuredResponse; +import com.openai.models.responses.StructuredResponseOutputMessage; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class AnalysisResponseParser { + + public T extractStructuredContent(StructuredResponse response) { + return response.output().stream() + .filter(item -> item.message().isPresent()) + .flatMap(item -> item.asMessage().content().stream()) + .filter(content -> content.outputText().isPresent()) + .map(StructuredResponseOutputMessage.Content::asOutputText) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." + )); + } + + public AnalysisLlmResponse sanitizeSinglePassSubheadings( + AnalysisPromptInput promptInput, + AnalysisLlmResponse response + ) { + if (response == null || promptInput == null || promptInput.questions() == null) { + return response; + } + Map answerByQuestionId = promptInput.questions().stream() + .collect(Collectors.toMap( + AnalysisPromptInput.QuestionAnswer::questionId, + AnalysisPromptInput.QuestionAnswer::answer + )); + List answers = new ArrayList<>(answerByQuestionId.values()); + List keyStrengths = response.keyStrengths() == null + ? null + : response.keyStrengths().stream() + .filter(item -> item != null && !isBracketedSubheadingInAnyAnswer(answers, item.quote())) + .toList(); + List keyWeaknesses = response.keyWeaknesses() == null + ? null + : response.keyWeaknesses().stream() + .filter(item -> item != null && !isBracketedSubheadingInAnyAnswer(answers, item.quote())) + .toList(); + List questionAnalyses = response.questionAnalyses() == null + ? null + : response.questionAnalyses().stream() + .filter(item -> item != null + && !isBracketedSubheading(answerByQuestionId.get(item.questionId()), item.sentence())) + .toList(); + return new AnalysisLlmResponse( + response.jobFit(), + response.impact(), + response.completeness(), + response.feedback(), + keyStrengths, + keyWeaknesses, + response.missingKeywords(), + questionAnalyses + ); + } + + public QuestionAnalysisStatus parseQuestionAnalysisStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + try { + return QuestionAnalysisStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private boolean isBracketedSubheadingInAnyAnswer(List answers, String candidateText) { + return answers.stream().anyMatch(answer -> isBracketedSubheading(answer, candidateText)); + } + + private boolean isBracketedSubheading(String answer, String candidateText) { + if (!StringUtils.hasText(answer) || !StringUtils.hasText(candidateText)) { + return false; + } + String trimmedCandidate = candidateText.trim(); + if (trimmedCandidate.indexOf('\n') >= 0 + || trimmedCandidate.indexOf('\r') >= 0 + || !trimmedCandidate.startsWith("[") + || !trimmedCandidate.endsWith("]") + || trimmedCandidate.length() <= 2) { + return false; + } + return answer.lines().map(String::trim).anyMatch(trimmedCandidate::equals); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 5b278acc..439bc0ec 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -8,6 +8,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotCase; @@ -41,6 +42,20 @@ class AnalysisAiClientTest { private final FewShotSearchService fewShotSearchService = mock(FewShotSearchService.class); private final FewShotProperties fewShotProperties = new FewShotProperties(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AnalysisResponseParser analysisResponseParser = new AnalysisResponseParser(); + private final AnalysisPromptBuilder analysisPromptBuilder = new AnalysisPromptBuilder( + new FewShotPromptProvider(), + fewShotSearchService, + fewShotProperties, + objectMapper + ); + private final OpenAiAnalysisAdapter openAiAnalysisAdapter = new OpenAiAnalysisAdapter( + mock(OpenAIClient.class), + mock(LlmConcurrencyLimiter.class), + mock(AsyncMetricsRecorder.class), + analysisResponseParser + ); private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( mock(OpenAIClient.class), mock(CorpusRetrievalService.class), @@ -49,7 +64,10 @@ class AnalysisAiClientTest { fewShotSearchService, fewShotProperties, mock(AsyncMetricsRecorder.class), - new ObjectMapper() + objectMapper, + analysisPromptBuilder, + analysisResponseParser, + openAiAnalysisAdapter ); @Test From 3cce00ddde5c59425afcb691ed79debe1398449e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:03:27 +0900 Subject: [PATCH 24/55] =?UTF-8?q?[Refactor]=20analysis=20async=20orchestra?= =?UTF-8?q?tion/infrastructure=20=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/async/AnalysisAsyncUseCase.java | 182 +++++++++ .../async/AnalysisAsyncQueueProcessor.java | 19 + .../AnalysisAsyncTaskSweepCoordinator.java | 120 ++++++ .../async/AnalysisAsyncWorkerBridge.java | 369 ++++++++++++++++++ .../async/AnalysisAsyncFacadeService.java | 171 +------- .../service/async/AnalysisAsyncProcessor.java | 14 +- .../async/AnalysisAsyncSweepService.java | 113 +----- .../async/AnalysisWorkerBridgeService.java | 356 +---------------- 8 files changed, 743 insertions(+), 601 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java new file mode 100644 index 00000000..408c7f32 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java @@ -0,0 +1,182 @@ +package com.jobdri.jobdri_api.domain.analysis.application.usecase.async; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncProcessor; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Locale; + +// 분석 비동기 작업의 접수/조회/취소 유스케이스를 조율한다. +public class AnalysisAsyncUseCase { + private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; + + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncProcessor analysisAsyncProcessor; + private final AnalysisService analysisService; + private final UserService userService; + + public AnalysisAsyncUseCase( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncProcessor analysisAsyncProcessor, + AnalysisService analysisService, + UserService userService + ) { + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisAsyncProcessor = analysisAsyncProcessor; + this.analysisService = analysisService; + this.userService = userService; + } + + public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { + User validatedUser = userService.validateUser(user); + analysisService.validateAnalysisRequest(validatedUser, mockApplyId); + + return analysisAsyncTaskService.findActiveTask(validatedUser.getId(), mockApplyId) + .map(this::toInProgressResponse) + .orElseGet(() -> createCachedOrProcessTask(validatedUser, mockApplyId)); + } + + public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String taskId) { + User validatedUser = userService.validateUser(user); + AnalysisAsyncStatusResponse status = analysisAsyncTaskService.getTaskStatus(validatedUser.getId(), taskId); + if (!status.mockApplyId().equals(mockApplyId)) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); + } + if (!"SUCCEEDED".equals(status.status())) { + return status; + } + + return AnalysisAsyncStatusResponse.builder() + .taskId(status.taskId()) + .mockApplyId(status.mockApplyId()) + .status(status.status()) + .message(status.message()) + .error(status.error()) + .failureReason(status.failureReason()) + .workerId(status.workerId()) + .retryCount(status.retryCount()) + .maxRetryCount(status.maxRetryCount()) + .queueLatencyMillis(status.queueLatencyMillis()) + .createdAt(status.createdAt()) + .submittedAt(status.submittedAt()) + .lastAttemptAt(status.lastAttemptAt()) + .startedAt(status.startedAt()) + .completedAt(status.completedAt()) + .cancelRequested(status.cancelRequested()) + .cancelledAt(status.cancelledAt()) + .currentStep(status.currentStep()) + .progressPercent(status.progressPercent()) + .estimatedRemainingSeconds(status.estimatedRemainingSeconds()) + .steps(status.steps()) + .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) + .build(); + } + + public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { + User validatedUser = userService.validateUser(user); + return analysisAsyncTaskService.cancelTask(validatedUser.getId(), mockApplyId, taskId); + } + + private AnalysisAsyncSubmitResponse createCachedOrProcessTask(User user, Long mockApplyId) { + if (analysisService.hasReusableAnalysis(user, mockApplyId)) { + return toCachedResponse(); + } + return createAndProcessTask(user, mockApplyId); + } + + private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { + PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); + if (!pendingTaskResult.created()) { + return toInProgressResponse(pendingTaskResult.task()); + } + + AnalysisAsyncTask task = pendingTaskResult.task(); + String taskId = task.getTaskId(); + + try { + analysisAsyncProcessor.process( + taskId, + user.getId(), + mockApplyId, + task.getMaxRetryCount() + ); + return new AnalysisAsyncSubmitResponse( + taskId, + "PENDING", + "자소서 분석 비동기 작업이 접수되었습니다.", + false, + false + ); + } catch (RuntimeException e) { + analysisAsyncTaskService.deleteTask(taskId); + throw e; + } + } + + private PendingTaskResult createPendingTask(User user, Long mockApplyId) { + try { + return new PendingTaskResult( + analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId), + true + ); + } catch (DataIntegrityViolationException e) { + if (!isActiveTaskUniqueConflict(e)) { + throw e; + } + AnalysisAsyncTask existingTask = analysisAsyncTaskService.findActiveTask(user.getId(), mockApplyId) + .orElseThrow(() -> e); + return new PendingTaskResult(existingTask, false); + } + } + + private AnalysisAsyncSubmitResponse toInProgressResponse(AnalysisAsyncTask task) { + return new AnalysisAsyncSubmitResponse( + task.getTaskId(), + task.getStatus().name(), + "이미 진행 중인 자소서 분석 작업이 있습니다.", + false, + false + ); + } + + private AnalysisAsyncSubmitResponse toCachedResponse() { + return new AnalysisAsyncSubmitResponse( + null, + "SUCCEEDED", + "동일 입력의 기존 자소서 분석 결과를 재사용했습니다.", + true, + true + ); + } + + private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { + Throwable cause = exception; + while (cause != null) { + if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation + && containsConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (containsConstraintName(cause.getMessage())) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private boolean containsConstraintName(String value) { + return value != null && value.toLowerCase(Locale.ROOT).contains(ACTIVE_TASK_UNIQUE_CONSTRAINT); + } + + private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java new file mode 100644 index 00000000..a92ce857 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisTaskMessagePublisher; + +// 분석 비동기 작업을 MQ 워커 메시지로 변환해 발행한다. +public class AnalysisAsyncQueueProcessor { + private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; + + public AnalysisAsyncQueueProcessor(AnalysisTaskMessagePublisher analysisTaskMessagePublisher) { + this.analysisTaskMessagePublisher = analysisTaskMessagePublisher; + } + + public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { + analysisTaskMessagePublisher.publish( + AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java new file mode 100644 index 00000000..b873a4a5 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -0,0 +1,120 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumSet; + +@Slf4j +// timeout/retry 기준으로 만료된 분석 async task를 정리한다. +public class AnalysisAsyncTaskSweepCoordinator { + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisCreditService analysisCreditService; + private final UserService userService; + private final TransactionTemplate transactionTemplate; + private final AnalysisQueueProperties analysisQueueProperties; + + public AnalysisAsyncTaskSweepCoordinator( + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisCreditService analysisCreditService, + UserService userService, + TransactionTemplate transactionTemplate, + AnalysisQueueProperties analysisQueueProperties + ) { + this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisCreditService = analysisCreditService; + this.userService = userService; + this.transactionTemplate = transactionTemplate; + this.analysisQueueProperties = analysisQueueProperties; + } + + public int sweepTimedOutTasks() { + int expiredCount = 0; + for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + try { + expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); + } catch (RuntimeException e) { + log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); + } + } + return expiredCount; + } + + private int sweepTimedOutTask(String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); + if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + return 0; + } + + ExpirationDecision expirationDecision = resolveExpiration(task); + if (expirationDecision == null) { + return 0; + } + + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed( + task.getTaskId(), + expirationDecision.failureReason(), + expirationDecision.errorMessage(), + task.getRetryCount() + ); + return 1; + } + + private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStatus() == TaskStatus.PENDING + && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { + return new ExpirationDecision( + FailureReason.QUEUE_TIMEOUT, + "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." + ); + } + + LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); + if (task.getStatus() == TaskStatus.RUNNING + && isExpired(lastActivityAt, now, analysisQueueProperties.getProcessingTimeoutSeconds())) { + return new ExpirationDecision( + FailureReason.INTERNAL_ERROR, + "자소서 분석 작업이 처리 제한 시간을 초과했습니다." + ); + } + + return null; + } + + private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutSeconds) { + if (baseTime == null || timeoutSeconds <= 0) { + return false; + } + return Duration.between(baseTime, now).getSeconds() >= timeoutSeconds; + } + + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + + User user = userService.getUser(task.getUserId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditReleased(task.getTaskId()); + } + + private record ExpirationDecision(FailureReason failureReason, String errorMessage) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java new file mode 100644 index 00000000..f7394044 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -0,0 +1,369 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; +import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; +import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.logging.LoggingContext; +import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +// 외부 분석 워커와 내부 분석 도메인 상태를 연결한다. +public class AnalysisAsyncWorkerBridge { + private final AnalysisAsyncTaskService analysisAsyncTaskService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + private final AnalysisService analysisService; + private final AnalysisCreditService analysisCreditService; + private final UserService userService; + private final WorkerTaskResultService workerTaskResultService; + private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + private final ObjectMapper objectMapper; + + public AnalysisAsyncWorkerBridge( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisService analysisService, + AnalysisCreditService analysisCreditService, + UserService userService, + WorkerTaskResultService workerTaskResultService, + AnalysisInputFingerprintProvider analysisInputFingerprintProvider, + ObjectMapper objectMapper + ) { + this.analysisAsyncTaskService = analysisAsyncTaskService; + this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; + this.analysisService = analysisService; + this.analysisCreditService = analysisCreditService; + this.userService = userService; + this.workerTaskResultService = workerTaskResultService; + this.analysisInputFingerprintProvider = analysisInputFingerprintProvider; + this.objectMapper = objectMapper; + } + + @Transactional + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + AnalysisAsyncTask task = getTask(taskId); + if (isTerminal(task)) { + return; + } + analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); + try (var ignored = LoggingContext.with("worker.task.running", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, null))) { + log.info("Analysis worker marked task as running"); + } + } + + @Transactional + public void markRetry( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTask(taskId); + if (isTerminal(task)) { + return; + } + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); + analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); + try (var ignored = LoggingContext.with("worker.task.retry", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { + log.warn("Analysis worker scheduled retry: failureReason={}", failureReason); + } + } + + @Transactional + public void failTask( + String taskId, + FailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + AnalysisAsyncTask task = getTask(taskId); + if (isTerminal(task)) { + return; + } + + analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); + releaseCreditIfNeeded(task); + analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); + try (var ignored = LoggingContext.with("worker.task.failed", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { + log.warn("Analysis worker failed task: failureReason={}", failureReason); + } + } + + @Transactional + public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { + AnalysisAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); + if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + reserveCreditIfNeeded(task); + if (task.getExecutionContextSnapshot() != null) { + return readContextSnapshot(task); + } + + User user = userService.getUser(userId); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); + AnalysisWorkerContextResponse context = new AnalysisWorkerContextResponse( + userId, + mockApplyId, + payload.jobPosting().getCompany().getName(), + payload.jobPosting().getDetailClassification().getDetailName(), + payload.jobPosting().getTask(), + payload.jobPosting().getRequirement(), + payload.jobPosting().getPreferred(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), + payload.jobPosting().getDetailClassification().getDetailName(), + toQuestionItems(payload.questions()), + CorpusReferenceContext.from(payload.retrievalContext()), + payload.similarJobPostings() + ); + task.captureExecutionSnapshot( + writeContextSnapshot(context), + analysisInputFingerprintProvider.create(payload) + ); + return context; + } + + @Transactional + public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { + AnalysisAsyncTask task = getTask(taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + if (task.getStatus() == TaskStatus.CANCELLED) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "취소된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + workerTaskResultService.upsertGenerated( + TaskType.ANALYSIS_COMPLETE, + taskId, + new AnalysisWorkerResultStoreRequest(request.userId(), request.mockApplyId(), request.llmResponse()) + ); + if (task.getStatus() == TaskStatus.SUCCEEDED) { + workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); + return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); + } + if (task.getStatus() == TaskStatus.FAILED) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "이미 실패 처리된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + + User user = userService.getUser(request.userId()); + AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( + user, + request.mockApplyId(), + contextSnapshot.similarJobPostings() + ).withAnswerSnapshots(contextSnapshot.questions().stream() + .filter(question -> question.answer() != null && !question.answer().isBlank()) + .map(question -> new AnalysisExecutionPayload.AnswerSnapshot( + question.questionId(), + question.answer() + )) + .toList()); + AnalysisLlmResponse llmResponse = request.llmResponse(); + AnalysisResponse response = analysisService.finalizeAnalysis( + user, + request.mockApplyId(), + payload, + llmResponse, + task.getInputFingerprintSnapshot() + ); + analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); + confirmCreditIfNeeded(task); + analysisAsyncTaskService.markSuccess(taskId, response); + workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); + try (var ignored = LoggingContext.with( + "worker.task.completed", + null, + workerContext(taskId, "ANALYSIS", request.workerId(), task.getRetryCount(), request.queueLatencyMillis()) + )) { + log.info("Analysis worker completed task"); + } + return response; + } + + @Transactional + public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { + AnalysisAsyncTask task = getTask(taskId); + rejectIfCancelled(task, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId); + if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 결과 저장 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + workerTaskResultService.upsertGenerated(TaskType.ANALYSIS_COMPLETE, taskId, request); + try (var ignored = LoggingContext.with("worker.result.stored", null, workerContext(taskId, "ANALYSIS", null, task.getRetryCount(), null))) { + log.info("Analysis worker result stored"); + } + } + + @Transactional(readOnly = true) + public WorkerTaskResultResponse getStoredResult(String taskId) { + getTask(taskId); + return workerTaskResultService.get(taskId); + } + + private List toQuestionItems(List questions) { + return questions.stream() + .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( + question.getId(), + question.getContent(), + question.getAnswer(), + question.getLimit() + )) + .toList(); + } + + private String writeContextSnapshot(AnalysisWorkerContextResponse context) { + try { + return objectMapper.writeValueAsString(context); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다." + ); + } + } + + private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) { + if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId() + ); + } + try { + return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class); + } catch (JsonProcessingException exception) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId() + ); + } + } + + private AnalysisAsyncTask getTask(String taskId) { + return analysisAsyncTaskRepository.findById(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + + private boolean isTerminal(AnalysisAsyncTask task) { + return task.getStatus() == TaskStatus.SUCCEEDED + || task.getStatus() == TaskStatus.FAILED + || task.getStatus() == TaskStatus.CANCELLED; + } + + private void rejectIfCancelled(AnalysisAsyncTask task, String message) { + if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); + } + } + + private void reserveCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.NONE) { + return; + } + + User user = userService.getUser(task.getUserId()); + String creditReferenceId = analysisCreditService.createAsyncReferenceId(task.getTaskId()); + analysisCreditService.deduct(user, creditReferenceId); + analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); + } + + private void confirmCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); + } + + private void releaseCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return; + } + User user = userService.getUser(task.getUserId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); + analysisAsyncTaskService.markCreditReleased(task.getTaskId()); + } + + private Map workerContext( + String taskId, + String taskType, + String workerId, + Integer retryCount, + Long queueLatencyMillis + ) { + Map context = new LinkedHashMap<>(); + context.put(LoggingMdcKeys.TASK_ID, taskId); + context.put(LoggingMdcKeys.TASK_TYPE, taskType); + if (workerId != null) { + context.put(LoggingMdcKeys.WORKER_ID, workerId); + } + if (retryCount != null) { + context.put(LoggingMdcKeys.RETRY_COUNT, String.valueOf(retryCount)); + } + if (queueLatencyMillis != null) { + context.put(LoggingMdcKeys.QUEUE_LATENCY_MILLIS, String.valueOf(queueLatencyMillis)); + } + return context; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java index 0c890c9d..c5ffe757 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java @@ -1,172 +1,21 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.application.usecase.async.AnalysisAsyncUseCase; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; -import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import java.util.Locale; - @Service -@RequiredArgsConstructor // 분석 비동기 작업의 접수와 상태 조회를 외부 API 관점에서 조율하는 서비스다. -public class AnalysisAsyncFacadeService { - private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; - - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisAsyncProcessor analysisAsyncProcessor; - private final AnalysisService analysisService; - private final UserService userService; - - public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { - User validatedUser = userService.validateUser(user); - analysisService.validateAnalysisRequest(validatedUser, mockApplyId); - - return analysisAsyncTaskService.findActiveTask(validatedUser.getId(), mockApplyId) - .map(this::toInProgressResponse) - .orElseGet(() -> createCachedOrProcessTask(validatedUser, mockApplyId)); - } - - public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String taskId) { - User validatedUser = userService.validateUser(user); - AnalysisAsyncStatusResponse status = analysisAsyncTaskService.getTaskStatus(validatedUser.getId(), taskId); - if (!status.mockApplyId().equals(mockApplyId)) { - throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); - } - if (!"SUCCEEDED".equals(status.status())) { - return status; - } - - return AnalysisAsyncStatusResponse.builder() - .taskId(status.taskId()) - .mockApplyId(status.mockApplyId()) - .status(status.status()) - .message(status.message()) - .error(status.error()) - .failureReason(status.failureReason()) - .workerId(status.workerId()) - .retryCount(status.retryCount()) - .maxRetryCount(status.maxRetryCount()) - .queueLatencyMillis(status.queueLatencyMillis()) - .createdAt(status.createdAt()) - .submittedAt(status.submittedAt()) - .lastAttemptAt(status.lastAttemptAt()) - .startedAt(status.startedAt()) - .completedAt(status.completedAt()) - .cancelRequested(status.cancelRequested()) - .cancelledAt(status.cancelledAt()) - .currentStep(status.currentStep()) - .progressPercent(status.progressPercent()) - .estimatedRemainingSeconds(status.estimatedRemainingSeconds()) - .steps(status.steps()) - .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) - .build(); - } - - public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { - User validatedUser = userService.validateUser(user); - return analysisAsyncTaskService.cancelTask(validatedUser.getId(), mockApplyId, taskId); - } - - private AnalysisAsyncSubmitResponse createCachedOrProcessTask(User user, Long mockApplyId) { - if (analysisService.hasReusableAnalysis(user, mockApplyId)) { - return toCachedResponse(); - } - return createAndProcessTask(user, mockApplyId); - } - - private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApplyId) { - PendingTaskResult pendingTaskResult = createPendingTask(user, mockApplyId); - if (!pendingTaskResult.created()) { - return toInProgressResponse(pendingTaskResult.task()); - } - - AnalysisAsyncTask task = pendingTaskResult.task(); - String taskId = task.getTaskId(); - - try { - analysisAsyncProcessor.process( - taskId, - user.getId(), - mockApplyId, - task.getMaxRetryCount() - ); - return new AnalysisAsyncSubmitResponse( - taskId, - "PENDING", - "자소서 분석 비동기 작업이 접수되었습니다.", - false, - false - ); - } catch (RuntimeException e) { - analysisAsyncTaskService.deleteTask(taskId); - throw e; - } - } - - private PendingTaskResult createPendingTask(User user, Long mockApplyId) { - try { - return new PendingTaskResult( - analysisAsyncTaskService.createPendingTask(user.getId(), mockApplyId), - true - ); - } catch (DataIntegrityViolationException e) { - if (!isActiveTaskUniqueConflict(e)) { - throw e; - } - AnalysisAsyncTask existingTask = analysisAsyncTaskService.findActiveTask(user.getId(), mockApplyId) - .orElseThrow(() -> e); - return new PendingTaskResult(existingTask, false); - } - } - - private AnalysisAsyncSubmitResponse toInProgressResponse(AnalysisAsyncTask task) { - return new AnalysisAsyncSubmitResponse( - task.getTaskId(), - task.getStatus().name(), - "이미 진행 중인 자소서 분석 작업이 있습니다.", - false, - false - ); - } - - private AnalysisAsyncSubmitResponse toCachedResponse() { - return new AnalysisAsyncSubmitResponse( - null, - "SUCCEEDED", - "동일 입력의 기존 자소서 분석 결과를 재사용했습니다.", - true, - true - ); - } - - private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { - Throwable cause = exception; - while (cause != null) { - if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation - && containsConstraintName(constraintViolation.getConstraintName())) { - return true; - } - if (containsConstraintName(cause.getMessage())) { - return true; - } - cause = cause.getCause(); - } - return false; - } - - private boolean containsConstraintName(String value) { - return value != null && value.toLowerCase(Locale.ROOT).contains(ACTIVE_TASK_UNIQUE_CONSTRAINT); - } - - private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { +public class AnalysisAsyncFacadeService extends AnalysisAsyncUseCase { + + public AnalysisAsyncFacadeService( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncProcessor analysisAsyncProcessor, + AnalysisService analysisService, + UserService userService + ) { + super(analysisAsyncTaskService, analysisAsyncProcessor, analysisService, userService); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java index 36ef74e9..2c440765 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java @@ -1,19 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; -import lombok.RequiredArgsConstructor; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncQueueProcessor; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor // 분석 비동기 작업을 MQ 메시지로 변환해 워커 실행 경로로 넘기는 서비스다. -public class AnalysisAsyncProcessor { +public class AnalysisAsyncProcessor extends AnalysisAsyncQueueProcessor { - private final AnalysisTaskMessagePublisher analysisTaskMessagePublisher; - - public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { - analysisTaskMessagePublisher.publish( - AnalysisTaskMessage.of(taskId, userId, mockApplyId, maxRetryCount) - ); + public AnalysisAsyncProcessor(AnalysisTaskMessagePublisher analysisTaskMessagePublisher) { + super(analysisTaskMessagePublisher); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index 83354b78..af835182 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -1,107 +1,30 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncTaskSweepCoordinator; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.EnumSet; - -@Slf4j @Service -@RequiredArgsConstructor -public class AnalysisAsyncSweepService { - - private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisCreditService analysisCreditService; - private final UserService userService; - private final TransactionTemplate transactionTemplate; - private final AnalysisQueueProperties analysisQueueProperties; - - public int sweepTimedOutTasks() { - int expiredCount = 0; - for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { - try { - expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); - } catch (RuntimeException e) { - log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); - } - } - return expiredCount; - } - - private int sweepTimedOutTask(String taskId) { - AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); - if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { - return 0; - } - - ExpirationDecision expirationDecision = resolveExpiration(task); - if (expirationDecision == null) { - return 0; - } - - releaseCreditIfNeeded(task); - analysisAsyncTaskService.markFailed( - task.getTaskId(), - expirationDecision.failureReason(), - expirationDecision.errorMessage(), - task.getRetryCount() +public class AnalysisAsyncSweepService extends AnalysisAsyncTaskSweepCoordinator { + + public AnalysisAsyncSweepService( + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisCreditService analysisCreditService, + UserService userService, + TransactionTemplate transactionTemplate, + AnalysisQueueProperties analysisQueueProperties + ) { + super( + analysisAsyncTaskRepository, + analysisAsyncTaskService, + analysisCreditService, + userService, + transactionTemplate, + analysisQueueProperties ); - return 1; - } - - private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { - LocalDateTime now = LocalDateTime.now(); - if (task.getStatus() == TaskStatus.PENDING - && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { - return new ExpirationDecision( - FailureReason.QUEUE_TIMEOUT, - "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." - ); - } - - LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); - if (task.getStatus() == TaskStatus.RUNNING - && isExpired(lastActivityAt, now, analysisQueueProperties.getProcessingTimeoutSeconds())) { - return new ExpirationDecision( - FailureReason.INTERNAL_ERROR, - "자소서 분석 작업이 처리 제한 시간을 초과했습니다." - ); - } - - return null; - } - - private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeoutSeconds) { - if (baseTime == null || timeoutSeconds <= 0) { - return false; - } - return Duration.between(baseTime, now).getSeconds() >= timeoutSeconds; - } - - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - - User user = userService.getUser(task.getUserId()); - analysisCreditService.refund(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - - private record ExpirationDecision(FailureReason failureReason, String errorMessage) { } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index ed85cfb1..07b9779c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -1,353 +1,39 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncWorkerBridge; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; -import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; -import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; -import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import com.jobdri.jobdri_api.global.logging.LoggingContext; -import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; @Service -@Slf4j -@RequiredArgsConstructor // 외부 분석 워커와 내부 분석 도메인 상태를 연결해 주는 브리지 서비스다. -public class AnalysisWorkerBridgeService { - - private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; - private final AnalysisService analysisService; - private final AnalysisCreditService analysisCreditService; - private final UserService userService; - private final WorkerTaskResultService workerTaskResultService; - private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; - private final ObjectMapper objectMapper; - - @Transactional - public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - analysisAsyncTaskService.markRunning(taskId, workerId, retryCount, submittedAt); - try (var ignored = LoggingContext.with("worker.task.running", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, null))) { - log.info("Analysis worker marked task as running"); - } - } - - @Transactional - public void markRetry( - String taskId, - FailureReason failureReason, - String errorMessage, - int retryCount, - String workerId, - Long queueLatencyMillis +public class AnalysisWorkerBridgeService extends AnalysisAsyncWorkerBridge { + + public AnalysisWorkerBridgeService( + AnalysisAsyncTaskService analysisAsyncTaskService, + AnalysisAsyncTaskRepository analysisAsyncTaskRepository, + AnalysisService analysisService, + AnalysisCreditService analysisCreditService, + UserService userService, + WorkerTaskResultService workerTaskResultService, + AnalysisInputFingerprintProvider analysisInputFingerprintProvider, + ObjectMapper objectMapper ) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); - analysisAsyncTaskService.markRetryScheduled(taskId, failureReason, errorMessage, retryCount); - try (var ignored = LoggingContext.with("worker.task.retry", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { - log.warn("Analysis worker scheduled retry: failureReason={}", failureReason); - } - } - - @Transactional - public void failTask( - String taskId, - FailureReason failureReason, - String errorMessage, - int retryCount, - String workerId, - Long queueLatencyMillis - ) { - AnalysisAsyncTask task = getTask(taskId); - if (isTerminal(task)) { - return; - } - - analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); - releaseCreditIfNeeded(task); - analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); - try (var ignored = LoggingContext.with("worker.task.failed", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { - log.warn("Analysis worker failed task: failureReason={}", failureReason); - } - } - - @Transactional - public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { - AnalysisAsyncTask task = getTask(taskId); - rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); - if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." - ); - } - reserveCreditIfNeeded(task); - if (task.getExecutionContextSnapshot() != null) { - return readContextSnapshot(task); - } - - User user = userService.getUser(userId); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); - AnalysisWorkerContextResponse context = new AnalysisWorkerContextResponse( - userId, - mockApplyId, - payload.jobPosting().getCompany().getName(), - payload.jobPosting().getDetailClassification().getDetailName(), - payload.jobPosting().getTask(), - payload.jobPosting().getRequirement(), - payload.jobPosting().getPreferred(), - payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), - payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), - payload.jobPosting().getDetailClassification().getDetailName(), - toQuestionItems(payload.questions()), - CorpusReferenceContext.from(payload.retrievalContext()), - payload.similarJobPostings() - ); - task.captureExecutionSnapshot( - writeContextSnapshot(context), - analysisInputFingerprintProvider.create(payload) - ); - return context; - } - - @Transactional - public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { - AnalysisAsyncTask task = getTask(taskId); - if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." - ); - } - if (task.getStatus() == TaskStatus.CANCELLED) { - workerTaskResultService.markDeliveryFailedIfPresent( - TaskType.ANALYSIS_COMPLETE, - taskId, - "취소된 자소서 분석 비동기 작업입니다." - ); - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); - } - workerTaskResultService.upsertGenerated( - TaskType.ANALYSIS_COMPLETE, - taskId, - new AnalysisWorkerResultStoreRequest(request.userId(), request.mockApplyId(), request.llmResponse()) + super( + analysisAsyncTaskService, + analysisAsyncTaskRepository, + analysisService, + analysisCreditService, + userService, + workerTaskResultService, + analysisInputFingerprintProvider, + objectMapper ); - if (task.getStatus() == TaskStatus.SUCCEEDED) { - workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); - return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); - } - if (task.getStatus() == TaskStatus.FAILED) { - workerTaskResultService.markDeliveryFailedIfPresent( - TaskType.ANALYSIS_COMPLETE, - taskId, - "이미 실패 처리된 자소서 분석 비동기 작업입니다." - ); - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); - } - - User user = userService.getUser(request.userId()); - AnalysisWorkerContextResponse contextSnapshot = readContextSnapshot(task); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution( - user, - request.mockApplyId(), - contextSnapshot.similarJobPostings() - ).withAnswerSnapshots(contextSnapshot.questions().stream() - .filter(question -> question.answer() != null && !question.answer().isBlank()) - .map(question -> new AnalysisExecutionPayload.AnswerSnapshot( - question.questionId(), - question.answer() - )) - .toList()); - AnalysisLlmResponse llmResponse = request.llmResponse(); - AnalysisResponse response = analysisService.finalizeAnalysis( - user, - request.mockApplyId(), - payload, - llmResponse, - task.getInputFingerprintSnapshot() - ); - analysisAsyncTaskService.updateWorkerMetadata(taskId, request.workerId(), request.queueLatencyMillis()); - confirmCreditIfNeeded(task); - analysisAsyncTaskService.markSuccess(taskId, response); - workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); - try (var ignored = LoggingContext.with( - "worker.task.completed", - null, - workerContext(taskId, "ANALYSIS", request.workerId(), task.getRetryCount(), request.queueLatencyMillis()) - )) { - log.info("Analysis worker completed task"); - } - return response; - } - - @Transactional - public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { - AnalysisAsyncTask task = getTask(taskId); - rejectIfCancelled(task, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId); - if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 결과 저장 요청 정보가 작업 정보와 일치하지 않습니다." - ); - } - workerTaskResultService.upsertGenerated(TaskType.ANALYSIS_COMPLETE, taskId, request); - try (var ignored = LoggingContext.with("worker.result.stored", null, workerContext(taskId, "ANALYSIS", null, task.getRetryCount(), null))) { - log.info("Analysis worker result stored"); - } - } - - @Transactional(readOnly = true) - public WorkerTaskResultResponse getStoredResult(String taskId) { - getTask(taskId); - return workerTaskResultService.get(taskId); - } - - private List toQuestionItems(List questions) { - return questions.stream() - .map(question -> new AnalysisWorkerContextResponse.AnalysisWorkerQuestionItem( - question.getId(), - question.getContent(), - question.getAnswer(), - question.getLimit() - )) - .toList(); - } - - private String writeContextSnapshot(AnalysisWorkerContextResponse context) { - try { - return objectMapper.writeValueAsString(context); - } catch (JsonProcessingException exception) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다." - ); - } - } - - private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task) { - if (task.getExecutionContextSnapshot() == null || task.getInputFingerprintSnapshot() == null) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 실행 snapshot이 존재하지 않습니다. taskId=" + task.getTaskId() - ); - } - try { - return objectMapper.readValue(task.getExecutionContextSnapshot(), AnalysisWorkerContextResponse.class); - } catch (JsonProcessingException exception) { - throw new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId() - ); - } - } - - private AnalysisAsyncTask getTask(String taskId) { - return analysisAsyncTaskRepository.findById(taskId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, - "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId - )); - } - - private boolean isTerminal(AnalysisAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; - } - - private void rejectIfCancelled(AnalysisAsyncTask task, String message) { - if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); - } - } - - private void reserveCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.NONE) { - return; - } - - User user = userService.getUser(task.getUserId()); - String creditReferenceId = analysisCreditService.createAsyncReferenceId(task.getTaskId()); - analysisCreditService.deduct(user, creditReferenceId); - analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); - } - - private void confirmCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); - } - - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - User user = userService.getUser(task.getUserId()); - analysisCreditService.refund(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - - private Map workerContext( - String taskId, - String taskType, - String workerId, - Integer retryCount, - Long queueLatencyMillis - ) { - Map context = new LinkedHashMap<>(); - context.put(LoggingMdcKeys.TASK_ID, taskId); - context.put(LoggingMdcKeys.TASK_TYPE, taskType); - if (workerId != null) { - context.put(LoggingMdcKeys.WORKER_ID, workerId); - } - if (retryCount != null) { - context.put(LoggingMdcKeys.RETRY_COUNT, String.valueOf(retryCount)); - } - if (queueLatencyMillis != null) { - context.put(LoggingMdcKeys.QUEUE_LATENCY_MILLIS, String.valueOf(queueLatencyMillis)); - } - return context; } } From ed3a1d329307f157358163c8b00819f0019c9767 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:06:59 +0900 Subject: [PATCH 25/55] =?UTF-8?q?[Refactor]=20QuestionService=EB=A5=BC=20c?= =?UTF-8?q?ommand/query/catalog=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuestionController.java | 18 +- .../QuestionCandidateCatalogService.java | 47 +++ .../question/QuestionCommandService.java | 233 ++++++++++++++ .../question/QuestionDomainSupport.java | 71 +++++ .../question/QuestionQueryService.java | 72 +++++ .../service/question/QuestionService.java | 296 +----------------- 6 files changed, 450 insertions(+), 287 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java index 7f10e83d..586ae304 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/QuestionController.java @@ -6,7 +6,8 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionService; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionCommandService; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionQueryService; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; import com.jobdri.jobdri_api.global.security.UserDetailsImpl; import io.swagger.v3.oas.annotations.Operation; @@ -32,7 +33,8 @@ @Tag(name = "Question", description = "자소서 문항 선택 및 답변 저장 API") public class QuestionController { - private final QuestionService questionService; + private final QuestionQueryService questionQueryService; + private final QuestionCommandService questionCommandService; @Operation(summary = "문항 후보 목록 조회", description = "문항 선택 화면에서 사용할 기본 문항 후보와 선택 여부를 조회합니다.") @GetMapping("/candidates") @@ -42,7 +44,7 @@ public ApiResponse> getQuestionCandidates( ) { return ApiResponse.onSuccess( "문항 후보 목록 조회에 성공했습니다.", - questionService.getQuestionCandidates(userDetails.getUser(), mockApplyId) + questionQueryService.getQuestionCandidates(userDetails.getUser(), mockApplyId) ); } @@ -55,7 +57,7 @@ public ApiResponse addCustomQuestionCandidate( ) { return ApiResponse.onSuccess( "직접 추가 문항 후보가 생성되었습니다.", - questionService.addCustomQuestionCandidate(userDetails.getUser(), mockApplyId, request) + questionCommandService.addCustomQuestionCandidate(userDetails.getUser(), mockApplyId, request) ); } @@ -67,7 +69,7 @@ public ApiResponse getSelectedQuestions( ) { return ApiResponse.onSuccess( "선택 문항 조회에 성공했습니다.", - questionService.getSelectedQuestions(userDetails.getUser(), mockApplyId) + questionQueryService.getSelectedQuestions(userDetails.getUser(), mockApplyId) ); } @@ -80,7 +82,7 @@ public ApiResponse saveSelectedQuestions( ) { return ApiResponse.onSuccess( "선택 문항이 저장되었습니다.", - questionService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request) + questionCommandService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request) ); } @@ -93,7 +95,7 @@ public ApiResponse saveAnswers( ) { return ApiResponse.onSuccess( "자소서 답변이 저장되었습니다.", - questionService.saveAnswers(userDetails.getUser(), mockApplyId, request) + questionCommandService.saveAnswers(userDetails.getUser(), mockApplyId, request) ); } @@ -106,7 +108,7 @@ public ApiResponse deleteQuestion( ) { return ApiResponse.onSuccess( "선택 문항이 삭제되었습니다.", - questionService.deleteQuestion(userDetails.getUser(), mockApplyId, questionId) + questionCommandService.deleteQuestion(userDetails.getUser(), mockApplyId, questionId) ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java new file mode 100644 index 00000000..7a510993 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java @@ -0,0 +1,47 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +@Service +// 기본 문항 후보와 후보 판별 규칙을 제공한다. +public class QuestionCandidateCatalogService { + private static final List DEFAULT_CANDIDATES = List.of( + new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), + new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), + new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), + new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), + new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) + ); + + public List getDefaultCandidateResponses(Set selectedContents) { + return DEFAULT_CANDIDATES.stream() + .map(candidate -> new QuestionCandidateResponse( + candidate.id(), + candidate.content(), + candidate.charLimit(), + selectedContents.contains(candidate.content()), + false + )) + .toList(); + } + + public boolean isCustomQuestion(String content) { + return DEFAULT_CANDIDATES.stream() + .noneMatch(candidate -> candidate.content().equals(content)); + } + + public void validateCustomCandidate(String content) { + if (!isCustomQuestion(content)) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미 기본 후보에 존재하는 문항입니다."); + } + } + + private record QuestionCandidate(Long id, String content, int charLimit) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java new file mode 100644 index 00000000..c7905603 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java @@ -0,0 +1,233 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +// 문항 후보 추가, 선택 저장, 답변 저장, 삭제를 담당한다. +public class QuestionCommandService { + private final QuestionDomainSupport questionDomainSupport; + private final QuestionCandidateCatalogService questionCandidateCatalogService; + private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; + private final QuestionAnalysisRepository questionAnalysisRepository; + private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + + public QuestionCommandService( + QuestionDomainSupport questionDomainSupport, + QuestionCandidateCatalogService questionCandidateCatalogService, + MockApplyRepository mockApplyRepository, + QuestionRepository questionRepository, + QuestionAnalysisRepository questionAnalysisRepository, + CustomQuestionCandidateRepository customQuestionCandidateRepository + ) { + this.questionDomainSupport = questionDomainSupport; + this.questionCandidateCatalogService = questionCandidateCatalogService; + this.mockApplyRepository = mockApplyRepository; + this.questionRepository = questionRepository; + this.questionAnalysisRepository = questionAnalysisRepository; + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + } + + @Transactional + @AuditLogEvent(action = "CUSTOM_QUESTION_CANDIDATE_ADD", targetType = "MOCK_APPLY", targetId = "#arg1") + public QuestionCandidateResponse addCustomQuestionCandidate( + User user, + Long mockApplyId, + QuestionCandidateCreateRequest request + ) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + String content = request.content().trim(); + questionCandidateCatalogService.validateCustomCandidate(content); + + CustomQuestionCandidate candidate = findOrCreateCustomCandidate(mockApply, content, request.charLimit()); + boolean selected = questionRepository.existsByMockApplyIdAndContent(mockApply.getId(), candidate.getContent()); + + return new QuestionCandidateResponse( + candidate.getId(), + candidate.getContent(), + candidate.getLimit(), + selected, + true + ); + } + + @Transactional + @AuditLogEvent(action = "QUESTION_SELECTION_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") + public QuestionSelectionResponse saveSelectedQuestions( + User user, + Long mockApplyId, + QuestionSelectionSaveRequest request + ) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + questionDomainSupport.validateSelectionCount(request.questions().size()); + + List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); + questionRepository.deleteAll(existingQuestions); + + List questions = request.questions().stream() + .map(item -> Question.create( + mockApply, + item.content().trim(), + questionDomainSupport.resolveCharLimit(item.charLimit()), + "" + )) + .toList(); + List savedQuestions = questionRepository.saveAll(questions); + mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); + + return new QuestionSelectionResponse( + mockApply.getId(), + mockApply.getStatus(), + savedQuestions.stream().map(questionDomainSupport::toQuestionResponse).toList() + ); + } + + @Transactional + @AuditLogEvent(action = "QUESTION_ANSWER_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") + public QuestionAnswerResponse saveAnswers( + User user, + Long mockApplyId, + QuestionAnswerSaveRequest request + ) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + questionDomainSupport.validateSelectionCount(request.questions().size()); + + List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + Map questionMap = existingQuestions.stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + Set requestedQuestionIds = new HashSet<>(); + List syncedQuestions = new ArrayList<>(); + + for (QuestionAnswerSaveRequest.QuestionAnswerItem item : request.questions()) { + if (item.questionId() == null) { + syncedQuestions.add(Question.create( + mockApply, + item.content().trim(), + questionDomainSupport.resolveCharLimit(item.charLimit()), + questionDomainSupport.normalizeAnswer(item.answer()) + )); + continue; + } + + if (!requestedQuestionIds.add(item.questionId())) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "중복된 문항 ID가 포함되어 있습니다. questionId=" + item.questionId() + ); + } + + Question existingQuestion = questionMap.get(item.questionId()); + if (existingQuestion == null) { + throw new GeneralException( + GeneralErrorCode.QUESTION_NOT_FOUND, + "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + item.questionId() + ); + } + + existingQuestion.updateContentLimitAndAnswer( + item.content().trim(), + questionDomainSupport.resolveCharLimit(item.charLimit()), + questionDomainSupport.normalizeAnswer(item.answer()) + ); + syncedQuestions.add(existingQuestion); + } + + List deletedQuestions = existingQuestions.stream() + .filter(question -> !requestedQuestionIds.contains(question.getId())) + .toList(); + questionRepository.deleteAll(deletedQuestions); + List savedQuestions = questionRepository.saveAll(syncedQuestions); + mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); + + return new QuestionAnswerResponse( + mockApply.getId(), + mockApply.getStatus(), + mockApplyRepository.calculateSequence(mockApply), + savedQuestions.stream().map(questionDomainSupport::toQuestionResponse).toList() + ); + } + + @Transactional + @AuditLogEvent(action = "QUESTION_DELETE", targetType = "MOCK_APPLY", targetId = "#arg1") + public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Long questionId) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + if (existingQuestions.size() <= 1) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); + } + + Question question = existingQuestions.stream() + .filter(existingQuestion -> existingQuestion.getId().equals(questionId)) + .findFirst() + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.QUESTION_NOT_FOUND, + "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + questionId + )); + + questionAnalysisRepository.deleteAllByQuestionId(question.getId()); + questionRepository.delete(question); + List remainingQuestions = existingQuestions.stream() + .filter(existingQuestion -> !existingQuestion.getId().equals(question.getId())) + .map(questionDomainSupport::toQuestionResponse) + .toList(); + + return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), remainingQuestions); + } + + private CustomQuestionCandidate findOrCreateCustomCandidate( + MockApply mockApply, + String content, + Integer charLimit + ) { + return customQuestionCandidateRepository + .findByMockApplyIdAndContent(mockApply.getId(), content) + .orElseGet(() -> saveCustomCandidate(mockApply, content, charLimit)); + } + + private CustomQuestionCandidate saveCustomCandidate( + MockApply mockApply, + String content, + Integer charLimit + ) { + try { + return customQuestionCandidateRepository.saveAndFlush(CustomQuestionCandidate.create( + mockApply, + content, + questionDomainSupport.resolveCharLimit(charLimit) + )); + } catch (DataIntegrityViolationException e) { + return customQuestionCandidateRepository + .findByMockApplyIdAndContent(mockApply.getId(), content) + .orElseThrow(() -> e); + } + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java new file mode 100644 index 00000000..d00a44c9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java @@ -0,0 +1,71 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +// 문항 도메인 공통 규칙과 지원서 접근 검증을 제공한다. +public class QuestionDomainSupport { + private static final int MIN_SELECTION_COUNT = 1; + private static final int MAX_SELECTION_COUNT = 5; + private static final int DEFAULT_CHAR_LIMIT = 1000; + + private final MockApplyRepository mockApplyRepository; + private final QuestionCandidateCatalogService questionCandidateCatalogService; + + public QuestionDomainSupport( + MockApplyRepository mockApplyRepository, + QuestionCandidateCatalogService questionCandidateCatalogService + ) { + this.mockApplyRepository = mockApplyRepository; + this.questionCandidateCatalogService = questionCandidateCatalogService; + } + + public MockApply getOwnedMockApply(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findById(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + + public void validateSelectionCount(int count) { + if (count < MIN_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); + } + if (count > MAX_SELECTION_COUNT) { + throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다."); + } + } + + public int resolveCharLimit(Integer charLimit) { + if (charLimit == null) { + return DEFAULT_CHAR_LIMIT; + } + return charLimit; + } + + public String normalizeAnswer(String answer) { + if (StringUtils.hasText(answer)) { + return answer; + } + return ""; + } + + public QuestionResponse toQuestionResponse(Question question) { + return QuestionResponse.from(question, questionCandidateCatalogService.isCustomQuestion(question.getContent())); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java new file mode 100644 index 00000000..9f16c53d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java @@ -0,0 +1,72 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +// 문항 후보/선택 목록 조회를 담당한다. +public class QuestionQueryService { + private final QuestionDomainSupport questionDomainSupport; + private final QuestionCandidateCatalogService questionCandidateCatalogService; + private final QuestionRepository questionRepository; + private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + + public QuestionQueryService( + QuestionDomainSupport questionDomainSupport, + QuestionCandidateCatalogService questionCandidateCatalogService, + QuestionRepository questionRepository, + CustomQuestionCandidateRepository customQuestionCandidateRepository + ) { + this.questionDomainSupport = questionDomainSupport; + this.questionCandidateCatalogService = questionCandidateCatalogService; + this.questionRepository = questionRepository; + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + } + + public List getQuestionCandidates(User user, Long mockApplyId) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + List selectedQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); + Set selectedContents = selectedQuestions.stream() + .map(Question::getContent) + .collect(Collectors.toSet()); + + List candidates = new ArrayList<>( + questionCandidateCatalogService.getDefaultCandidateResponses(selectedContents) + ); + + customQuestionCandidateRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() + .map(candidate -> new QuestionCandidateResponse( + candidate.getId(), + candidate.getContent(), + candidate.getLimit(), + selectedContents.contains(candidate.getContent()), + true + )) + .forEach(candidates::add); + + return candidates; + } + + public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { + MockApply mockApply = questionDomainSupport.getOwnedMockApply(user, mockApplyId); + List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() + .map(questionDomainSupport::toQuestionResponse) + .toList(); + + return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java index 7ba7502e..9342e378 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java @@ -1,326 +1,64 @@ package com.jobdri.jobdri_api.domain.analysis.service.question; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; -import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; -import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -// 분석 전 단계에서 문항 선택, 추가, 답변 저장을 담당하는 서비스다. +// 기존 QuestionService 진입점을 유지하는 호환용 파사드다. public class QuestionService { + private final QuestionQueryService questionQueryService; + private final QuestionCommandService questionCommandService; - private static final int MIN_SELECTION_COUNT = 1; - private static final int MAX_SELECTION_COUNT = 5; - private static final int DEFAULT_CHAR_LIMIT = 1000; - - private static final List DEFAULT_CANDIDATES = List.of( - new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), - new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), - new QuestionCandidate(3L, "문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000), - new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), - new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) - ); - - private final MockApplyRepository mockApplyRepository; - private final QuestionRepository questionRepository; - private final QuestionAnalysisRepository questionAnalysisRepository; - private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + public QuestionService( + QuestionQueryService questionQueryService, + QuestionCommandService questionCommandService + ) { + this.questionQueryService = questionQueryService; + this.questionCommandService = questionCommandService; + } public List getQuestionCandidates(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List selectedQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - Set selectedContents = selectedQuestions.stream() - .map(Question::getContent) - .collect(Collectors.toSet()); - - List candidates = new ArrayList<>(DEFAULT_CANDIDATES.stream() - .map(candidate -> new QuestionCandidateResponse( - candidate.id(), - candidate.content(), - candidate.charLimit(), - selectedContents.contains(candidate.content()), - false - )) - .toList()); - - customQuestionCandidateRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() - .map(candidate -> new QuestionCandidateResponse( - candidate.getId(), - candidate.getContent(), - candidate.getLimit(), - selectedContents.contains(candidate.getContent()), - true - )) - .forEach(candidates::add); - - return candidates; + return questionQueryService.getQuestionCandidates(user, mockApplyId); } - @Transactional - @AuditLogEvent(action = "CUSTOM_QUESTION_CANDIDATE_ADD", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionCandidateResponse addCustomQuestionCandidate( User user, Long mockApplyId, QuestionCandidateCreateRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - String content = request.content().trim(); - validateCustomCandidate(content); - - CustomQuestionCandidate candidate = findOrCreateCustomCandidate(mockApply, content, request.charLimit()); - boolean selected = questionRepository.existsByMockApplyIdAndContent(mockApply.getId(), candidate.getContent()); - - return new QuestionCandidateResponse( - candidate.getId(), - candidate.getContent(), - candidate.getLimit(), - selected, - true - ); - } - - private CustomQuestionCandidate findOrCreateCustomCandidate( - MockApply mockApply, - String content, - Integer charLimit - ) { - return customQuestionCandidateRepository - .findByMockApplyIdAndContent(mockApply.getId(), content) - .orElseGet(() -> saveCustomCandidate(mockApply, content, charLimit)); - } - - private CustomQuestionCandidate saveCustomCandidate( - MockApply mockApply, - String content, - Integer charLimit - ) { - try { - return customQuestionCandidateRepository.saveAndFlush(CustomQuestionCandidate.create( - mockApply, - content, - resolveCharLimit(charLimit) - )); - } catch (DataIntegrityViolationException e) { - return customQuestionCandidateRepository - .findByMockApplyIdAndContent(mockApply.getId(), content) - .orElseThrow(() -> e); - } + return questionCommandService.addCustomQuestionCandidate(user, mockApplyId, request); } public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream() - .map(this::toQuestionResponse) - .toList(); - - return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions); + return questionQueryService.getSelectedQuestions(user, mockApplyId); } - @Transactional - @AuditLogEvent(action = "QUESTION_SELECTION_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionSelectionResponse saveSelectedQuestions( User user, Long mockApplyId, QuestionSelectionSaveRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - validateSelectionCount(request.questions().size()); - - List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); - questionRepository.deleteAll(existingQuestions); - - List questions = request.questions().stream() - .map(item -> Question.create( - mockApply, - item.content().trim(), - resolveCharLimit(item.charLimit()), - "" - )) - .toList(); - List savedQuestions = questionRepository.saveAll(questions); - mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); - - return new QuestionSelectionResponse( - mockApply.getId(), - mockApply.getStatus(), - savedQuestions.stream().map(this::toQuestionResponse).toList() - ); + return questionCommandService.saveSelectedQuestions(user, mockApplyId, request); } - @Transactional - @AuditLogEvent(action = "QUESTION_ANSWER_SAVE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionAnswerResponse saveAnswers( User user, Long mockApplyId, QuestionAnswerSaveRequest request ) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - validateSelectionCount(request.questions().size()); - - List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - Map questionMap = existingQuestions.stream() - .collect(Collectors.toMap(Question::getId, Function.identity())); - Set requestedQuestionIds = new HashSet<>(); - List syncedQuestions = new ArrayList<>(); - - for (QuestionAnswerSaveRequest.QuestionAnswerItem item : request.questions()) { - if (item.questionId() == null) { - syncedQuestions.add(Question.create( - mockApply, - item.content().trim(), - resolveCharLimit(item.charLimit()), - normalizeAnswer(item.answer()) - )); - continue; - } - - if (!requestedQuestionIds.add(item.questionId())) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "중복된 문항 ID가 포함되어 있습니다. questionId=" + item.questionId() - ); - } - - Question existingQuestion = questionMap.get(item.questionId()); - if (existingQuestion == null) { - throw new GeneralException( - GeneralErrorCode.QUESTION_NOT_FOUND, - "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + item.questionId() - ); - } - - existingQuestion.updateContentLimitAndAnswer( - item.content().trim(), - resolveCharLimit(item.charLimit()), - normalizeAnswer(item.answer()) - ); - syncedQuestions.add(existingQuestion); - } - - List deletedQuestions = existingQuestions.stream() - .filter(question -> !requestedQuestionIds.contains(question.getId())) - .toList(); - questionRepository.deleteAll(deletedQuestions); - List savedQuestions = questionRepository.saveAll(syncedQuestions); - mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); - - return new QuestionAnswerResponse( - mockApply.getId(), - mockApply.getStatus(), - mockApplyRepository.calculateSequence(mockApply), - savedQuestions.stream().map(this::toQuestionResponse).toList() - ); + return questionCommandService.saveAnswers(user, mockApplyId, request); } - @Transactional - @AuditLogEvent(action = "QUESTION_DELETE", targetType = "MOCK_APPLY", targetId = "#arg1") public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Long questionId) { - MockApply mockApply = getOwnedMockApply(user, mockApplyId); - List existingQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()); - if (existingQuestions.size() <= MIN_SELECTION_COUNT) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); - } - - Question question = existingQuestions.stream() - .filter(existingQuestion -> existingQuestion.getId().equals(questionId)) - .findFirst() - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.QUESTION_NOT_FOUND, - "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + questionId - )); - - questionAnalysisRepository.deleteAllByQuestionId(question.getId()); - questionRepository.delete(question); - List remainingQuestions = existingQuestions.stream() - .filter(existingQuestion -> !existingQuestion.getId().equals(question.getId())) - .map(this::toQuestionResponse) - .toList(); - - return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), remainingQuestions); - } - - private QuestionResponse toQuestionResponse(Question question) { - return QuestionResponse.from(question, isCustomQuestion(question.getContent())); - } - - private MockApply getOwnedMockApply(User user, Long mockApplyId) { - MockApply mockApply = mockApplyRepository.findById(mockApplyId) - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.MOCK_APPLY_NOT_FOUND, - "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId - )); - - if (!mockApply.getUser().getId().equals(user.getId())) { - throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); - } - - return mockApply; - } - - private void validateSelectionCount(int count) { - if (count < MIN_SELECTION_COUNT) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다."); - } - if (count > MAX_SELECTION_COUNT) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다."); - } - } - - private int resolveCharLimit(Integer charLimit) { - if (charLimit == null) { - return DEFAULT_CHAR_LIMIT; - } - return charLimit; - } - - private boolean isCustomQuestion(String content) { - return DEFAULT_CANDIDATES.stream() - .noneMatch(candidate -> candidate.content().equals(content)); - } - - private void validateCustomCandidate(String content) { - if (!isCustomQuestion(content)) { - throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미 기본 후보에 존재하는 문항입니다."); - } - } - - private String normalizeAnswer(String answer) { - if (StringUtils.hasText(answer)) { - return answer; - } - return ""; - } - - private record QuestionCandidate(Long id, String content, int charLimit) { + return questionCommandService.deleteQuestion(user, mockApplyId, questionId); } } From 9a0a9876dd803031f54974471c96a665847711b1 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:19:36 +0900 Subject: [PATCH 26/55] =?UTF-8?q?[Refactor]=20analysis=20dto=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EC=9E=AC=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/AnalysisGenerator.java | 2 +- .../AnalysisWorkerInternalController.java | 14 +++++++------- .../llm/AnalysisCandidateResponse.java | 2 +- .../{ => external}/llm/AnalysisLlmResponse.java | 2 +- .../llm/CandidateRecheckResponse.java | 2 +- .../llm/CandidateReviewResponse.java | 2 +- .../criteria/JobCategoryEvaluationCriteria.java | 2 +- .../worker/AnalysisTaskMessage.java | 2 +- .../worker/AnalysisWorkerCompleteRequest.java | 4 ++-- .../worker/AnalysisWorkerContextRequest.java | 2 +- .../worker/AnalysisWorkerContextResponse.java | 2 +- .../worker/AnalysisWorkerFailureRequest.java | 2 +- .../worker/AnalysisWorkerResultStoreRequest.java | 4 ++-- .../worker/AnalysisWorkerRetryRequest.java | 2 +- .../worker/AnalysisWorkerRunningRequest.java | 2 +- .../worker/CorpusReferenceContext.java | 2 +- .../worker/SimilarJobPostingContext.java | 2 +- .../EvaluationAnalysisBatchService.java | 4 ++-- .../MissingKeywordSanitizerReplayService.java | 2 +- .../evaluation/NlgEvaluationBatchService.java | 2 +- .../async/AnalysisAsyncQueueProcessor.java | 2 +- .../async/AnalysisAsyncWorkerBridge.java | 10 +++++----- .../analysis/service/ai/AnalysisAiClient.java | 14 +++++++------- .../analysis/service/ai/AnalysisAiGenerator.java | 2 +- .../service/ai/AnalysisPromptBuilder.java | 6 +++--- .../service/ai/AnalysisResponseParser.java | 2 +- .../JobCategoryEvaluationCriteriaProvider.java | 2 +- .../async/AnalysisTaskMessagePublisher.java | 2 +- .../service/core/AnalysisExecutionPayload.java | 4 ++-- .../core/AnalysisInputFingerprintProvider.java | 4 ++-- .../service/core/AnalysisPreparationResult.java | 4 ++-- .../service/core/AnalysisPreparationService.java | 4 ++-- .../core/AnalysisResultPersistenceService.java | 2 +- .../analysis/service/core/AnalysisService.java | 4 ++-- .../retrieval/JobPostingRagContextAssembler.java | 2 +- .../MissingKeywordSanitizationDecision.java | 2 +- .../MissingKeywordSanitizationResult.java | 2 +- .../sanitization/MissingKeywordSanitizer.java | 2 +- .../AnalysisWorkerContextResponseTest.java | 2 +- .../worker/CorpusReferenceContextTest.java | 2 +- .../EvaluationAnalysisBatchServiceTest.java | 6 +++--- .../EvaluationAnalysisRunnerSafetyTest.java | 2 +- ...MissingKeywordSanitizerReplayServiceTest.java | 2 +- .../NlgEvaluationBatchServiceTest.java | 2 +- .../service/ai/AnalysisAiClientTest.java | 14 +++++++------- ...obCategoryEvaluationCriteriaProviderTest.java | 2 +- .../async/AnalysisWorkerBridgeServiceTest.java | 16 ++++++++-------- .../AnalysisInputFingerprintProviderTest.java | 2 +- .../service/core/AnalysisServiceTest.java | 4 ++-- .../JobPostingRagContextAssemblerTest.java | 2 +- .../MissingKeywordSanitizerTest.java | 2 +- 51 files changed, 93 insertions(+), 93 deletions(-) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => external}/llm/AnalysisCandidateResponse.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => external}/llm/AnalysisLlmResponse.java (95%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => external}/llm/CandidateRecheckResponse.java (90%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => external}/llm/CandidateReviewResponse.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/criteria/JobCategoryEvaluationCriteria.java (85%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisTaskMessage.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerCompleteRequest.java (71%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerContextRequest.java (78%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerContextResponse.java (97%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerFailureRequest.java (87%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerResultStoreRequest.java (63%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerRetryRequest.java (87%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerRunningRequest.java (79%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/CorpusReferenceContext.java (98%) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/SimilarJobPostingContext.java (81%) rename src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/AnalysisWorkerContextResponseTest.java (98%) rename src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/{ => internal}/worker/CorpusReferenceContextTest.java (98%) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java index 44a90372..75542d05 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.application.port; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; public interface AnalysisGenerator { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java index 901c23de..4cbc916a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -2,13 +2,13 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerFailureRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRetryRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerRunningRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerFailureRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerRetryRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerRunningRequest; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java index 99ee4266..907547bd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisCandidateResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java index 91d7c36e..a28c8a0b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/AnalysisLlmResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/AnalysisLlmResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java similarity index 90% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java index 722be6de..e33d0661 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateRecheckResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateRecheckResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; public record CandidateRecheckResponse( RecheckDecision decision, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java index 8f062d04..71a305e7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/llm/CandidateReviewResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/external/llm/CandidateReviewResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.llm; +package com.jobdri.jobdri_api.domain.analysis.dto.external.llm; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java similarity index 85% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java index 15733c57..e00da80c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/criteria/JobCategoryEvaluationCriteria.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/criteria/JobCategoryEvaluationCriteria.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.criteria; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisTaskMessage.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisTaskMessage.java index f8910e15..81a34b17 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisTaskMessage.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisTaskMessage.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; import lombok.Builder; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerCompleteRequest.java similarity index 71% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerCompleteRequest.java index 32eb1fd8..856c3bb7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerCompleteRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerCompleteRequest.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextRequest.java similarity index 78% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextRequest.java index 0ec7a04b..19526389 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponse.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponse.java index 1c7db5c2..7e507f04 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java index 1c9d9023..749040ce 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import jakarta.validation.constraints.Min; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java index 01e76b3c..6b213bc1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerResultStoreRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerResultStoreRequest.java @@ -1,6 +1,6 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java similarity index 87% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java index b8448db9..e12be1f3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import jakarta.validation.constraints.Min; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRunningRequest.java similarity index 79% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRunningRequest.java index 08ecd1fe..52f2ed3d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerRunningRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRunningRequest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java index a4873c3d..5d9ff93a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContext.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContext.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java index 40dd252f..cb8ec0cc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/SimilarJobPostingContext.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/SimilarJobPostingContext.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; public record SimilarJobPostingContext( Long jobPostingId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index baaf19a0..4b7fc8f6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java index 5cfcd7b4..65cd03cd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationDecision; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java index 85af0d91..af1d278a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java index a92ce857..3cc9377d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncQueueProcessor.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisTaskMessage; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisTaskMessagePublisher; // 분석 비동기 작업을 MQ 워커 메시지로 변환해 발행한다. diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index f7394044..27dd03b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -2,12 +2,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerContextResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index a50386cb..a4ec58c3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -2,13 +2,13 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse.RecheckDecision; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse.RecheckDecision; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse.RejectionCode; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java index 99d5646b..95be2539 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java index 7f0e1e44..de8becb6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.policy.AnalysisPromptPolicy; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; @@ -402,7 +402,7 @@ String buildRecheckPrompt( RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, AnalysisCandidateResponse candidates, - com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse reviewResponse + com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse reviewResponse ) { String questionText = formatQuestions(promptInput); String candidateJson = writeJson(candidates); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java index ed6cb1d2..0ea23271 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java index ef571578..6cc501ff 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProvider.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java index 5f366e11..2399481c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisTaskMessagePublisher.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisTaskMessage; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisTaskMessage; import com.jobdri.jobdri_api.global.apiPayload.code.BaseErrorCode; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java index 6b62e49d..fbf9e8e2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index 4992db01..d8fba32a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.CorpusReferenceContext; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java index 3c8b354b..331393fe 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java index b375fdca..45440697 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java index 683247cf..538faecf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 057004c3..2b368b27 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java index 91893af0..9c296866 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssembler.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.retrieval; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java index b3722e57..3c7b6169 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationDecision.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; public record MissingKeywordSanitizationDecision( int candidateIndex, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java index 6b5b7bc5..12406ddc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizationResult.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java index 6ce3bd04..8e6e9ecb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizer.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import org.springframework.util.StringUtils; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java index 5c44861c..ab30ecf2 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/AnalysisWorkerContextResponseTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerContextResponseTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java index bd45bc44..2484d37f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/worker/CorpusReferenceContextTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/CorpusReferenceContextTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.dto.worker; +package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java index 7c358441..8d2b681b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.evaluation; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java index 423f3b5a..a4999e81 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusAdminRunner; import com.jobdri.jobdri_api.global.scheduling.AsyncTaskSweepScheduler; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.DefaultApplicationArguments; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java index 8d90ffd2..b2945829 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.evaluation; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java index dbcbe468..7eb3ac8b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.evaluation; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 439bc0ec..540b1a19 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateRecheckResponse.RecheckDecision; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.CandidateReviewResponse.RejectionCode; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse.RecheckDecision; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse.RejectionCode; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java index 31e58bbc..e7f6e20b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/JobCategoryEvaluationCriteriaProviderTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.criteria.JobCategoryEvaluationCriteria; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index d41cab02..4ef127fd 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -1,11 +1,11 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerCompleteRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.AnalysisWorkerResultStoreRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.Question; @@ -406,7 +406,7 @@ void completeTaskRejectsMismatchedIdentity() { AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( 2L, 11L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class), + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class), "worker-1", 10L ); @@ -424,7 +424,7 @@ void storeGeneratedResultPersistsPayload() { AnalysisWorkerResultStoreRequest request = new AnalysisWorkerResultStoreRequest( 1L, 10L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class) + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class) ); analysisWorkerBridgeService.storeGeneratedResult(task.getTaskId(), request); @@ -442,7 +442,7 @@ void storeGeneratedResultAllowsTerminalTask() { AnalysisWorkerResultStoreRequest request = new AnalysisWorkerResultStoreRequest( 1L, 10L, - mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class) + mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class) ); analysisWorkerBridgeService.storeGeneratedResult(task.getTaskId(), request); @@ -457,7 +457,7 @@ void completeTaskMarksDeliveredForSucceededTask() { task.markSuccess(); User user = User.signup("테스트 사용자", "analysis-complete@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - var llmResponse = mock(com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse.class); + var llmResponse = mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class); when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java index 60806622..36256183 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index d26e43fc..ac404cb9 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -1,8 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java index 3b264fdc..38c33550 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/retrieval/JobPostingRagContextAssemblerTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.retrieval; -import com.jobdri.jobdri_api.domain.analysis.dto.worker.SimilarJobPostingContext; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java index a987a2cb..8439e656 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/sanitization/MissingKeywordSanitizerTest.java @@ -1,6 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From cfc88f4513e626741cd7a6b66bcd116dc7e6ab32 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:22:58 +0900 Subject: [PATCH 27/55] =?UTF-8?q?[Refactor]=20analysis=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=83=80=EC=9E=85=EC=9D=84=20domain/type=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/AnalysisWorkerFailureRequest.java | 4 +- .../worker/AnalysisWorkerRetryRequest.java | 4 +- .../response/QuestionAnalysisResponse.java | 2 +- .../analysis/entity/AnalysisAsyncTask.java | 63 +++++++------------ .../analysis/entity/QuestionAnalysis.java | 1 + .../EvaluationAnalysisBatchService.java | 2 +- .../AnalysisAsyncTaskSweepCoordinator.java | 22 +++---- .../async/AnalysisAsyncWorkerBridge.java | 30 ++++----- .../AnalysisAsyncTaskRepository.java | 8 +-- .../analysis/service/ai/AnalysisAiClient.java | 2 +- .../service/ai/AnalysisResponseParser.java | 2 +- .../async/AnalysisAsyncSseService.java | 6 +- .../async/AnalysisAsyncTaskService.java | 32 +++++----- .../AnalysisResultPersistenceService.java | 2 +- .../type/AnalysisAsyncCreditStatus.java | 8 +++ .../type/AnalysisAsyncFailureReason.java | 9 +++ .../type/AnalysisAsyncTaskStatus.java | 9 +++ .../QuestionAnalysisStatus.java | 2 +- .../JobPostingWorkerFailureRequest.java | 4 +- .../worker/JobPostingWorkerRetryRequest.java | 4 +- .../entity/JobPostingAsyncTask.java | 30 ++++----- .../JobPostingAsyncTaskRepository.java | 4 +- .../service/JobPostingAsyncSseService.java | 6 +- .../service/JobPostingAsyncTaskService.java | 38 +++++------ .../JobPostingWorkerBridgeService.java | 24 +++---- .../mockapply/service/MockApplyService.java | 4 +- .../async/AnalysisAsyncFacadeServiceTest.java | 11 ++-- .../AnalysisWorkerBridgeServiceTest.java | 14 ++--- .../service/core/AnalysisServiceTest.java | 2 +- .../service/question/QuestionServiceTest.java | 2 +- .../JobPostingAsyncTaskServiceTest.java | 34 +++++----- .../service/JobPostingServiceTest.java | 2 +- .../service/MockApplyServiceTest.java | 5 +- 33 files changed, 202 insertions(+), 190 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java rename src/main/java/com/jobdri/jobdri_api/domain/analysis/{entity => type}/QuestionAnalysisStatus.java (63%) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java index 749040ce..083a1c74 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerFailureRequest.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record AnalysisWorkerFailureRequest( @NotBlank String errorMessage, - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, Long queueLatencyMillis diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java index e12be1f3..b0f0312b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/internal/worker/AnalysisWorkerRetryRequest.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.dto.internal.worker; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record AnalysisWorkerRetryRequest( @NotBlank String errorMessage, - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @Min(0) int retryCount, @NotBlank String workerId, Long queueLatencyMillis diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java index 92a9e929..1611cc4e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionAnalysisResponse.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.response; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; public record QuestionAnalysisResponse( Long questionAnalysisId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 42cb8b5c..be9041f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -1,5 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.entity; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.entity.CreatedAtEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -37,11 +40,11 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(name = "credit_status", nullable = false, length = 20) - private CreditStatus creditStatus; + private AnalysisAsyncCreditStatus creditStatus; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - private TaskStatus status; + private AnalysisAsyncTaskStatus status; @Column(nullable = false, length = 255) private String message; @@ -51,7 +54,7 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(name = "failure_reason", length = 40) - private FailureReason failureReason; + private AnalysisAsyncFailureReason failureReason; @Column(name = "worker_id", length = 100) private String workerId; @@ -103,8 +106,8 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe task.taskId = UUID.randomUUID().toString(); task.userId = userId; task.mockApplyId = mockApplyId; - task.creditStatus = CreditStatus.NONE; - task.status = TaskStatus.PENDING; + task.creditStatus = AnalysisAsyncCreditStatus.NONE; + task.status = AnalysisAsyncTaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); @@ -117,22 +120,22 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe public void markCreditReserved(String creditReferenceId) { this.creditReferenceId = creditReferenceId; - this.creditStatus = CreditStatus.RESERVED; + this.creditStatus = AnalysisAsyncCreditStatus.RESERVED; } public void markCreditConfirmed() { - this.creditStatus = CreditStatus.CONFIRMED; + this.creditStatus = AnalysisAsyncCreditStatus.CONFIRMED; } public void markCreditReleased() { - this.creditStatus = CreditStatus.RELEASED; + this.creditStatus = AnalysisAsyncCreditStatus.RELEASED; } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { if (isTerminal()) { return; } - this.status = TaskStatus.RUNNING; + this.status = AnalysisAsyncTaskStatus.RUNNING; this.message = "자소서 분석을 진행 중입니다."; this.currentStep = "PREPARING_CONTEXT"; this.progressPercent = Math.max(resolveProgressPercent(), 10); @@ -151,7 +154,7 @@ public void markSuccess() { if (isTerminal()) { return; } - this.status = TaskStatus.SUCCEEDED; + this.status = AnalysisAsyncTaskStatus.SUCCEEDED; this.message = "자소서 분석이 완료되었습니다."; this.error = null; this.failureReason = null; @@ -161,7 +164,7 @@ public void markSuccess() { this.estimatedRemainingSeconds = 0; } - public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } @@ -169,7 +172,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, markFailed(failureReason, errorMessage, retryCount); return; } - this.status = TaskStatus.PENDING; + this.status = AnalysisAsyncTaskStatus.PENDING; this.message = "자소서 분석 재시도를 대기 중입니다."; this.currentStep = "VALIDATING_INPUT"; this.progressPercent = 0; @@ -179,11 +182,11 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, this.completedAt = null; } - public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } - this.status = TaskStatus.FAILED; + this.status = AnalysisAsyncTaskStatus.FAILED; this.message = "자소서 분석에 실패했습니다."; this.failureReason = failureReason; this.error = errorMessage; @@ -194,17 +197,17 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret } public void requestCancel() { - if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + if (status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED) { return; } this.cancelRequested = true; - if (status == TaskStatus.CANCELLED) { + if (status == AnalysisAsyncTaskStatus.CANCELLED) { if (cancelledAt == null) { this.cancelledAt = LocalDateTime.now(); } return; } - this.status = TaskStatus.CANCELLED; + this.status = AnalysisAsyncTaskStatus.CANCELLED; this.message = "자소서 분석 작업이 취소되었습니다."; this.error = null; this.failureReason = null; @@ -232,33 +235,13 @@ public void captureExecutionSnapshot(String executionContextSnapshot, String inp } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + return status == AnalysisAsyncTaskStatus.SUCCEEDED + || status == AnalysisAsyncTaskStatus.FAILED + || status == AnalysisAsyncTaskStatus.CANCELLED; } private int resolveProgressPercent() { return progressPercent == null ? 0 : progressPercent; } - public enum TaskStatus { - PENDING, - RUNNING, - SUCCEEDED, - FAILED, - CANCELLED - } - - public enum CreditStatus { - NONE, - RESERVED, - CONFIRMED, - RELEASED - } - - public enum FailureReason { - RATE_LIMIT, - QUEUE_TIMEOUT, - OPENAI_TIMEOUT, - VALIDATION_ERROR, - INTERNAL_ERROR - } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java index 1e5fc38c..2b7a02d6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysis.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.entity; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java index 4b7fc8f6..7f08546a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java @@ -6,7 +6,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java index b873a4a5..026b07b0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -1,9 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; @@ -45,7 +45,7 @@ public AnalysisAsyncTaskSweepCoordinator( public int sweepTimedOutTasks() { int expiredCount = 0; - for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING))) { try { expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); } catch (RuntimeException e) { @@ -57,7 +57,7 @@ public int sweepTimedOutTasks() { private int sweepTimedOutTask(String taskId) { AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); - if (task == null || task.getStatus() == TaskStatus.SUCCEEDED || task.getStatus() == TaskStatus.FAILED) { + if (task == null || task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED || task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { return 0; } @@ -78,19 +78,19 @@ private int sweepTimedOutTask(String taskId) { private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { LocalDateTime now = LocalDateTime.now(); - if (task.getStatus() == TaskStatus.PENDING + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { return new ExpirationDecision( - FailureReason.QUEUE_TIMEOUT, + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다." ); } LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); - if (task.getStatus() == TaskStatus.RUNNING + if (task.getStatus() == AnalysisAsyncTaskStatus.RUNNING && isExpired(lastActivityAt, now, analysisQueueProperties.getProcessingTimeoutSeconds())) { return new ExpirationDecision( - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "자소서 분석 작업이 처리 제한 시간을 초과했습니다." ); } @@ -106,7 +106,7 @@ private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeou } private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } @@ -115,6 +115,6 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { analysisAsyncTaskService.markCreditReleased(task.getTaskId()); } - private record ExpirationDecision(FailureReason failureReason, String errorMessage) { + private record ExpirationDecision(AnalysisAsyncFailureReason failureReason, String errorMessage) { } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index 27dd03b1..88dc494c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -9,9 +9,9 @@ import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; @@ -83,7 +83,7 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public void markRetry( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -103,7 +103,7 @@ public void markRetry( @Transactional public void failTask( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -170,7 +170,7 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques "자소서 분석 worker 완료 요청 정보가 작업 정보와 일치하지 않습니다." ); } - if (task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { workerTaskResultService.markDeliveryFailedIfPresent( TaskType.ANALYSIS_COMPLETE, taskId, @@ -186,11 +186,11 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques taskId, new AnalysisWorkerResultStoreRequest(request.userId(), request.mockApplyId(), request.llmResponse()) ); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { workerTaskResultService.markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, taskId); return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); } - if (task.getStatus() == TaskStatus.FAILED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { workerTaskResultService.markDeliveryFailedIfPresent( TaskType.ANALYSIS_COMPLETE, taskId, @@ -307,19 +307,19 @@ private AnalysisAsyncTask getTask(String taskId) { } private boolean isTerminal(AnalysisAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; } private void rejectIfCancelled(AnalysisAsyncTask task, String message) { - if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED || task.isCancelRequested()) { throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); } } private void reserveCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.NONE) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.NONE) { return; } @@ -330,14 +330,14 @@ private void reserveCreditIfNeeded(AnalysisAsyncTask task) { } private void confirmCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); } private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } User user = userService.getUser(task.getUserId()); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index 4a849e68..ec43ed7b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.repository; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -14,14 +14,14 @@ public interface AnalysisAsyncTaskRepository extends JpaRepository findFirstByUserIdAndMockApplyIdAndStatusInOrderByCreatedAtDesc( Long userId, Long mockApplyId, - Collection statuses + Collection statuses ); List findByUserIdAndMockApplyIdInAndStatusIn( Long userId, Collection mockApplyIds, - Collection statuses + Collection statuses ); - List findByStatusIn(Collection statuses); + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index a4ec58c3..4fdba4e4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -11,7 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java index 0ea23271..7f4f024c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisResponseParser.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import com.openai.models.responses.StructuredResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java index eb770598..906b14bf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSseService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,7 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(AnalysisAsyncStatusResponse statusResponse) { - TaskStatus status = TaskStatus.valueOf(statusResponse.status()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + AnalysisAsyncTaskStatus status = AnalysisAsyncTaskStatus.valueOf(statusResponse.status()); + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index b84c2868..38f43ba2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -5,9 +5,9 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisProgressStepResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.CreditStatus; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; @@ -78,7 +78,7 @@ public Optional findActiveTask(Long userId, Long mockApplyId) return analysisAsyncTaskRepository.findFirstByUserIdAndMockApplyIdAndStatusInOrderByCreatedAtDesc( userId, mockApplyId, - EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING) + EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING) ); } @@ -95,16 +95,16 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public void markSuccess(String taskId, AnalysisResponse result) { AnalysisAsyncTask task = getTask(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { return; } - if (task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "취소된 자소서 분석 비동기 작업입니다. taskId=" + taskId ); } - if (task.getStatus() == TaskStatus.FAILED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId @@ -117,7 +117,7 @@ public void markSuccess(String taskId, AnalysisResponse result) { } @Transactional - public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); recordProcessingMetric(task, "retry"); task.markRetryScheduled(failureReason, errorMessage, retryCount); @@ -125,7 +125,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin } @Transactional - public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { AnalysisAsyncTask task = getTask(taskId); recordProcessingMetric(task, "failed"); task.markFailed(failureReason, errorMessage, retryCount); @@ -144,11 +144,11 @@ public AnalysisAsyncCancelResponse cancelTask(Long userId, Long mockApplyId, Str throw new GeneralException(GeneralErrorCode.FORBIDDEN, "요청한 mockApplyId와 작업 정보가 일치하지 않습니다."); } - TaskStatus previousStatus = task.getStatus(); + AnalysisAsyncTaskStatus previousStatus = task.getStatus(); LocalDateTime previousCancelledAt = task.getCancelledAt(); task.requestCancel(); - boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; - boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + boolean cancelled = task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != AnalysisAsyncTaskStatus.CANCELLED && cancelled; if (newlyCancelled) { releaseCreditIfNeeded(task); recordProcessingMetric(task, "cancelled"); @@ -184,7 +184,7 @@ public void markCreditReleased(String taskId) { } @Transactional(readOnly = true) - public CreditStatus getCreditStatus(String taskId) { + public AnalysisAsyncCreditStatus getCreditStatus(String taskId) { return getTask(taskId).getCreditStatus(); } @@ -251,12 +251,12 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task, Ana DEFAULT_ESTIMATED_REMAINING_SECONDS )) .steps(buildSteps(task)) - .result(task.getStatus() == TaskStatus.SUCCEEDED ? result : null) + .result(task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED ? result : null) .build(); } private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != CreditStatus.RESERVED || task.getCreditReferenceId() == null) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { return; } User user = userService.getUser(task.getUserId()); @@ -281,7 +281,7 @@ private List buildSteps(AnalysisAsyncTask task) { ); } - private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + private AsyncTaskProgressStatus toProgressStatus(AnalysisAsyncTaskStatus status) { return AsyncTaskProgressStatus.valueOf(status.name()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java index 538faecf..08b68f1a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -13,7 +13,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java new file mode 100644 index 00000000..897c69a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncCreditStatus.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncCreditStatus { + NONE, + RESERVED, + CONFIRMED, + RELEASED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java new file mode 100644 index 00000000..31c53aa7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncFailureReason { + RATE_LIMIT, + QUEUE_TIMEOUT, + OPENAI_TIMEOUT, + VALIDATION_ERROR, + INTERNAL_ERROR +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java new file mode 100644 index 00000000..941ed43e --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncTaskStatus.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.analysis.type; + +public enum AnalysisAsyncTaskStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java similarity index 63% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java index 2c99de5a..8fa11f56 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/QuestionAnalysisStatus.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/QuestionAnalysisStatus.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.entity; +package com.jobdri.jobdri_api.domain.analysis.type; public enum QuestionAnalysisStatus { PROVEN, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java index 3dc9a3c2..e672bff6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerFailureRequest.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record JobPostingWorkerFailureRequest( - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, @NotBlank @Size(max = 100) String workerId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java index 99e3ec15..ba24fdc0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/worker/JobPostingWorkerRetryRequest.java @@ -1,13 +1,13 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.worker; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record JobPostingWorkerRetryRequest( - @NotNull FailureReason failureReason, + @NotNull AnalysisAsyncFailureReason failureReason, @NotBlank String errorMessage, @Min(0) int retryCount, @NotBlank @Size(max = 100) String workerId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java index e1817253..1a84a1c9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/entity/JobPostingAsyncTask.java @@ -32,7 +32,7 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - private TaskStatus status; + private AnalysisAsyncTaskStatus status; @Column(nullable = false, length = 255) private String message; @@ -42,7 +42,7 @@ public class JobPostingAsyncTask extends CreatedAtEntity { @Enumerated(EnumType.STRING) @Column(name = "failure_reason", length = 40) - private FailureReason failureReason; + private AnalysisAsyncFailureReason failureReason; @Column(name = "worker_id", length = 100) private String workerId; @@ -91,7 +91,7 @@ public static JobPostingAsyncTask pending(Long userId, int maxRetryCount) { JobPostingAsyncTask task = new JobPostingAsyncTask(); task.taskId = UUID.randomUUID().toString(); task.userId = userId; - task.status = TaskStatus.PENDING; + task.status = AnalysisAsyncTaskStatus.PENDING; task.message = "채용 공고 비동기 작업이 접수되었습니다."; task.retryCount = 0; task.maxRetryCount = Math.max(0, maxRetryCount); @@ -106,7 +106,7 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte if (isTerminal()) { return; } - this.status = TaskStatus.RUNNING; + this.status = AnalysisAsyncTaskStatus.RUNNING; this.message = "채용 공고 비동기 처리를 진행 중입니다."; this.currentStep = "EXTRACTING_CONTENT"; this.progressPercent = Math.max(resolveProgressPercent(), 20); @@ -125,7 +125,7 @@ public void markSuccess(String resultPayload) { if (isTerminal()) { return; } - this.status = TaskStatus.SUCCEEDED; + this.status = AnalysisAsyncTaskStatus.SUCCEEDED; this.message = "채용 공고 비동기 처리에 성공했습니다."; this.error = null; this.failureReason = null; @@ -136,7 +136,7 @@ public void markSuccess(String resultPayload) { this.estimatedRemainingSeconds = 0; } - public void markRetryScheduled(FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } @@ -144,7 +144,7 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, markFailed(failureReason, errorMessage, retryCount); return; } - this.status = TaskStatus.PENDING; + this.status = AnalysisAsyncTaskStatus.PENDING; this.message = "채용 공고 비동기 재시도를 대기 중입니다."; this.currentStep = "VALIDATING_INPUT"; this.progressPercent = 0; @@ -154,11 +154,11 @@ public void markRetryScheduled(FailureReason failureReason, String errorMessage, this.completedAt = null; } - public void markFailed(FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; } - this.status = TaskStatus.FAILED; + this.status = AnalysisAsyncTaskStatus.FAILED; this.message = "채용 공고 비동기 처리에 실패했습니다."; this.failureReason = failureReason; this.error = errorMessage; @@ -169,17 +169,17 @@ public void markFailed(FailureReason failureReason, String errorMessage, int ret } public void requestCancel() { - if (status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED) { + if (status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED) { return; } this.cancelRequested = true; - if (status == TaskStatus.CANCELLED) { + if (status == AnalysisAsyncTaskStatus.CANCELLED) { if (cancelledAt == null) { this.cancelledAt = LocalDateTime.now(); } return; } - this.status = TaskStatus.CANCELLED; + this.status = AnalysisAsyncTaskStatus.CANCELLED; this.message = "채용 공고 비동기 작업이 취소되었습니다."; this.error = null; this.failureReason = null; @@ -199,14 +199,14 @@ public void updateWorkerMetadata(String workerId, Long queueLatencyMillis) { } private boolean isTerminal() { - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } private int resolveProgressPercent() { return progressPercent == null ? 0 : progressPercent; } - public enum FailureReason { + public enum AnalysisAsyncFailureReason { RATE_LIMIT, QUEUE_TIMEOUT, WORKER_TIMEOUT, @@ -215,7 +215,7 @@ public enum FailureReason { INTERNAL_ERROR } - public enum TaskStatus { + public enum AnalysisAsyncTaskStatus { PENDING, RUNNING, SUCCEEDED, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java index 5deefb5a..22f9bbaa 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingAsyncTaskRepository.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -11,5 +11,5 @@ public interface JobPostingAsyncTaskRepository extends JpaRepository { Optional findByTaskIdAndUserId(String taskId, Long userId); - List findByStatusIn(Collection statuses); + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java index ec18f267..6b6bc38f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncSseService.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.service; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingAsyncStatusResponse; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.global.sse.SseSubscriptionRegistry; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -40,7 +40,7 @@ private String channelKey(String taskId) { } private boolean isTerminal(JobPostingAsyncStatusResponse statusResponse) { - TaskStatus status = TaskStatus.valueOf(statusResponse.getStatus()); - return status == TaskStatus.SUCCEEDED || status == TaskStatus.FAILED || status == TaskStatus.CANCELLED; + AnalysisAsyncTaskStatus status = AnalysisAsyncTaskStatus.valueOf(statusResponse.getStatus()); + return status == AnalysisAsyncTaskStatus.SUCCEEDED || status == AnalysisAsyncTaskStatus.FAILED || status == AnalysisAsyncTaskStatus.CANCELLED; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java index ab9c46c6..62fc53c2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskService.java @@ -5,8 +5,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingProgressStepResponse; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; @@ -86,16 +86,16 @@ public void markRunning(String taskId, String workerId, int retryCount, Instant @Transactional public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestResponse result) { JobPostingAsyncTask task = getTaskState(taskId); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { return deserializeResult(task.getResultPayload()); } - if (task.getStatus() == TaskStatus.FAILED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); } - if (task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId @@ -109,7 +109,7 @@ public JobPostingIngestResponse markSuccess(String taskId, JobPostingIngestRespo } @Transactional - public void markRetryScheduled(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markRetryScheduled(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; @@ -120,7 +120,7 @@ public void markRetryScheduled(String taskId, FailureReason failureReason, Strin } @Transactional - public void markFailed(String taskId, FailureReason failureReason, String errorMessage, int retryCount) { + public void markFailed(String taskId, AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { JobPostingAsyncTask task = getTaskState(taskId); if (isTerminal(task)) { return; @@ -134,11 +134,11 @@ public void markFailed(String taskId, FailureReason failureReason, String errorM @Transactional public JobPostingAsyncCancelResponse cancelTask(User user, String taskId) { JobPostingAsyncTask task = getOwnedTaskState(user, taskId); - TaskStatus previousStatus = task.getStatus(); + AnalysisAsyncTaskStatus previousStatus = task.getStatus(); LocalDateTime previousCancelledAt = task.getCancelledAt(); task.requestCancel(); - boolean cancelled = task.getStatus() == TaskStatus.CANCELLED; - boolean newlyCancelled = previousStatus != TaskStatus.CANCELLED && cancelled; + boolean cancelled = task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; + boolean newlyCancelled = previousStatus != AnalysisAsyncTaskStatus.CANCELLED && cancelled; if (newlyCancelled) { recordProcessingMetric(task, "cancelled"); } @@ -182,7 +182,7 @@ public JobPostingAsyncStatusResponse getTask(User user, String taskId) { @Transactional public int sweepTimedOutTasks() { int expiredCount = 0; - for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(TaskStatus.PENDING, TaskStatus.RUNNING))) { + for (JobPostingAsyncTask task : jobPostingAsyncTaskRepository.findByStatusIn(EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING))) { if (expireTimedOutTaskIfNeeded(task)) { expiredCount++; } @@ -244,9 +244,9 @@ private JobPostingAsyncTask getTaskState(String taskId) { } private boolean isTerminal(JobPostingAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; } private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { @@ -255,11 +255,11 @@ private boolean expireTimedOutTaskIfNeeded(JobPostingAsyncTask task) { } LocalDateTime now = LocalDateTime.now(); - if (task.getStatus() == TaskStatus.PENDING + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, jobPostingQueueProperties.getQueueTimeoutSeconds())) { markFailed( task.getTaskId(), - FailureReason.QUEUE_TIMEOUT, + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, "채용 공고 작업이 대기열에서 시간 내 처리되지 않았습니다.", task.getRetryCount() ); @@ -267,11 +267,11 @@ && isExpired(task.getSubmittedAt(), now, jobPostingQueueProperties.getQueueTimeo } LocalDateTime lastActivityAt = task.getLastAttemptAt() != null ? task.getLastAttemptAt() : task.getStartedAt(); - if (task.getStatus() == TaskStatus.RUNNING + if (task.getStatus() == AnalysisAsyncTaskStatus.RUNNING && isExpired(lastActivityAt, now, jobPostingQueueProperties.getProcessingTimeoutSeconds())) { markFailed( task.getTaskId(), - FailureReason.WORKER_TIMEOUT, + AnalysisAsyncFailureReason.WORKER_TIMEOUT, "채용 공고 작업이 처리 제한 시간을 초과했습니다.", task.getRetryCount() ); @@ -312,7 +312,7 @@ private List buildSteps(JobPostingAsyncTask task ); } - private AsyncTaskProgressStatus toProgressStatus(TaskStatus status) { + private AsyncTaskProgressStatus toProgressStatus(AnalysisAsyncTaskStatus status) { return AsyncTaskProgressStatus.valueOf(status.name()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 4ed58f04..c93d0baf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -9,8 +9,8 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerFinalizeRequest; import com.jobdri.jobdri_api.domain.jobposting.dto.worker.JobPostingWorkerResultStoreRequest; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; @@ -71,7 +71,7 @@ public JobPostingIngestResponse completeTask(String taskId, JobPostingIngestResp @Transactional public void markRetry( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -91,7 +91,7 @@ public void markRetry( @Transactional public void failTask( String taskId, - FailureReason failureReason, + AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount, String workerId, @@ -139,21 +139,21 @@ public JobPostingIngestResponse finalizeAndComplete( "해당 비동기 작업을 찾을 수 없습니다. taskId=" + taskId )); - if (task.getStatus() == TaskStatus.SUCCEEDED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED) { workerTaskResultService.markDeliveredIfPresent(TaskType.JOB_POSTING_FINALIZE, taskId); return jobPostingAsyncTaskService.getTask(taskId).getResult(); } - if (task.getStatus() == TaskStatus.FAILED || task.getStatus() == TaskStatus.CANCELLED) { + if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED) { workerTaskResultService.markDeliveryFailedIfPresent( TaskType.JOB_POSTING_FINALIZE, taskId, - task.getStatus() == TaskStatus.CANCELLED + task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED ? "취소된 채용 공고 비동기 작업입니다." : "이미 실패 처리된 채용 공고 비동기 작업입니다." ); throw new GeneralException( GeneralErrorCode.INVALID_PARAMETER, - task.getStatus() == TaskStatus.CANCELLED + task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED ? "취소된 채용 공고 비동기 작업입니다. taskId=" + taskId : "이미 실패 처리된 채용 공고 비동기 작업입니다. taskId=" + taskId ); @@ -236,13 +236,13 @@ private JobPostingAsyncTask getTask(String taskId) { } private boolean isTerminal(JobPostingAsyncTask task) { - return task.getStatus() == TaskStatus.SUCCEEDED - || task.getStatus() == TaskStatus.FAILED - || task.getStatus() == TaskStatus.CANCELLED; + return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED + || task.getStatus() == AnalysisAsyncTaskStatus.FAILED + || task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; } private void rejectIfCancelled(JobPostingAsyncTask task, String message) { - if (task.getStatus() == TaskStatus.CANCELLED || task.isCancelRequested()) { + if (task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED || task.isCancelRequested()) { throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, message); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index c39639cc..ee090335 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -2,7 +2,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; @@ -262,7 +262,7 @@ private Map findActiveAnalysisTaskIds(Long userId, List return analysisAsyncTaskRepository.findByUserIdAndMockApplyIdInAndStatusIn( userId, mockApplyIds, - List.of(TaskStatus.PENDING, TaskStatus.RUNNING) + List.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING) ) .stream() .sorted(Comparator diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index 6706eff9..b4aa3394 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -5,7 +5,8 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -261,11 +262,11 @@ void cancelDelegatesToTaskService() { void retryAtLimitMarksTaskFailed() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); - task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-1", 1); - task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-2", 2); - task.markRetryScheduled(FailureReason.INTERNAL_ERROR, "retry-3", 3); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-1", 1); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-2", 2); + task.markRetryScheduled(AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry-3", 3); - assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTask.TaskStatus.FAILED); + assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTaskStatus.FAILED); assertThat(task.getRetryCount()).isEqualTo(3); } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index 4ef127fd..7d55aeb5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -7,7 +7,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; @@ -265,12 +265,12 @@ void failTaskReleasesReservedCreditOnlyOnce() { return null; }).when(analysisAsyncTaskService).markCreditReleased(task.getTaskId()); doAnswer(invocation -> { - task.markFailed(FailureReason.INTERNAL_ERROR, "error", 1); + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); return null; - }).when(analysisAsyncTaskService).markFailed(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1); + }).when(analysisAsyncTaskService).markFailed(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); - analysisWorkerBridgeService.failTask(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); - analysisWorkerBridgeService.failTask(task.getTaskId(), FailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); + analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); verify(analysisCreditService, times(1)).refund(user, "analysisTaskId=" + task.getTaskId()); verify(analysisAsyncTaskService, times(1)).markCreditReleased(task.getTaskId()); @@ -386,7 +386,7 @@ void terminalTaskDoesNotReopen() { analysisWorkerBridgeService.markRunning(task.getTaskId(), "worker-1", 1, java.time.Instant.now()); analysisWorkerBridgeService.markRetry( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry", 1, "worker-1", @@ -394,7 +394,7 @@ void terminalTaskDoesNotReopen() { ); verify(analysisAsyncTaskService, never()).markRunning(eq(task.getTaskId()), eq("worker-1"), eq(1), any()); - verify(analysisAsyncTaskService, never()).markRetryScheduled(eq(task.getTaskId()), eq(FailureReason.INTERNAL_ERROR), eq("retry"), eq(1)); + verify(analysisAsyncTaskService, never()).markRetryScheduled(eq(task.getTaskId()), eq(AnalysisAsyncFailureReason.INTERNAL_ERROR), eq("retry"), eq(1)); } @Test diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index ac404cb9..d6f9d4f5 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -6,7 +6,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java index ebf3bdc9..a9e2697c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java @@ -10,7 +10,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java index eb1db2e2..dc808c1f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAsyncTaskServiceTest.java @@ -6,7 +6,7 @@ import com.jobdri.jobdri_api.domain.notification.entity.NotificationTargetType; import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.FailureReason; +import com.jobdri.jobdri_api.domain.jobposting.entity.JobPostingAsyncTask.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingAsyncTaskRepository; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -120,7 +120,7 @@ void getTaskMarksPendingTimeout() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.QUEUE_TIMEOUT.name()); } @Test @@ -137,7 +137,7 @@ void getTaskMarksRunningTimeout() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.WORKER_TIMEOUT.name()); } @Test @@ -153,7 +153,7 @@ void getTaskMarksPendingTimeoutUsingSeconds() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.QUEUE_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.QUEUE_TIMEOUT.name()); } @Test @@ -170,7 +170,7 @@ void getTaskMarksRunningTimeoutUsingSeconds() { var response = jobPostingAsyncTaskService.getTask(task.getTaskId()); assertThat(response.getStatus()).isEqualTo("FAILED"); - assertThat(response.getFailureReason()).isEqualTo(FailureReason.WORKER_TIMEOUT.name()); + assertThat(response.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.WORKER_TIMEOUT.name()); } @Test @@ -182,13 +182,13 @@ void markRetryScheduledFailsWhenMaxRetryReached() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry exhausted", 3 ); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); - assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.FAILED); + assertThat(task.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.INTERNAL_ERROR); assertThat(task.getRetryCount()).isEqualTo(3); verify(jobPostingAsyncSseService).publish(any()); } @@ -210,7 +210,7 @@ void updateWorkerMetadataUpdatesWorkerFields() { @DisplayName("이미 종료된 task에 대한 재시도 예약은 무시된다") void markRetryScheduledDoesNothingWhenTaskIsTerminal() { JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); - task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "failed", 1); String originalError = task.getError(); int originalRetryCount = task.getRetryCount(); @@ -218,12 +218,12 @@ void markRetryScheduledDoesNothingWhenTaskIsTerminal() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.QUEUE_TIMEOUT, + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, "should be ignored", 2 ); - assertThat(task.getFailureReason()).isEqualTo(FailureReason.INTERNAL_ERROR); + assertThat(task.getFailureReason()).isEqualTo(AnalysisAsyncFailureReason.INTERNAL_ERROR); assertThat(task.getError()).isEqualTo(originalError); assertThat(task.getRetryCount()).isEqualTo(originalRetryCount); verify(jobPostingAsyncSseService, never()).publish(any()); @@ -282,7 +282,7 @@ void markFailedCreatesSanitizedNotification() { jobPostingAsyncTaskService.markFailed( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "worker stack trace", 1 ); @@ -310,7 +310,7 @@ void cancelTaskMarksCancelledAndPublishesStatus() { var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("CANCELLED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.CANCELLED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.CANCELLED); assertThat(task.isCancelRequested()).isTrue(); verify(jobPostingAsyncSseService).publish(any()); verify(notificationService, never()).createNotification(any(), any(), any(), any(), any(), any(), any()); @@ -341,7 +341,7 @@ void cancelTaskKeepsSucceededTaskWithoutPublish() throws Exception { var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("SUCCEEDED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.SUCCEEDED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.SUCCEEDED); assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 성공했습니다."); verify(jobPostingAsyncSseService, never()).publish(any()); } @@ -352,14 +352,14 @@ void cancelTaskKeepsFailedTaskWithoutPublish() { User user = User.signup("테스트 사용자", "job-posting-cancel-failed@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 7L); JobPostingAsyncTask task = JobPostingAsyncTask.pending(7L, 3); - task.markFailed(FailureReason.INTERNAL_ERROR, "failed", 1); + task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "failed", 1); when(jobPostingAsyncTaskRepository.findByTaskIdAndUserId(task.getTaskId(), 7L)).thenReturn(Optional.of(task)); var response = jobPostingAsyncTaskService.cancelTask(user, task.getTaskId()); assertThat(response.status()).isEqualTo("FAILED"); - assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.TaskStatus.FAILED); + assertThat(task.getStatus()).isEqualTo(JobPostingAsyncTask.AnalysisAsyncTaskStatus.FAILED); assertThat(task.getMessage()).isEqualTo("채용 공고 비동기 처리에 실패했습니다."); verify(jobPostingAsyncSseService, never()).publish(any()); } @@ -410,7 +410,7 @@ void markRetryScheduledDoesNotCreateNotification() { jobPostingAsyncTaskService.markRetryScheduled( task.getTaskId(), - FailureReason.INTERNAL_ERROR, + AnalysisAsyncFailureReason.INTERNAL_ERROR, "retry scheduled", 1 ); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java index e17c4631..3012a700 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingServiceTest.java @@ -3,7 +3,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index f841a7df..f1abb333 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -4,7 +4,8 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; @@ -354,7 +355,7 @@ void getMyMockAppliesUsesNewestActiveAnalysisTaskOnly() { MockApply failedTaskApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.MOCK)); failedTaskApply.updateStatus(MockApplyStatus.ANSWER_WRITE); AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(user.getId(), failedTaskApply.getId(), 0); - failedTask.markFailed(AnalysisAsyncTask.FailureReason.OPENAI_TIMEOUT, "timeout", 0); + failedTask.markFailed(AnalysisAsyncFailureReason.OPENAI_TIMEOUT, "timeout", 0); analysisAsyncTaskRepository.save(failedTask); MockApply cancelledTaskApply = mockApplyRepository.save(MockApply.create(user, posting, ApplyType.MOCK)); cancelledTaskApply.updateStatus(MockApplyStatus.ANSWER_WRITE); From 1f2ac258a61576692f96301c0cdebdbaf4a27e06 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:24:42 +0900 Subject: [PATCH 28/55] =?UTF-8?q?[Refactor]=20evaluation=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20runtime=20analysis=EC=99=80=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis}/EvaluationAnalysisBatchService.java | 2 +- .../analysis}/EvaluationAnalysisCase.java | 2 +- .../analysis}/EvaluationAnalysisResult.java | 2 +- .../analysis}/EvaluationAnalysisRunner.java | 2 +- .../analysis}/EvaluationCsvSupport.java | 2 +- .../analysis}/EvaluationExitCoordinator.java | 2 +- .../analysis}/EvaluationQuestionAnalysisResult.java | 2 +- .../analysis}/EvaluationRunnerDiagnostics.java | 2 +- .../analysis}/EvaluationRunnerFlagValidator.java | 2 +- .../analysis}/HybridExactMergeRunner.java | 2 +- .../analysis}/HybridExactMergeService.java | 2 +- .../analysis}/MissingKeywordSanitizerReplayRunner.java | 2 +- .../analysis}/MissingKeywordSanitizerReplayService.java | 2 +- .../analysis}/NlgEvaluationAiClient.java | 2 +- .../analysis}/NlgEvaluationBatchService.java | 2 +- .../analysis}/NlgEvaluationComparisonCsvSupport.java | 2 +- .../analysis}/NlgEvaluationComparisonResult.java | 2 +- .../analysis}/NlgEvaluationCsvSupport.java | 2 +- .../analysis}/NlgEvaluationErrorCode.java | 2 +- .../analysis}/NlgEvaluationResponse.java | 2 +- .../evaluation => evaluation/analysis}/NlgEvaluationResult.java | 2 +- .../evaluation => evaluation/analysis}/NlgEvaluationRunner.java | 2 +- .../analysis}/EvaluationAnalysisBatchServiceTest.java | 2 +- .../analysis}/EvaluationAnalysisRunnerSafetyTest.java | 2 +- .../analysis}/EvaluationCsvSupportTest.java | 2 +- .../analysis}/HybridExactMergeServiceTest.java | 2 +- .../analysis}/MissingKeywordSanitizerReplayRunnerTest.java | 2 +- .../analysis}/MissingKeywordSanitizerReplayServiceTest.java | 2 +- .../analysis}/NlgEvaluationAiClientTest.java | 2 +- .../analysis}/NlgEvaluationBatchServiceTest.java | 2 +- 30 files changed, 30 insertions(+), 30 deletions(-) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisBatchService.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisCase.java (81%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisResult.java (98%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisRunner.java (98%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationCsvSupport.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationExitCoordinator.java (97%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationQuestionAnalysisResult.java (78%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationRunnerDiagnostics.java (94%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationRunnerFlagValidator.java (96%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/HybridExactMergeRunner.java (98%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/HybridExactMergeService.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/MissingKeywordSanitizerReplayRunner.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/MissingKeywordSanitizerReplayService.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationAiClient.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationBatchService.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationComparisonCsvSupport.java (99%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationComparisonResult.java (95%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationCsvSupport.java (98%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationErrorCode.java (85%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationResponse.java (97%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationResult.java (96%) rename src/main/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationRunner.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisBatchServiceTest.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationAnalysisRunnerSafetyTest.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/EvaluationCsvSupportTest.java (98%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/HybridExactMergeServiceTest.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/MissingKeywordSanitizerReplayRunnerTest.java (97%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/MissingKeywordSanitizerReplayServiceTest.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationAiClientTest.java (99%) rename src/test/java/com/jobdri/jobdri_api/domain/{analysis/evaluation => evaluation/analysis}/NlgEvaluationBatchServiceTest.java (99%) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index 7f08546a..0808cc72 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java similarity index 81% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java index bd32f5c6..0ed2cd8c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisCase.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisCase.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationAnalysisCase( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java index 30dcdd0d..60acb682 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationAnalysisResult( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java index 08e531d2..51e36a20 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java index c70343bc..08bbc77a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedReader; import java.io.BufferedWriter; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java index d4b9d8c9..ee74bff1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationExitCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationExitCoordinator.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java similarity index 78% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java index 15e0af54..382cf546 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationQuestionAnalysisResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationQuestionAnalysisResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record EvaluationQuestionAnalysisResult( Long questionId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java similarity index 94% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java index 79467dfb..ad5ec1d2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerDiagnostics.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerDiagnostics.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java similarity index 96% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java index 1ee5d093..965fce8f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationRunnerFlagValidator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationRunnerFlagValidator.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java index ecfd665c..0fdaa4e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java index 68ec70a5..9791542f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeService.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java index aedce188..b7e646c0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java index 65cd03cd..751aab77 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java index 4bca6209..e34589b9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClient.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.openai.client.OpenAIClient; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index af1d278a..9210d17f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java index 824102d2..17b37a8b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedWriter; import java.io.IOException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java similarity index 95% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java index a0560756..35e91e50 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationComparisonResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationComparisonResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record NlgEvaluationComparisonResult( String sourceResultFile, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java index 1fe595b0..cd8ba8d7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationCsvSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationCsvSupport.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.io.BufferedWriter; import java.io.IOException; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java similarity index 85% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java index 27719c36..427d0d5d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationErrorCode.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationErrorCode.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; public enum NlgEvaluationErrorCode { FALSE_POSITIVE_ANALYSIS, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java similarity index 97% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java index 8d7cd57b..e98bd9f6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResponse.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import java.util.List; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java similarity index 96% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java index 1ef7df2c..23655dda 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationResult.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; record NlgEvaluationResult( String caseId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java similarity index 99% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java index 80ddea40..41a68007 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationRunner.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationRunner.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java index 8d2b681b..62c11de6 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java index a4999e81..41859e9e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationAnalysisRunnerSafetyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisRunnerSafetyTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.domain.corpus.service.CorpusAdminRunner; import com.jobdri.jobdri_api.global.scheduling.AsyncTaskSweepScheduler; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java similarity index 98% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java index 9ea41f71..1e9f1baf 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/EvaluationCsvSupportTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationCsvSupportTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java index e824d952..93c5771d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/HybridExactMergeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/HybridExactMergeServiceTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java similarity index 97% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java index 1ffd58cb..21fdc13d 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayRunnerTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayRunnerTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java index b2945829..b5862f7b 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java index 304b71c9..cc93db90 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationAiClientTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.openai.client.OpenAIClient; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java similarity index 99% rename from src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java rename to src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java index 7eb3ac8b..b2582f93 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/evaluation/NlgEvaluationBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchServiceTest.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.evaluation; +package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; From 32c636967d0c368c88617ab0881c9e406c9323ac Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:41:40 +0900 Subject: [PATCH 29/55] =?UTF-8?q?[Refactor]=20analysis=20async/question=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=95=EB=A6=AC=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model}/AnalysisExecutionPayload.java | 2 +- .../application/port/AnalysisGenerator.java | 2 +- .../usecase/async/AnalysisAsyncUseCase.java | 47 +- .../request/QuestionAnswerSaveRequest.java | 3 + .../QuestionCandidateCreateRequest.java | 3 + .../request/QuestionSelectionSaveRequest.java | 3 + .../response/AnalysisAsyncStatusResponse.java | 26 + .../ai/OpenAiAnalysisAdapter.java | 20 +- .../async/AnalysisAsyncWorkerBridge.java | 8 +- .../analysis/service/ai/AnalysisAiClient.java | 2 +- .../service/ai/AnalysisAiGenerator.java | 2 +- .../service/ai/AnalysisPromptBuilder.java | 6 + .../AnalysisInputFingerprintProvider.java | 1 + .../core/AnalysisPreparationResult.java | 1 + .../AnalysisResultPersistenceService.java | 1 + .../service/core/AnalysisService.java | 3 +- .../QuestionCandidateCatalogService.java | 7 +- .../question/QuestionCommandService.java | 20 +- .../question/QuestionDomainSupport.java | 9 +- .../service/question/QuestionService.java | 64 -- .../type/AnalysisAsyncFailureReason.java | 1 + .../ai/OpenAiAnalysisAdapterTest.java | 109 +++ .../service/ai/AnalysisAiClientTest.java | 7 +- .../async/AnalysisAsyncFacadeServiceTest.java | 62 +- .../AnalysisWorkerBridgeServiceTest.java | 2 +- .../AnalysisInputFingerprintProviderTest.java | 1 + .../service/core/AnalysisServiceTest.java | 1 + .../service/question/QuestionServiceTest.java | 629 ------------------ 28 files changed, 278 insertions(+), 764 deletions(-) rename src/main/java/com/jobdri/jobdri_api/domain/analysis/{service/core => application/model}/AnalysisExecutionPayload.java (98%) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java delete mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java similarity index 98% rename from src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java rename to src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java index fbf9e8e2..26a7b570 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisExecutionPayload.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/model/AnalysisExecutionPayload.java @@ -1,4 +1,4 @@ -package com.jobdri.jobdri_api.domain.analysis.service.core; +package com.jobdri.jobdri_api.domain.analysis.application.model; import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java index 75542d05..b7faf232 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/port/AnalysisGenerator.java @@ -1,7 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.application.port; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; public interface AnalysisGenerator { AnalysisLlmResponse analyze(AnalysisExecutionPayload payload); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java index 408c7f32..c5f9a4a4 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java @@ -7,14 +7,14 @@ import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncProcessor; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; -import java.util.Locale; - // 분석 비동기 작업의 접수/조회/취소 유스케이스를 조율한다. public class AnalysisAsyncUseCase { private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; @@ -55,30 +55,7 @@ public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String t return status; } - return AnalysisAsyncStatusResponse.builder() - .taskId(status.taskId()) - .mockApplyId(status.mockApplyId()) - .status(status.status()) - .message(status.message()) - .error(status.error()) - .failureReason(status.failureReason()) - .workerId(status.workerId()) - .retryCount(status.retryCount()) - .maxRetryCount(status.maxRetryCount()) - .queueLatencyMillis(status.queueLatencyMillis()) - .createdAt(status.createdAt()) - .submittedAt(status.submittedAt()) - .lastAttemptAt(status.lastAttemptAt()) - .startedAt(status.startedAt()) - .completedAt(status.completedAt()) - .cancelRequested(status.cancelRequested()) - .cancelledAt(status.cancelledAt()) - .currentStep(status.currentStep()) - .progressPercent(status.progressPercent()) - .estimatedRemainingSeconds(status.estimatedRemainingSeconds()) - .steps(status.steps()) - .result(analysisService.getAnalysis(validatedUser, status.mockApplyId())) - .build(); + return status.withResult(analysisService.getAnalysis(validatedUser, status.mockApplyId())); } public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { @@ -117,7 +94,12 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp false ); } catch (RuntimeException e) { - analysisAsyncTaskService.deleteTask(taskId); + analysisAsyncTaskService.markFailed( + taskId, + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + task.getRetryCount() + ); throw e; } } @@ -161,11 +143,8 @@ private AnalysisAsyncSubmitResponse toCachedResponse() { private boolean isActiveTaskUniqueConflict(DataIntegrityViolationException exception) { Throwable cause = exception; while (cause != null) { - if (cause instanceof org.hibernate.exception.ConstraintViolationException constraintViolation - && containsConstraintName(constraintViolation.getConstraintName())) { - return true; - } - if (containsConstraintName(cause.getMessage())) { + if (cause instanceof ConstraintViolationException constraintViolation + && ACTIVE_TASK_UNIQUE_CONSTRAINT.equalsIgnoreCase(constraintViolation.getConstraintName())) { return true; } cause = cause.getCause(); @@ -173,10 +152,6 @@ && containsConstraintName(constraintViolation.getConstraintName())) { return false; } - private boolean containsConstraintName(String value) { - return value != null && value.toLowerCase(Locale.ROOT).contains(ACTIVE_TASK_UNIQUE_CONSTRAINT); - } - private record PendingTaskResult(AnalysisAsyncTask task, boolean created) { } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java index 81b994a2..d27cfb4b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionAnswerSaveRequest.java @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; import com.fasterxml.jackson.annotation.JsonAlias; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -24,6 +26,7 @@ public record QuestionAnswerItem( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit, @NotNull(message = "답변 내용은 필수입니다.") diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java index 9e048475..ef4ecb05 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionCandidateCreateRequest.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; @@ -8,6 +10,7 @@ public record QuestionCandidateCreateRequest( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java index c0716342..5b92b85c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/request/QuestionSelectionSaveRequest.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.analysis.dto.request; +import com.jobdri.jobdri_api.domain.analysis.service.question.QuestionDomainSupport; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; @@ -19,6 +21,7 @@ public record QuestionSelectionItem( String content, @Positive(message = "글자수 제한은 1 이상이어야 합니다.") + @Max(value = QuestionDomainSupport.MAX_CHAR_LIMIT, message = "글자수 제한은 최대 5000자까지 설정할 수 있습니다.") Integer charLimit, Boolean custom diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java index 55ca67f5..3d3f8865 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/AnalysisAsyncStatusResponse.java @@ -30,4 +30,30 @@ public record AnalysisAsyncStatusResponse( List steps, AnalysisResponse result ) { + public AnalysisAsyncStatusResponse withResult(AnalysisResponse result) { + return new AnalysisAsyncStatusResponse( + taskId, + mockApplyId, + status, + message, + error, + failureReason, + workerId, + retryCount, + maxRetryCount, + queueLatencyMillis, + createdAt, + submittedAt, + lastAttemptAt, + startedAt, + completedAt, + cancelRequested, + cancelledAt, + currentStep, + progressPercent, + estimatedRemainingSeconds, + steps, + result + ); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java index 640f4370..08daf24b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java @@ -19,7 +19,7 @@ public class OpenAiAnalysisAdapter { private final AnalysisResponseParser analysisResponseParser; @Value("${openai.model.cover-letter-analysis:gpt-4o-mini}") - private String analysisModel = "gpt-4o-mini"; + private String analysisModel; public T createStructuredResponse(String operationName, String prompt, Class responseType) { var params = ResponseCreateParams.builder() @@ -29,19 +29,21 @@ public T createStructuredResponse(String operationName, String prompt, Class .text(responseType) .build(); long startedAt = System.nanoTime(); + boolean success = false; try { StructuredResponse response = llmConcurrencyLimiter.execute( operationName, () -> openAIClient.responses().create(params) ); - asyncMetricsRecorder.recordLlmRequest(operationName, "success", elapsedMillis(startedAt)); - return analysisResponseParser.extractStructuredContent(response); - } catch (RuntimeException e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; - } catch (Error e) { - asyncMetricsRecorder.recordLlmRequest(operationName, "error", elapsedMillis(startedAt)); - throw e; + T structuredContent = analysisResponseParser.extractStructuredContent(response); + success = true; + return structuredContent; + } finally { + asyncMetricsRecorder.recordLlmRequest( + operationName, + success ? "success" : "error", + elapsedMillis(startedAt) + ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index 88dc494c..7268a7fb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -16,7 +16,7 @@ import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -276,7 +276,8 @@ private String writeContextSnapshot(AnalysisWorkerContextResponse context) { } catch (JsonProcessingException exception) { throw new GeneralException( GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다." + "자소서 분석 worker 컨텍스트 snapshot 저장에 실패했습니다.", + exception ); } } @@ -293,7 +294,8 @@ private AnalysisWorkerContextResponse readContextSnapshot(AnalysisAsyncTask task } catch (JsonProcessingException exception) { throw new GeneralException( GeneralErrorCode.INTERNAL_SERVER_ERROR, - "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId() + "자소서 분석 worker 컨텍스트 snapshot을 읽을 수 없습니다. taskId=" + task.getTaskId(), + exception ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index 4fdba4e4..e08a3ffb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -17,7 +17,7 @@ import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java index 95be2539..db622fdd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiGenerator.java @@ -2,7 +2,7 @@ import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java index de8becb6..ce3e740a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisPromptBuilder.java @@ -753,6 +753,12 @@ private String writeJson(Object value) { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException e) { + log.warn( + "Failed to serialize analysis prompt value. fallback=[] valueType={}, value={}", + value == null ? "null" : value.getClass().getName(), + value, + e + ); return "[]"; } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java index d8fba32a..4a8aaec1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProvider.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java index 331393fe..aa23ac37 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisPreparationResult.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java index 08b68f1a..cf5d5ff6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisResultPersistenceService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisHighlightResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisQuestionResponse; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java index 2b368b27..c2d7fff3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisService.java @@ -1,12 +1,13 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.analysis.application.port.AnalysisGenerator; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java index 7a510993..f8ecdbab 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Set; +import java.util.stream.Collectors; @Service // 기본 문항 후보와 후보 판별 규칙을 제공한다. @@ -18,6 +19,9 @@ public class QuestionCandidateCatalogService { new QuestionCandidate(4L, "협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800), new QuestionCandidate(5L, "가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000) ); + private static final Set DEFAULT_CANDIDATE_CONTENTS = DEFAULT_CANDIDATES.stream() + .map(QuestionCandidate::content) + .collect(Collectors.toUnmodifiableSet()); public List getDefaultCandidateResponses(Set selectedContents) { return DEFAULT_CANDIDATES.stream() @@ -32,8 +36,7 @@ public List getDefaultCandidateResponses(Set } public boolean isCustomQuestion(String content) { - return DEFAULT_CANDIDATES.stream() - .noneMatch(candidate -> candidate.content().equals(content)); + return !DEFAULT_CANDIDATE_CONTENTS.contains(content); } public void validateCustomCandidate(String content) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java index c7905603..c59db0e2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java @@ -5,6 +5,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; import com.jobdri.jobdri_api.domain.analysis.entity.Question; @@ -91,7 +92,7 @@ public QuestionSelectionResponse saveSelectedQuestions( questionDomainSupport.validateSelectionCount(request.questions().size()); List existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId()); - questionRepository.deleteAll(existingQuestions); + deleteQuestions(existingQuestions); List questions = request.questions().stream() .map(item -> Question.create( @@ -164,7 +165,7 @@ public QuestionAnswerResponse saveAnswers( List deletedQuestions = existingQuestions.stream() .filter(question -> !requestedQuestionIds.contains(question.getId())) .toList(); - questionRepository.deleteAll(deletedQuestions); + deleteQuestions(deletedQuestions); List savedQuestions = questionRepository.saveAll(syncedQuestions); mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); @@ -193,9 +194,8 @@ public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Lon "해당 지원서의 문항을 찾을 수 없습니다. questionId=" + questionId )); - questionAnalysisRepository.deleteAllByQuestionId(question.getId()); - questionRepository.delete(question); - List remainingQuestions = existingQuestions.stream() + deleteQuestions(List.of(question)); + List remainingQuestions = existingQuestions.stream() .filter(existingQuestion -> !existingQuestion.getId().equals(question.getId())) .map(questionDomainSupport::toQuestionResponse) .toList(); @@ -230,4 +230,14 @@ private CustomQuestionCandidate saveCustomCandidate( .orElseThrow(() -> e); } } + + private void deleteQuestions(List questions) { + if (questions == null || questions.isEmpty()) { + return; + } + questions.stream() + .map(Question::getId) + .forEach(questionAnalysisRepository::deleteAllByQuestionId); + questionRepository.deleteAll(questions); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java index d00a44c9..51a57d7c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionDomainSupport.java @@ -16,6 +16,7 @@ public class QuestionDomainSupport { private static final int MIN_SELECTION_COUNT = 1; private static final int MAX_SELECTION_COUNT = 5; private static final int DEFAULT_CHAR_LIMIT = 1000; + public static final int MAX_CHAR_LIMIT = 5000; private final MockApplyRepository mockApplyRepository; private final QuestionCandidateCatalogService questionCandidateCatalogService; @@ -55,12 +56,18 @@ public int resolveCharLimit(Integer charLimit) { if (charLimit == null) { return DEFAULT_CHAR_LIMIT; } + if (charLimit > MAX_CHAR_LIMIT) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "글자수 제한은 최대 " + MAX_CHAR_LIMIT + "자까지 설정할 수 있습니다." + ); + } return charLimit; } public String normalizeAnswer(String answer) { if (StringUtils.hasText(answer)) { - return answer; + return answer.trim(); } return ""; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java deleted file mode 100644 index 9342e378..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.jobdri.jobdri_api.domain.analysis.service.question; - -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.user.entity.User; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -@Service -@Transactional(readOnly = true) -// 기존 QuestionService 진입점을 유지하는 호환용 파사드다. -public class QuestionService { - private final QuestionQueryService questionQueryService; - private final QuestionCommandService questionCommandService; - - public QuestionService( - QuestionQueryService questionQueryService, - QuestionCommandService questionCommandService - ) { - this.questionQueryService = questionQueryService; - this.questionCommandService = questionCommandService; - } - - public List getQuestionCandidates(User user, Long mockApplyId) { - return questionQueryService.getQuestionCandidates(user, mockApplyId); - } - - public QuestionCandidateResponse addCustomQuestionCandidate( - User user, - Long mockApplyId, - QuestionCandidateCreateRequest request - ) { - return questionCommandService.addCustomQuestionCandidate(user, mockApplyId, request); - } - - public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) { - return questionQueryService.getSelectedQuestions(user, mockApplyId); - } - - public QuestionSelectionResponse saveSelectedQuestions( - User user, - Long mockApplyId, - QuestionSelectionSaveRequest request - ) { - return questionCommandService.saveSelectedQuestions(user, mockApplyId, request); - } - - public QuestionAnswerResponse saveAnswers( - User user, - Long mockApplyId, - QuestionAnswerSaveRequest request - ) { - return questionCommandService.saveAnswers(user, mockApplyId, request); - } - - public QuestionSelectionResponse deleteQuestion(User user, Long mockApplyId, Long questionId) { - return questionCommandService.deleteQuestion(user, mockApplyId, questionId); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java index 31c53aa7..cad64735 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/type/AnalysisAsyncFailureReason.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.type; public enum AnalysisAsyncFailureReason { + PUBLISH_FAILED, RATE_LIMIT, QUEUE_TIMEOUT, OPENAI_TIMEOUT, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java new file mode 100644 index 00000000..e4905c2d --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapterTest.java @@ -0,0 +1,109 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.ai; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisResponseParser; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import com.openai.client.OpenAIClient; +import com.openai.models.responses.StructuredResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OpenAiAnalysisAdapterTest { + + private final OpenAIClient openAIClient = mock(OpenAIClient.class); + private final LlmConcurrencyLimiter llmConcurrencyLimiter = mock(LlmConcurrencyLimiter.class); + private final AsyncMetricsRecorder asyncMetricsRecorder = mock(AsyncMetricsRecorder.class); + private final AnalysisResponseParser analysisResponseParser = mock(AnalysisResponseParser.class); + + private OpenAiAnalysisAdapter openAiAnalysisAdapter; + + @BeforeEach + void setUp() { + openAiAnalysisAdapter = new OpenAiAnalysisAdapter( + openAIClient, + llmConcurrencyLimiter, + asyncMetricsRecorder, + analysisResponseParser + ); + ReflectionTestUtils.setField(openAiAnalysisAdapter, "analysisModel", "gpt-4o-mini"); + } + + @Test + @DisplayName("정상 응답이면 limiter 실행 후 success 메트릭을 기록한다") + void createStructuredResponseRecordsSuccessMetric() throws Exception { + StructuredResponse response = mock(StructuredResponse.class); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + when(analysisResponseParser.extractStructuredContent(response)).thenReturn("ok"); + + String result = openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class); + + assertThat(result).isEqualTo("ok"); + verify(llmConcurrencyLimiter).execute(eq("analysis"), any()); + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("success"), any(Long.class)); + verify(asyncMetricsRecorder, never()).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } + + @Test + @DisplayName("limiter 실행 중 RuntimeException이 나면 그대로 전파하고 error 메트릭을 기록한다") + void createStructuredResponsePropagatesLimiterFailure() { + RuntimeException exception = new RuntimeException("boom"); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenThrow(exception); + + assertThatThrownBy(() -> openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class)) + .isSameAs(exception); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } + + @Test + @DisplayName("응답 추출 실패면 success 없이 error 메트릭만 기록한다") + void createStructuredResponseRecordsOnlyErrorWhenParsingFails() throws Exception { + StructuredResponse response = mock(StructuredResponse.class); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + when(analysisResponseParser.extractStructuredContent(response)) + .thenThrow(new IllegalStateException("parse failed")); + + assertThatThrownBy(() -> openAiAnalysisAdapter.createStructuredResponse("analysis", "prompt", String.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("parse failed"); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + verify(asyncMetricsRecorder, never()).recordLlmRequest(eq("analysis"), eq("success"), any(Long.class)); + } + + @Test + @DisplayName("output text가 없으면 parser 예외를 전파하고 error 메트릭을 기록한다") + void createStructuredResponsePropagatesParserFailureWhenOutputMissing() throws Exception { + AnalysisResponseParser realParser = new AnalysisResponseParser(); + OpenAiAnalysisAdapter adapter = new OpenAiAnalysisAdapter( + openAIClient, + llmConcurrencyLimiter, + asyncMetricsRecorder, + realParser + ); + ReflectionTestUtils.setField(adapter, "analysisModel", "gpt-4o-mini"); + + StructuredResponse response = mock(StructuredResponse.class); + when(response.output()).thenReturn(List.of()); + when(llmConcurrencyLimiter.execute(eq("analysis"), any())).thenReturn(response); + + assertThatThrownBy(() -> adapter.createStructuredResponse("analysis", "prompt", String.class)) + .isInstanceOf(GeneralException.class); + + verify(asyncMetricsRecorder).recordLlmRequest(eq("analysis"), eq("error"), any(Long.class)); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 540b1a19..a6a51a63 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -50,12 +50,7 @@ class AnalysisAiClientTest { fewShotProperties, objectMapper ); - private final OpenAiAnalysisAdapter openAiAnalysisAdapter = new OpenAiAnalysisAdapter( - mock(OpenAIClient.class), - mock(LlmConcurrencyLimiter.class), - mock(AsyncMetricsRecorder.class), - analysisResponseParser - ); + private final OpenAiAnalysisAdapter openAiAnalysisAdapter = mock(OpenAiAnalysisAdapter.class); private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( mock(OpenAIClient.class), mock(CorpusRetrievalService.class), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index b4aa3394..37eb34cc 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -13,6 +13,7 @@ import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,6 +25,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.util.ReflectionTestUtils; +import java.sql.SQLException; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +33,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -66,7 +69,7 @@ void submitReturnsExistingTaskWhenCreateConflicts() { when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty(), Optional.of(existingTask)); when(analysisAsyncTaskService.createPendingTask(1L, 10L)) - .thenThrow(new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply")); + .thenThrow(activeTaskUniqueConflict()); AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); @@ -81,8 +84,7 @@ void submitPropagatesExceptionWhenTaskDisappearsAfterConflict() { User user = User.signup("테스트 사용자", "analysis-async-missing@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - DataIntegrityViolationException exception = - new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply"); + DataIntegrityViolationException exception = activeTaskUniqueConflict(); when(userService.validateUser(user)).thenReturn(user); when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); @@ -115,6 +117,49 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); } + @Test + @DisplayName("메시지 발행 실패 시 task를 삭제하지 않고 PUBLISH_FAILED로 실패 처리한다") + void submitMarksTaskFailedWhenPublishFails() { + User user = User.signup("테스트 사용자", "analysis-async-publish-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask createdTask = AnalysisAsyncTask.pending(1L, 10L, 3); + RuntimeException publishException = new RuntimeException("publish failed"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenReturn(createdTask); + doThrow(publishException).when(analysisAsyncProcessor).process(createdTask.getTaskId(), 1L, 10L, 3); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(publishException); + + verify(analysisAsyncTaskService).markFailed( + createdTask.getTaskId(), + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + 0 + ); + verify(analysisAsyncTaskService, never()).deleteTask(createdTask.getTaskId()); + } + + @Test + @DisplayName("제약 조건명이 없으면 메시지 기반으로 중복 충돌로 분류하지 않는다") + void submitDoesNotTreatMessageOnlyConflictAsDuplicate() { + User user = User.signup("테스트 사용자", "analysis-async-message-only@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + DataIntegrityViolationException exception = + new DataIntegrityViolationException("uk_analysis_async_tasks_active_user_mock_apply"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisService.hasReusableAnalysis(user, 10L)).thenReturn(false); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.createPendingTask(1L, 10L)).thenThrow(exception); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(exception); + } + @Test @DisplayName("활성 작업이 없고 동일 입력 분석 결과가 있으면 task를 만들지 않고 즉시 재사용 응답을 반환한다") void submitReturnsCachedResponseWhenReusableAnalysisExists() { @@ -269,4 +314,15 @@ void retryAtLimitMarksTaskFailed() { assertThat(task.getStatus()).isEqualTo(AnalysisAsyncTaskStatus.FAILED); assertThat(task.getRetryCount()).isEqualTo(3); } + + private DataIntegrityViolationException activeTaskUniqueConflict() { + return new DataIntegrityViolationException( + "constraint violation", + new ConstraintViolationException( + "duplicate", + new SQLException("duplicate"), + "uk_analysis_async_tasks_active_user_mock_apply" + ) + ); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index 7d55aeb5..d5bb7d9a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -11,7 +11,7 @@ import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisExecutionPayload; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.company.entity.Company; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java index 36256183..d36a882a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisInputFingerprintProviderTest.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.service.ai.FewShotPromptProvider; import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index d6f9d4f5..ed112019 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java deleted file mode 100644 index a9e2697c..00000000 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionServiceTest.java +++ /dev/null @@ -1,629 +0,0 @@ -package com.jobdri.jobdri_api.domain.analysis.service.question; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse; -import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; -import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; -import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; -import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; -import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; -import com.jobdri.jobdri_api.domain.classification.entity.Classification; -import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; -import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; -import com.jobdri.jobdri_api.domain.classification.repository.ClassificationRepository; -import com.jobdri.jobdri_api.domain.classification.repository.DetailClassificationRepository; -import com.jobdri.jobdri_api.domain.company.entity.Company; -import com.jobdri.jobdri_api.domain.company.entity.CompanySize; -import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; -import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository; -import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; -import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; -import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.repository.UserRepository; -import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; -import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.tuple; - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -class QuestionServiceTest { - - @Autowired - private QuestionService questionService; - - @Autowired - private QuestionRepository questionRepository; - - @Autowired - private QuestionAnalysisRepository questionAnalysisRepository; - - @Autowired - private AnalysisRepository analysisRepository; - - @Autowired - private MockApplyRepository mockApplyRepository; - - @Autowired - private JobPostingRepository jobPostingRepository; - - @Autowired - private CompanyRepository companyRepository; - - @Autowired - private ClassificationRepository classificationRepository; - - @Autowired - private DetailClassificationRepository detailClassificationRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private ObjectMapper objectMapper; - - @Test - @DisplayName("선택 문항을 저장하고 지원 상태를 답변 작성 단계로 변경한다") - void saveSelectedQuestions() { - User user = saveUser("question-save@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("직접 추가한 문항입니다.", null, true) - )); - - QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), request); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(response.questions()).hasSize(2); - assertThat(response.questions().get(0).content()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(response.questions().get(0).charLimit()).isEqualTo(700); - assertThat(response.questions().get(0).custom()).isFalse(); - assertThat(response.questions().get(1).content()).isEqualTo("직접 추가한 문항입니다."); - assertThat(response.questions().get(1).charLimit()).isEqualTo(1000); - assertThat(response.questions().get(1).custom()).isTrue(); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).hasSize(2); - } - - @Test - @DisplayName("문항 저장 시 기존 선택 문항을 새 선택 목록으로 교체한다") - void saveSelectedQuestionsReplacesExistingQuestions() { - User user = saveUser("question-replace@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 1", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 2", 500, false) - ))); - - QuestionSelectionResponse response = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("새 문항", 800, false) - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(response.questions().get(0).custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())) - .extracting(Question::getContent) - .containsExactly("새 문항"); - } - - @Test - @DisplayName("선택 문항 조회 시 직접 추가 여부를 함께 반환한다") - void getSelectedQuestionsReturnsCustomFlag() { - User user = saveUser("question-selected-custom-flag@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("직접 추가한 문항입니다.", 1000, true) - ))); - - QuestionSelectionResponse response = questionService.getSelectedQuestions(user, mockApply.getId()); - - assertThat(response.questions()).hasSize(2); - assertThat(response.questions()) - .extracting("content", "custom") - .containsExactlyInAnyOrder( - tuple("지원 동기와 입사 후 목표를 작성해주세요.", false), - tuple("직접 추가한 문항입니다.", true) - ); - } - - @Test - @DisplayName("직접 추가 문항은 선택 문항으로 저장하지 않고 후보 목록에 추가한다") - void addCustomQuestionCandidate() { - User user = saveUser("question-custom-candidate@example.com"); - MockApply mockApply = saveMockApply(user); - - QuestionCandidateResponse response = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("직접 추가한 후보 문항입니다.", 500) - ); - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(response.content()).isEqualTo("직접 추가한 후보 문항입니다."); - assertThat(response.charLimit()).isEqualTo(500); - assertThat(response.selected()).isFalse(); - assertThat(response.custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(candidates).hasSize(6); - assertThat(candidates.get(5).questionId()).isEqualTo(response.questionId()); - assertThat(candidates.get(5).content()).isEqualTo("직접 추가한 후보 문항입니다."); - assertThat(candidates.get(5).selected()).isFalse(); - assertThat(candidates.get(5).custom()).isTrue(); - } - - @Test - @DisplayName("기본 후보와 같은 내용은 직접 추가 문항 후보로 등록할 수 없다") - void addCustomQuestionCandidateThrowsWhenContentExistsInDefaultCandidate() { - User user = saveUser("question-custom-default-duplicate@example.com"); - MockApply mockApply = saveMockApply(user); - - assertThatThrownBy(() -> questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("지원 동기와 입사 후 목표를 작성해주세요.", 700) - )) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(questionService.getQuestionCandidates(user, mockApply.getId())).hasSize(5); - } - - @Test - @DisplayName("같은 직접 추가 문항 후보를 여러 번 요청하면 기존 후보를 반환한다") - void addCustomQuestionCandidateReturnsExistingCandidateWhenDuplicated() { - User user = saveUser("question-custom-duplicate@example.com"); - MockApply mockApply = saveMockApply(user); - - QuestionCandidateResponse first = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 500) - ); - QuestionCandidateResponse second = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 800) - ); - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(second.questionId()).isEqualTo(first.questionId()); - assertThat(second.content()).isEqualTo(first.content()); - assertThat(second.charLimit()).isEqualTo(first.charLimit()); - assertThat(second.selected()).isFalse(); - assertThat(second.custom()).isTrue(); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty(); - assertThat(candidates).hasSize(6); - assertThat(candidates.stream() - .filter(QuestionCandidateResponse::custom) - .map(QuestionCandidateResponse::content) - .toList()) - .containsExactly("중복 직접 추가 문항입니다."); - } - - @Test - @DisplayName("이미 선택된 직접 추가 후보를 다시 요청하면 선택 상태로 반환한다") - void addCustomQuestionCandidateReturnsSelectedStateWhenAlreadySelected() { - User user = saveUser("question-custom-selected@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionCandidateResponse first = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 500) - ); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("이미 선택된 직접 추가 문항입니다.", 500, true) - ))); - - QuestionCandidateResponse second = questionService.addCustomQuestionCandidate( - user, - mockApply.getId(), - new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 800) - ); - - assertThat(second.questionId()).isEqualTo(first.questionId()); - assertThat(second.content()).isEqualTo(first.content()); - assertThat(second.charLimit()).isEqualTo(first.charLimit()); - assertThat(second.selected()).isTrue(); - assertThat(second.custom()).isTrue(); - } - - @Test - @DisplayName("문항 후보 목록은 이미 저장된 기본 문항을 선택 상태로 반환한다") - void getQuestionCandidatesMarksSelectedQuestion() { - User user = saveUser("question-candidates@example.com"); - MockApply mockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기와 입사 후 목표를 작성해주세요.", 700, false) - ))); - - List candidates = questionService.getQuestionCandidates(user, mockApply.getId()); - - assertThat(candidates).hasSize(5); - assertThat(candidates) - .extracting(QuestionCandidateResponse::questionId) - .containsExactly(1L, 2L, 3L, 4L, 5L); - assertThat(candidates) - .extracting(QuestionCandidateResponse::custom) - .containsOnly(false); - assertThat(candidates.get(0).selected()).isTrue(); - assertThat(candidates.get(1).selected()).isFalse(); - } - - @Test - @DisplayName("선택 문항은 1개 이상이어야 한다") - void saveSelectedQuestionsThrowsWhenEmpty() { - User user = saveUser("question-empty@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of()); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - } - - @Test - @DisplayName("선택 문항은 최대 5개까지 저장할 수 있다") - void saveSelectedQuestionsThrowsWhenTooMany() { - User user = saveUser("question-too-many@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 1", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 2", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 3", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 4", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 5", 500, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("문항 6", 500, false) - )); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(user, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - } - - @Test - @DisplayName("다른 사용자의 지원서에는 문항을 저장할 수 없다") - void saveSelectedQuestionsThrowsWhenUserDoesNotOwnMockApply() { - User owner = saveUser("question-owner@example.com"); - User other = saveUser("question-other@example.com"); - MockApply mockApply = saveMockApply(owner); - QuestionSelectionSaveRequest request = new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기", 700, false) - )); - - assertThatThrownBy(() -> questionService.saveSelectedQuestions(other, mockApply.getId(), request)) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.FORBIDDEN); - } - - @Test - @DisplayName("저장된 문항의 내용과 답변을 함께 작성하거나 수정한다") - void saveAnswers() { - User user = saveUser("answer-save@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("지원 동기를 작성해주세요.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem( - questionId, - "지원 동기와 입사 후 목표를 작성해주세요.", - 1000, - "저는 백엔드 개발 경험을 바탕으로 지원했습니다." - ) - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.sequence()).isEqualTo(1); - assertThat(response.questions().get(0).content()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(response.questions().get(0).answer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); - Question savedQuestion = questionRepository.findById(questionId).orElseThrow(); - assertThat(savedQuestion.getContent()).isEqualTo("지원 동기와 입사 후 목표를 작성해주세요."); - assertThat(savedQuestion.getLimit()).isEqualTo(1000); - assertThat(savedQuestion.getAnswer()).isEqualTo("저는 백엔드 개발 경험을 바탕으로 지원했습니다."); - } - - @Test - @DisplayName("답변 저장은 선택 저장에서 허용한 짧은 문항 내용도 동일하게 허용한다") - void saveAnswersAllowsShortQuestionContentAcceptedBySelectionSave() { - User user = saveUser("answer-short-question@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("새 문항", 700, true) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "새 문항", 700, "새 문항 답변입니다.") - ))); - - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(response.questions().get(0).answer()).isEqualTo("새 문항 답변입니다."); - } - - @Test - @DisplayName("답변 저장 요청은 answers 필드명도 questions와 동일하게 역직렬화한다") - void questionAnswerSaveRequestAcceptsAnswersAlias() throws Exception { - QuestionAnswerSaveRequest request = objectMapper.readValue( - """ - { - "answers": [ - { - "questionId": 1, - "content": "새 문항", - "charLimit": 700, - "answer": "새 문항 답변입니다." - } - ] - } - """, - QuestionAnswerSaveRequest.class - ); - - assertThat(request.questions()).hasSize(1); - assertThat(request.questions().get(0).questionId()).isEqualTo(1L); - assertThat(request.questions().get(0).content()).isEqualTo("새 문항"); - assertThat(request.questions().get(0).answer()).isEqualTo("새 문항 답변입니다."); - } - - @Test - @DisplayName("답변 저장 시 문항 추가 수정 삭제와 글자수 제한을 화면 상태대로 동기화한다") - void saveAnswersSyncsQuestionList() { - User user = saveUser("answer-sync@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 1", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("기존 문항 2", 800, false) - ))); - Long retainedQuestionId = selected.questions().get(0).questionId(); - Long deletedQuestionId = selected.questions().get(1).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem( - retainedQuestionId, - "수정된 기존 문항", - 1000, - "수정된 기존 문항 답변입니다." - ), - new QuestionAnswerSaveRequest.QuestionAnswerItem( - null, - "새로 추가한 문항", - 700, - "새로 추가한 문항 답변입니다." - ) - ))); - - assertThat(response.questions()).hasSize(2); - assertThat(response.questions()) - .extracting("content", "charLimit", "answer") - .containsExactly( - tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), - tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") - ); - assertThat(questionRepository.findById(deletedQuestionId)).isEmpty(); - assertThat(questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId())) - .extracting(Question::getContent, Question::getLimit, Question::getAnswer) - .containsExactly( - tuple("수정된 기존 문항", 1000, "수정된 기존 문항 답변입니다."), - tuple("새로 추가한 문항", 700, "새로 추가한 문항 답변입니다.") - ); - assertThat(mockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - } - - @Test - @DisplayName("답변 저장 응답은 같은 공고 기준 현재 지원 순번을 반환한다") - void saveAnswersReturnsSequence() { - User user = saveUser("answer-sequence@example.com"); - JobPosting jobPosting = saveJobPosting(); - mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - MockApply secondMockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, secondMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("재지원 답변 문항입니다.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, secondMockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "재지원 답변 문항입니다.", 700, "두 번째 지원 답변입니다.") - ))); - - assertThat(response.mockApplyId()).isEqualTo(secondMockApply.getId()); - assertThat(response.sequence()).isEqualTo(2); - } - - @Test - @DisplayName("답변 저장 응답은 저장된 지원 순번을 우선 반환한다") - void saveAnswersReturnsStoredSequence() { - User user = saveUser("answer-stored-sequence@example.com"); - JobPosting jobPosting = saveJobPosting(); - MockApply mockApply = mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL, 4)); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("재지원 저장 순번 문항입니다.", 700, false) - ))); - Long questionId = selected.questions().get(0).questionId(); - - QuestionAnswerResponse response = questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(questionId, "재지원 저장 순번 문항입니다.", 700, "네 번째 지원 답변입니다.") - ))); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.sequence()).isEqualTo(4); - } - - @Test - @DisplayName("해당 지원서에 속하지 않은 문항은 답변 저장에 사용할 수 없다") - void saveAnswersThrowsWhenQuestionDoesNotBelongToMockApply() { - User user = saveUser("answer-invalid-question@example.com"); - MockApply mockApply = saveMockApply(user); - MockApply otherMockApply = saveMockApply(user); - QuestionSelectionResponse otherSelected = questionService.saveSelectedQuestions(user, otherMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("다른 지원서 문항", 700, false) - ))); - Long otherQuestionId = otherSelected.questions().get(0).questionId(); - - assertThatThrownBy(() -> questionService.saveAnswers(user, mockApply.getId(), new QuestionAnswerSaveRequest(List.of( - new QuestionAnswerSaveRequest.QuestionAnswerItem(otherQuestionId, "다른 지원서 문항", 700, "답변") - )))) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.QUESTION_NOT_FOUND); - } - - @Test - @DisplayName("선택 문항 1개를 삭제하고 남은 문항 목록을 반환한다") - void deleteQuestion() { - User user = saveUser("question-delete@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("삭제할 문항", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("남길 문항", 800, false) - ))); - Long deletedQuestionId = selected.questions().get(0).questionId(); - - QuestionSelectionResponse response = questionService.deleteQuestion(user, mockApply.getId(), deletedQuestionId); - - assertThat(response.mockApplyId()).isEqualTo(mockApply.getId()); - assertThat(response.questions()).hasSize(1); - assertThat(response.questions().get(0).content()).isEqualTo("남길 문항"); - assertThat(questionRepository.findById(deletedQuestionId)).isEmpty(); - assertThat(questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId())) - .extracting(Question::getContent) - .containsExactly("남길 문항"); - } - - @Test - @DisplayName("문항 삭제 시 해당 문항의 분석 상세도 함께 삭제한다") - void deleteQuestionDeletesQuestionAnalyses() { - User user = saveUser("question-delete-analysis@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("삭제할 분석 문항", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("남길 분석 문항", 800, false) - ))); - Question deletedQuestion = questionRepository.findById(selected.questions().get(0).questionId()).orElseThrow(); - Question retainedQuestion = questionRepository.findById(selected.questions().get(1).questionId()).orElseThrow(); - Analysis analysis = analysisRepository.save(Analysis.create(mockApply, 80, 80, 80, 80, "분석 피드백")); - QuestionAnalysis deletedQuestionAnalysis = questionAnalysisRepository.save(QuestionAnalysis.create( - deletedQuestion, - analysis, - "삭제할 문장", - "삭제할 이유", - "삭제할 개선안", - QuestionAnalysisStatus.MENTIONED, - 0, - 5 - )); - QuestionAnalysis retainedQuestionAnalysis = questionAnalysisRepository.save(QuestionAnalysis.create( - retainedQuestion, - analysis, - "남길 문장", - "남길 이유", - "남길 개선안", - QuestionAnalysisStatus.PROVEN, - 0, - 4 - )); - - questionService.deleteQuestion(user, mockApply.getId(), deletedQuestion.getId()); - - assertThat(questionAnalysisRepository.findById(deletedQuestionAnalysis.getId())).isEmpty(); - assertThat(questionAnalysisRepository.findById(retainedQuestionAnalysis.getId())).isPresent(); - } - - @Test - @DisplayName("마지막 남은 문항은 삭제할 수 없다") - void deleteQuestionThrowsWhenOnlyOneQuestionRemains() { - User user = saveUser("question-delete-last@example.com"); - MockApply mockApply = saveMockApply(user); - QuestionSelectionResponse selected = questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("마지막 문항", 700, false) - ))); - - assertThatThrownBy(() -> questionService.deleteQuestion(user, mockApply.getId(), selected.questions().get(0).questionId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.INVALID_PARAMETER); - assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).hasSize(1); - } - - @Test - @DisplayName("해당 지원서에 속하지 않은 문항은 삭제할 수 없다") - void deleteQuestionThrowsWhenQuestionDoesNotBelongToMockApply() { - User user = saveUser("question-delete-invalid@example.com"); - MockApply mockApply = saveMockApply(user); - MockApply otherMockApply = saveMockApply(user); - questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("현재 지원서 문항 1", 700, false), - new QuestionSelectionSaveRequest.QuestionSelectionItem("현재 지원서 문항 2", 700, false) - ))); - QuestionSelectionResponse otherSelected = questionService.saveSelectedQuestions(user, otherMockApply.getId(), new QuestionSelectionSaveRequest(List.of( - new QuestionSelectionSaveRequest.QuestionSelectionItem("다른 지원서 문항", 700, false) - ))); - - assertThatThrownBy(() -> questionService.deleteQuestion(user, mockApply.getId(), otherSelected.questions().get(0).questionId())) - .isInstanceOf(GeneralException.class) - .extracting("code") - .isEqualTo(GeneralErrorCode.QUESTION_NOT_FOUND); - } - - private User saveUser(String email) { - return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); - } - - private MockApply saveMockApply(User user) { - JobPosting jobPosting = saveJobPosting(); - return mockApplyRepository.save(MockApply.create(user, jobPosting, ApplyType.ACTUAL)); - } - - private JobPosting saveJobPosting() { - User user = saveUser("question-jobposting" + System.nanoTime() + "@example.com"); - Company company = companyRepository.save(Company.create("테스트 기업", CompanySize.MEDIUM)); - DetailClassification detailClassification = saveDetailClassification(); - return jobPostingRepository.save(JobPosting.create( - user, - company, - detailClassification, - "주요 업무", - "자격 요건", - "우대 사항" - )); - } - - private DetailClassification saveDetailClassification() { - Classification classification = Classification.create("테스트 대분류 " + System.nanoTime()); - MiddleClassification middleClassification = classification.addMiddleClassification("테스트 중분류"); - DetailClassification detailClassification = middleClassification.addDetailClassification("테스트 소분류"); - classificationRepository.save(classification); - return detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(); - } -} From 4ea5046caeeb3b472f584aa74063ba7188f39ac7 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:49:15 +0900 Subject: [PATCH 30/55] =?UTF-8?q?[Fix]=20analysis=20async=20=EC=9C=A0?= =?UTF-8?q?=EC=8B=A4=20=EB=B3=B5=EA=B5=AC=EC=99=80=20task=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/entity/AnalysisAsyncTask.java | 7 ++- .../AnalysisAsyncTaskSweepCoordinator.java | 2 +- .../async/AnalysisAsyncWorkerBridge.java | 38 +++++++----- .../AnalysisAsyncTaskRepository.java | 8 +++ .../async/AnalysisAsyncTaskService.java | 10 +-- .../AnalysisWorkerBridgeServiceTest.java | 62 ++++++++++++++++--- 6 files changed, 98 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index be9041f0..3dac990f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -151,7 +151,7 @@ public void markRunning(String workerId, int retryCount, Instant messageSubmitte } public void markSuccess() { - if (isTerminal()) { + if (isTerminal() && !isRecoverablePublishFailure()) { return; } this.status = AnalysisAsyncTaskStatus.SUCCEEDED; @@ -164,6 +164,11 @@ public void markSuccess() { this.estimatedRemainingSeconds = 0; } + public boolean isRecoverablePublishFailure() { + return status == AnalysisAsyncTaskStatus.FAILED + && failureReason == AnalysisAsyncFailureReason.PUBLISH_FAILED; + } + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java index 026b07b0..7723c01c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -56,7 +56,7 @@ public int sweepTimedOutTasks() { } private int sweepTimedOutTask(String taskId) { - AnalysisAsyncTask task = analysisAsyncTaskRepository.findById(taskId).orElse(null); + AnalysisAsyncTask task = analysisAsyncTaskRepository.findByIdForUpdate(taskId).orElse(null); if (task == null || task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED || task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { return 0; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index 7268a7fb..00a95e9d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -70,7 +70,7 @@ public AnalysisAsyncWorkerBridge( @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { - AnalysisAsyncTask task = getTask(taskId); + AnalysisAsyncTask task = getTaskForUpdate(taskId); if (isTerminal(task)) { return; } @@ -89,7 +89,7 @@ public void markRetry( String workerId, Long queueLatencyMillis ) { - AnalysisAsyncTask task = getTask(taskId); + AnalysisAsyncTask task = getTaskForUpdate(taskId); if (isTerminal(task)) { return; } @@ -109,7 +109,7 @@ public void failTask( String workerId, Long queueLatencyMillis ) { - AnalysisAsyncTask task = getTask(taskId); + AnalysisAsyncTask task = getTaskForUpdate(taskId); if (isTerminal(task)) { return; } @@ -124,7 +124,7 @@ public void failTask( @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { - AnalysisAsyncTask task = getTask(taskId); + AnalysisAsyncTask task = getTaskForUpdate(taskId); rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { throw new GeneralException( @@ -163,7 +163,7 @@ public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long @Transactional public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { - AnalysisAsyncTask task = getTask(taskId); + AnalysisAsyncTask task = getTaskForUpdate(taskId); if (!task.getUserId().equals(request.userId()) || !task.getMockApplyId().equals(request.mockApplyId())) { throw new GeneralException( GeneralErrorCode.FORBIDDEN, @@ -191,15 +191,17 @@ public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteReques return analysisService.getAnalysis(userService.getUser(request.userId()), request.mockApplyId()); } if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { - workerTaskResultService.markDeliveryFailedIfPresent( - TaskType.ANALYSIS_COMPLETE, - taskId, - "이미 실패 처리된 자소서 분석 비동기 작업입니다." - ); - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); + if (!task.isRecoverablePublishFailure()) { + workerTaskResultService.markDeliveryFailedIfPresent( + TaskType.ANALYSIS_COMPLETE, + taskId, + "이미 실패 처리된 자소서 분석 비동기 작업입니다." + ); + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } } User user = userService.getUser(request.userId()); @@ -308,6 +310,14 @@ private AnalysisAsyncTask getTask(String taskId) { )); } + private AnalysisAsyncTask getTaskForUpdate(String taskId) { + return analysisAsyncTaskRepository.findByIdForUpdate(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + } + private boolean isTerminal(AnalysisAsyncTask task) { return task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED || task.getStatus() == AnalysisAsyncTaskStatus.FAILED diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index ec43ed7b..de6ea0d3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -2,7 +2,11 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -24,4 +28,8 @@ List findByUserIdAndMockApplyIdInAndStatusIn( ); List findByStatusIn(Collection statuses); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select task from AnalysisAsyncTask task where task.taskId = :taskId") + Optional findByIdForUpdate(@Param("taskId") String taskId); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index 38f43ba2..9696c623 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -105,10 +105,12 @@ public void markSuccess(String taskId, AnalysisResponse result) { ); } if (task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); + if (!task.isRecoverablePublishFailure()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "이미 실패 처리된 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } } task.markSuccess(); recordProcessingMetric(task, "succeeded"); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index d5bb7d9a..bdf7d163 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -93,7 +93,7 @@ void completeTaskRejectsCancelledTaskWithoutSideEffects() { 10L ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); assertThatThrownBy(() -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request)) .isInstanceOf(GeneralException.class); @@ -128,7 +128,7 @@ void getContextRejectsCancelledTaskWithoutCreditReservation() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); task.requestCancel(); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); assertThatThrownBy(() -> analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L)) .isInstanceOf(GeneralException.class); @@ -188,7 +188,7 @@ void getContextReservesCreditBeforePreparingExecution() { List.of(similarContext) ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); when(analysisCreditService.createAsyncReferenceId(task.getTaskId())) @@ -239,7 +239,7 @@ void getContextDoesNotReserveCreditTwice() { List.of() ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); @@ -258,7 +258,7 @@ void failTaskReleasesReservedCreditOnlyOnce() { User user = User.signup("테스트 사용자", "analysis-worker-refund@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); doAnswer(invocation -> { task.markCreditReleased(); @@ -340,7 +340,7 @@ void completeTaskReusesInitialExecutionSnapshot() { 1L, 10L, llmResponse, "worker-1", 10L ); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); AnalysisExecutionPayload changedRetrievalPayload = new AnalysisExecutionPayload( 1L, 10L, jobPosting, List.of(), List.of(), null, null, List.of(laterContext) @@ -381,7 +381,7 @@ void terminalTaskDoesNotReopen() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); task.markSuccess(); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); analysisWorkerBridgeService.markRunning(task.getTaskId(), "worker-1", 1, java.time.Instant.now()); analysisWorkerBridgeService.markRetry( @@ -401,7 +401,7 @@ void terminalTaskDoesNotReopen() { @DisplayName("완료 요청의 사용자나 mockApply가 task와 다르면 거부한다") void completeTaskRejectsMismatchedIdentity() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( 2L, @@ -459,7 +459,7 @@ void completeTaskMarksDeliveredForSucceededTask() { ReflectionTestUtils.setField(user, "id", 1L); var llmResponse = mock(com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse.class); - when(analysisAsyncTaskRepository.findById(task.getTaskId())).thenReturn(Optional.of(task)); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.getAnalysis(user, 10L)).thenReturn(mock(com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse.class)); @@ -481,4 +481,48 @@ void completeTaskMarksDeliveredForSucceededTask() { ); inOrder.verify(workerTaskResultService).markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, task.getTaskId()); } + + @Test + @DisplayName("PUBLISH_FAILED 이후 늦게 도착한 complete 요청은 정상 완료로 복구한다") + void completeTaskRecoversAfterPublishFailure() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + task.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + User user = User.signup("테스트 사용자", "analysis-recover@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + ReflectionTestUtils.setField(task, "executionContextSnapshot", """ + {"userId":1,"mockApplyId":10,"companyName":"","jobTitle":"","task":"","requirements":"","preferredQualifications":"","bigClassificationName":"","middleClassificationName":"","detailClassificationName":"","questions":[],"corpusReferences":[],"similarJobPostings":[]} + """); + ReflectionTestUtils.setField(task, "inputFingerprintSnapshot", "publish-fingerprint"); + + AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); + AnalysisResponse response = mock(AnalysisResponse.class); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, + 10L, + llmResponse, + "worker-1", + 15L + ); + + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); + when(analysisService.finalizeAnalysis(user, 10L, completionPayload, llmResponse, "publish-fingerprint")) + .thenReturn(response); + + AnalysisResponse completed = analysisWorkerBridgeService.completeTask(task.getTaskId(), request); + + assertThat(completed).isEqualTo(response); + verify(analysisAsyncTaskService).markSuccess(task.getTaskId(), response); + verify(workerTaskResultService).markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, task.getTaskId()); + } } From ddfe7e7028f555e3ad640ecf2c4025b1f403c899 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 11 Aug 2026 15:54:22 +0900 Subject: [PATCH 31/55] =?UTF-8?q?[Fix]=20analysis=20getContext=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=BD=EA=B3=84=EC=99=80=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncWorkerBridge.java | 152 +++++++++++++----- .../async/AnalysisWorkerBridgeService.java | 7 +- .../AnalysisWorkerBridgeServiceTest.java | 16 ++ 3 files changed, 137 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index 00a95e9d..b742e801 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -30,15 +30,21 @@ import com.jobdri.jobdri_api.global.logging.LoggingMdcKeys; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; @Slf4j // 외부 분석 워커와 내부 분석 도메인 상태를 연결한다. public class AnalysisAsyncWorkerBridge { + private static final ConcurrentMap CONTEXT_LOCKS = new ConcurrentHashMap<>(); + private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisService analysisService; @@ -47,6 +53,7 @@ public class AnalysisAsyncWorkerBridge { private final WorkerTaskResultService workerTaskResultService; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; public AnalysisAsyncWorkerBridge( AnalysisAsyncTaskService analysisAsyncTaskService, @@ -56,7 +63,8 @@ public AnalysisAsyncWorkerBridge( UserService userService, WorkerTaskResultService workerTaskResultService, AnalysisInputFingerprintProvider analysisInputFingerprintProvider, - ObjectMapper objectMapper + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate ) { this.analysisAsyncTaskService = analysisAsyncTaskService; this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; @@ -66,6 +74,7 @@ public AnalysisAsyncWorkerBridge( this.workerTaskResultService = workerTaskResultService; this.analysisInputFingerprintProvider = analysisInputFingerprintProvider; this.objectMapper = objectMapper; + this.transactionTemplate = transactionTemplate; } @Transactional @@ -122,43 +131,39 @@ public void failTask( } } - @Transactional public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { - AnalysisAsyncTask task = getTaskForUpdate(taskId); - rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + taskId); - if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { - throw new GeneralException( - GeneralErrorCode.FORBIDDEN, - "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ReentrantLock contextLock = CONTEXT_LOCKS.computeIfAbsent(taskId, ignored -> new ReentrantLock()); + contextLock.lock(); + try { + ContextAccess contextAccess = transactionTemplate.execute( + status -> loadContextAccess(taskId, userId, mockApplyId) ); - } - reserveCreditIfNeeded(task); - if (task.getExecutionContextSnapshot() != null) { - return readContextSnapshot(task); - } + if (contextAccess == null) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "자소서 분석 worker 컨텍스트를 초기화할 수 없습니다. taskId=" + taskId + ); + } + if (contextAccess.snapshot() != null) { + return contextAccess.snapshot(); + } - User user = userService.getUser(userId); - AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(user, mockApplyId); - AnalysisWorkerContextResponse context = new AnalysisWorkerContextResponse( - userId, - mockApplyId, - payload.jobPosting().getCompany().getName(), - payload.jobPosting().getDetailClassification().getDetailName(), - payload.jobPosting().getTask(), - payload.jobPosting().getRequirement(), - payload.jobPosting().getPreferred(), - payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), - payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), - payload.jobPosting().getDetailClassification().getDetailName(), - toQuestionItems(payload.questions()), - CorpusReferenceContext.from(payload.retrievalContext()), - payload.similarJobPostings() - ); - task.captureExecutionSnapshot( - writeContextSnapshot(context), - analysisInputFingerprintProvider.create(payload) - ); - return context; + AnalysisExecutionPayload payload = analysisService.prepareAnalysisExecution(userService.getUser(userId), mockApplyId); + AnalysisWorkerContextResponse context = buildContext(userId, mockApplyId, payload); + String contextSnapshot = writeContextSnapshot(context); + String inputFingerprint = analysisInputFingerprintProvider.create(payload); + return transactionTemplate.execute( + status -> persistContextSnapshot(taskId, userId, mockApplyId, context, contextSnapshot, inputFingerprint) + ); + } catch (RuntimeException exception) { + releaseCreditAfterContextFailure(taskId); + throw exception; + } finally { + contextLock.unlock(); + if (!contextLock.hasQueuedThreads()) { + CONTEXT_LOCKS.remove(taskId, contextLock); + } + } } @Transactional @@ -272,6 +277,66 @@ private List toQuestio .toList(); } + private ContextAccess loadContextAccess(String taskId, Long userId, Long mockApplyId) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + validateContextRequest(task, userId, mockApplyId); + if (task.getExecutionContextSnapshot() != null) { + return new ContextAccess(readContextSnapshot(task)); + } + reserveCreditIfNeeded(task); + return new ContextAccess(null); + } + + private AnalysisWorkerContextResponse persistContextSnapshot( + String taskId, + Long userId, + Long mockApplyId, + AnalysisWorkerContextResponse context, + String contextSnapshot, + String inputFingerprint + ) { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + validateContextRequest(task, userId, mockApplyId); + if (task.getExecutionContextSnapshot() != null) { + return readContextSnapshot(task); + } + reserveCreditIfNeeded(task); + task.captureExecutionSnapshot(contextSnapshot, inputFingerprint); + return context; + } + + private void validateContextRequest(AnalysisAsyncTask task, Long userId, Long mockApplyId) { + rejectIfCancelled(task, "취소된 자소서 분석 작업입니다. taskId=" + task.getTaskId()); + if (!task.getUserId().equals(userId) || !task.getMockApplyId().equals(mockApplyId)) { + throw new GeneralException( + GeneralErrorCode.FORBIDDEN, + "자소서 분석 worker 컨텍스트 요청 정보가 작업 정보와 일치하지 않습니다." + ); + } + } + + private AnalysisWorkerContextResponse buildContext( + Long userId, + Long mockApplyId, + AnalysisExecutionPayload payload + ) { + return new AnalysisWorkerContextResponse( + userId, + mockApplyId, + payload.jobPosting().getCompany().getName(), + payload.jobPosting().getDetailClassification().getDetailName(), + payload.jobPosting().getTask(), + payload.jobPosting().getRequirement(), + payload.jobPosting().getPreferred(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getClassification().getBigName(), + payload.jobPosting().getDetailClassification().getMiddleClassification().getMiddleName(), + payload.jobPosting().getDetailClassification().getDetailName(), + toQuestionItems(payload.questions()), + CorpusReferenceContext.from(payload.retrievalContext()), + payload.similarJobPostings() + ); + } + private String writeContextSnapshot(AnalysisWorkerContextResponse context) { try { return objectMapper.writeValueAsString(context); @@ -331,7 +396,8 @@ private void rejectIfCancelled(AnalysisAsyncTask task, String message) { } private void reserveCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != AnalysisAsyncCreditStatus.NONE) { + if (task.getCreditStatus() == AnalysisAsyncCreditStatus.RESERVED + || task.getCreditStatus() == AnalysisAsyncCreditStatus.CONFIRMED) { return; } @@ -357,6 +423,17 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { analysisAsyncTaskService.markCreditReleased(task.getTaskId()); } + private void releaseCreditAfterContextFailure(String taskId) { + transactionTemplate.execute(status -> { + AnalysisAsyncTask task = getTaskForUpdate(taskId); + if (task.getExecutionContextSnapshot() != null) { + return null; + } + releaseCreditIfNeeded(task); + return null; + }); + } + private Map workerContext( String taskId, String taskType, @@ -378,4 +455,7 @@ private Map workerContext( } return context; } + + private record ContextAccess(AnalysisWorkerContextResponse snapshot) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index 07b9779c..b21ccb91 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -10,6 +10,7 @@ import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; @Service // 외부 분석 워커와 내부 분석 도메인 상태를 연결해 주는 브리지 서비스다. @@ -23,7 +24,8 @@ public AnalysisWorkerBridgeService( UserService userService, WorkerTaskResultService workerTaskResultService, AnalysisInputFingerprintProvider analysisInputFingerprintProvider, - ObjectMapper objectMapper + ObjectMapper objectMapper, + TransactionTemplate transactionTemplate ) { super( analysisAsyncTaskService, @@ -33,7 +35,8 @@ public AnalysisWorkerBridgeService( userService, workerTaskResultService, analysisInputFingerprintProvider, - objectMapper + objectMapper, + transactionTemplate ); } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index bdf7d163..b748aeec 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -23,6 +23,7 @@ import com.jobdri.jobdri_api.domain.workerresult.entity.WorkerTaskResult.TaskType; import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,6 +34,8 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; import java.util.List; import java.util.Optional; @@ -46,6 +49,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -74,12 +78,24 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisInputFingerprintProvider analysisInputFingerprintProvider; + @Mock + private TransactionTemplate transactionTemplate; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); @InjectMocks private AnalysisWorkerBridgeService analysisWorkerBridgeService; + @BeforeEach + void setUp() { + lenient().when(transactionTemplate.execute(any(TransactionCallback.class))) + .thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + } + @Test @DisplayName("취소된 task의 complete 요청은 결과 저장과 성공 처리를 하지 않는다") void completeTaskRejectsCancelledTaskWithoutSideEffects() { From 4539229f5f3a30d53b115fafc5cc093828f8c190 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:32:09 +0900 Subject: [PATCH 32/55] =?UTF-8?q?[Fix]=20async=20sweep=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/async/AnalysisAsyncUseCase.java | 17 ++++++- .../analysis/entity/AnalysisAsyncTask.java | 19 +++++++ .../AnalysisAsyncTaskRepository.java | 8 +++ .../async/AnalysisAsyncTaskService.java | 25 +++++++++ .../async/AnalysisAsyncFacadeServiceTest.java | 51 +++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java index c5f9a4a4..602b9db3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java @@ -15,6 +15,8 @@ import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; +import java.util.Optional; + // 분석 비동기 작업의 접수/조회/취소 유스케이스를 조율한다. public class AnalysisAsyncUseCase { private static final String ACTIVE_TASK_UNIQUE_CONSTRAINT = "uk_analysis_async_tasks_active_user_mock_apply"; @@ -64,6 +66,11 @@ public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String ta } private AnalysisAsyncSubmitResponse createCachedOrProcessTask(User user, Long mockApplyId) { + Optional recoverableTask = + analysisAsyncTaskService.findRecoverablePublishFailureTask(user.getId(), mockApplyId); + if (recoverableTask.isPresent()) { + return reopenAndProcessTask(user, mockApplyId, recoverableTask.get()); + } if (analysisService.hasReusableAnalysis(user, mockApplyId)) { return toCachedResponse(); } @@ -77,8 +84,16 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp } AnalysisAsyncTask task = pendingTaskResult.task(); - String taskId = task.getTaskId(); + return processTask(user, mockApplyId, task); + } + private AnalysisAsyncSubmitResponse reopenAndProcessTask(User user, Long mockApplyId, AnalysisAsyncTask task) { + AnalysisAsyncTask reopenedTask = analysisAsyncTaskService.reopenPublishFailureTask(task.getTaskId()); + return processTask(user, mockApplyId, reopenedTask); + } + + private AnalysisAsyncSubmitResponse processTask(User user, Long mockApplyId, AnalysisAsyncTask task) { + String taskId = task.getTaskId(); try { analysisAsyncProcessor.process( taskId, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 3dac990f..99d5c4e0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -169,6 +169,25 @@ public boolean isRecoverablePublishFailure() { && failureReason == AnalysisAsyncFailureReason.PUBLISH_FAILED; } + public void reopenForRepublish() { + if (!isRecoverablePublishFailure()) { + return; + } + this.status = AnalysisAsyncTaskStatus.PENDING; + this.message = "자소서 분석 비동기 작업이 다시 접수되었습니다."; + this.error = null; + this.failureReason = null; + this.workerId = null; + this.submittedAt = LocalDateTime.now(); + this.lastAttemptAt = null; + this.queueLatencyMillis = null; + this.startedAt = null; + this.completedAt = null; + this.currentStep = "VALIDATING_INPUT"; + this.progressPercent = 0; + this.estimatedRemainingSeconds = null; + } + public void markRetryScheduled(AnalysisAsyncFailureReason failureReason, String errorMessage, int retryCount) { if (isTerminal()) { return; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index de6ea0d3..cfc11807 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.repository; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; @@ -21,6 +22,13 @@ Optional findFirstByUserIdAndMockApplyIdAndStatusInOrderByCre Collection statuses ); + Optional findFirstByUserIdAndMockApplyIdAndStatusAndFailureReasonOrderByCreatedAtDesc( + Long userId, + Long mockApplyId, + AnalysisAsyncTaskStatus status, + AnalysisAsyncFailureReason failureReason + ); + List findByUserIdAndMockApplyIdInAndStatusIn( Long userId, Collection mockApplyIds, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index 9696c623..ea312e61 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -82,6 +82,17 @@ public Optional findActiveTask(Long userId, Long mockApplyId) ); } + @Transactional(readOnly = true) + public Optional findRecoverablePublishFailureTask(Long userId, Long mockApplyId) { + return analysisAsyncTaskRepository + .findFirstByUserIdAndMockApplyIdAndStatusAndFailureReasonOrderByCreatedAtDesc( + userId, + mockApplyId, + AnalysisAsyncTaskStatus.FAILED, + AnalysisAsyncFailureReason.PUBLISH_FAILED + ); + } + @Transactional public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { AnalysisAsyncTask task = getTask(taskId); @@ -185,6 +196,20 @@ public void markCreditReleased(String taskId) { getTask(taskId).markCreditReleased(); } + @Transactional + public AnalysisAsyncTask reopenPublishFailureTask(String taskId) { + AnalysisAsyncTask task = getTask(taskId); + if (!task.isRecoverablePublishFailure()) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "재접수할 수 없는 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); + } + task.reopenForRepublish(); + publishAfterCommit(toStatusResponse(task)); + return task; + } + @Transactional(readOnly = true) public AnalysisAsyncCreditStatus getCreditStatus(String taskId) { return getTask(taskId).getCreditStatus(); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index 37eb34cc..c54c894f 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -117,6 +117,31 @@ void submitDoesNotReserveCreditBeforeWorkerStarts() { verify(analysisAsyncProcessor, times(1)).process(createdTask.getTaskId(), 1L, 10L, 3); } + @Test + @DisplayName("이전 PUBLISH_FAILED task가 있으면 새 task를 만들지 않고 같은 task를 재접수한다") + void submitReopensRecoverablePublishFailureTask() { + User user = User.signup("테스트 사용자", "analysis-async-recoverable@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())).thenReturn(failedTask); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(failedTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.cached()).isFalse(); + assertThat(response.resultAvailable()).isFalse(); + verify(analysisAsyncTaskService, never()).createPendingTask(1L, 10L); + verify(analysisService, never()).hasReusableAnalysis(user, 10L); + verify(analysisAsyncTaskService).reopenPublishFailureTask(failedTask.getTaskId()); + verify(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); + } + @Test @DisplayName("메시지 발행 실패 시 task를 삭제하지 않고 PUBLISH_FAILED로 실패 처리한다") void submitMarksTaskFailedWhenPublishFails() { @@ -143,6 +168,32 @@ void submitMarksTaskFailedWhenPublishFails() { verify(analysisAsyncTaskService, never()).deleteTask(createdTask.getTaskId()); } + @Test + @DisplayName("재접수한 PUBLISH_FAILED task의 발행이 다시 실패하면 같은 task를 다시 실패 처리한다") + void submitMarksReopenedTaskFailedWhenPublishFailsAgain() { + User user = User.signup("테스트 사용자", "analysis-async-republish-fail@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + RuntimeException publishException = new RuntimeException("publish failed again"); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())).thenReturn(failedTask); + doThrow(publishException).when(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); + + assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) + .isSameAs(publishException); + + verify(analysisAsyncTaskService).markFailed( + failedTask.getTaskId(), + AnalysisAsyncFailureReason.PUBLISH_FAILED, + "자소서 분석 비동기 작업 발행에 실패했습니다.", + failedTask.getRetryCount() + ); + } + @Test @DisplayName("제약 조건명이 없으면 메시지 기반으로 중복 충돌로 분류하지 않는다") void submitDoesNotTreatMessageOnlyConflictAsDuplicate() { From 78ea1c26a1fa9af49344af2adcba64c70bdaed94 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:36:19 +0900 Subject: [PATCH 33/55] =?UTF-8?q?[Refactor]=20async=20timeout=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20batch=20query=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisAsyncTaskSweepCoordinator.java | 62 ++++++++- .../AnalysisAsyncTaskRepository.java | 23 +++- ...AnalysisAsyncTaskSweepCoordinatorTest.java | 121 ++++++++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java index 7723c01c..65d1589c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -11,15 +11,18 @@ import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.transaction.support.TransactionTemplate; import java.time.Duration; import java.time.LocalDateTime; -import java.util.EnumSet; +import java.util.List; @Slf4j // timeout/retry 기준으로 만료된 분석 async task를 정리한다. public class AnalysisAsyncTaskSweepCoordinator { + private static final int SWEEP_BATCH_SIZE = 100; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisCreditService analysisCreditService; @@ -44,18 +47,58 @@ public AnalysisAsyncTaskSweepCoordinator( } public int sweepTimedOutTasks() { + LocalDateTime now = LocalDateTime.now(); + int expiredCount = sweepTimedOutPendingTasks(now); + expiredCount += sweepTimedOutRunningTasks(now); + return expiredCount; + } + + private int sweepTimedOutPendingTasks(LocalDateTime now) { + LocalDateTime deadline = now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds()); + return sweepTimedOutTaskIds( + () -> analysisAsyncTaskRepository.findTimedOutPendingTaskIds( + deadline, + PageRequest.of(0, SWEEP_BATCH_SIZE) + ) + ); + } + + private int sweepTimedOutRunningTasks(LocalDateTime now) { + LocalDateTime deadline = now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds()); + return sweepTimedOutTaskIds( + () -> analysisAsyncTaskRepository.findTimedOutRunningTaskIds( + deadline, + PageRequest.of(0, SWEEP_BATCH_SIZE) + ) + ); + } + + private int sweepTimedOutTaskIds(TaskIdBatchLoader taskIdBatchLoader) { int expiredCount = 0; - for (AnalysisAsyncTask task : analysisAsyncTaskRepository.findByStatusIn(EnumSet.of(AnalysisAsyncTaskStatus.PENDING, AnalysisAsyncTaskStatus.RUNNING))) { - try { - expiredCount += transactionTemplate.execute(status -> sweepTimedOutTask(task.getTaskId())); - } catch (RuntimeException e) { - log.error("Analysis async task sweep failed for taskId={}", task.getTaskId(), e); + while (true) { + List taskIds = taskIdBatchLoader.load(); + if (taskIds.isEmpty()) { + return expiredCount; + } + for (String taskId : taskIds) { + expiredCount += sweepTimedOutTask(taskId); + } + if (taskIds.size() < SWEEP_BATCH_SIZE) { + return expiredCount; } } - return expiredCount; } private int sweepTimedOutTask(String taskId) { + try { + return transactionTemplate.execute(status -> sweepTimedOutTaskInTransaction(taskId)); + } catch (RuntimeException e) { + log.error("Analysis async task sweep failed for taskId={}", taskId, e); + return 0; + } + } + + private int sweepTimedOutTaskInTransaction(String taskId) { AnalysisAsyncTask task = analysisAsyncTaskRepository.findByIdForUpdate(taskId).orElse(null); if (task == null || task.getStatus() == AnalysisAsyncTaskStatus.SUCCEEDED || task.getStatus() == AnalysisAsyncTaskStatus.FAILED) { return 0; @@ -117,4 +160,9 @@ private void releaseCreditIfNeeded(AnalysisAsyncTask task) { private record ExpirationDecision(AnalysisAsyncFailureReason failureReason, String errorMessage) { } + + @FunctionalInterface + private interface TaskIdBatchLoader { + List load(); + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index cfc11807..bd407c6a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -6,6 +6,7 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -35,7 +36,27 @@ List findByUserIdAndMockApplyIdInAndStatusIn( Collection statuses ); - List findByStatusIn(Collection statuses); + @Query(""" + select task.taskId + from AnalysisAsyncTask task + where task.status = 'PENDING' + and task.submittedAt is not null + and task.submittedAt <= :deadline + order by task.submittedAt asc + """) + List findTimedOutPendingTaskIds(@Param("deadline") java.time.LocalDateTime deadline, Pageable pageable); + + @Query(""" + select task.taskId + from AnalysisAsyncTask task + where task.status = 'RUNNING' + and ( + (task.lastAttemptAt is not null and task.lastAttemptAt <= :deadline) + or (task.lastAttemptAt is null and task.startedAt is not null and task.startedAt <= :deadline) + ) + order by coalesce(task.lastAttemptAt, task.startedAt) asc + """) + List findTimedOutRunningTaskIds(@Param("deadline") java.time.LocalDateTime deadline, Pageable pageable); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select task from AnalysisAsyncTask task where task.taskId = :taskId") diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java new file mode 100644 index 00000000..672c47cd --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java @@ -0,0 +1,121 @@ +package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncTaskSweepCoordinatorTest { + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Mock + private AnalysisCreditService analysisCreditService; + + @Mock + private UserService userService; + + @Mock + private TransactionTemplate transactionTemplate; + + private AnalysisAsyncTaskSweepCoordinator analysisAsyncTaskSweepCoordinator; + private AnalysisQueueProperties analysisQueueProperties; + + @BeforeEach + void setUp() { + analysisQueueProperties = new AnalysisQueueProperties(); + analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( + analysisAsyncTaskRepository, + analysisAsyncTaskService, + analysisCreditService, + userService, + transactionTemplate, + analysisQueueProperties + ); + lenient().doAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(org.mockito.Mockito.mock(TransactionStatus.class)); + }).when(transactionTemplate).execute(any()); + } + + @Test + @DisplayName("sweep는 timeout 대상 task id만 batch 조회해 처리한다") + void sweepTimedOutTasksLoadsOnlyTimedOutTaskIdsInBatches() { + AnalysisAsyncTask pendingTask = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.setField(pendingTask, "submittedAt", LocalDateTime.now().minusMinutes(10)); + AnalysisAsyncTask runningTask = AnalysisAsyncTask.pending(2L, 20L, 3); + runningTask.markRunning("worker-1", 0, null); + ReflectionTestUtils.setField(runningTask, "lastAttemptAt", LocalDateTime.now().minusMinutes(20)); + + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of(pendingTask.getTaskId()), List.of()); + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of(runningTask.getTaskId()), List.of()); + when(analysisAsyncTaskRepository.findByIdForUpdate(pendingTask.getTaskId())).thenReturn(Optional.of(pendingTask)); + when(analysisAsyncTaskRepository.findByIdForUpdate(runningTask.getTaskId())).thenReturn(Optional.of(runningTask)); + + int expiredCount = analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); + + assertThat(expiredCount).isEqualTo(2); + verify(analysisAsyncTaskService).markFailed( + pendingTask.getTaskId(), + AnalysisAsyncFailureReason.QUEUE_TIMEOUT, + "자소서 분석 작업이 대기열에서 시간 내 처리되지 않았습니다.", + pendingTask.getRetryCount() + ); + verify(analysisAsyncTaskService).markFailed( + runningTask.getTaskId(), + AnalysisAsyncFailureReason.INTERNAL_ERROR, + "자소서 분석 작업이 처리 제한 시간을 초과했습니다.", + runningTask.getRetryCount() + ); + } + + @Test + @DisplayName("batch가 비어 있으면 추가 처리 없이 종료한다") + void sweepTimedOutTasksStopsWhenNoTimedOutTaskIdsExist() { + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of()); + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of()); + + int expiredCount = analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); + + assertThat(expiredCount).isZero(); + verify(analysisAsyncTaskRepository, never()).findByIdForUpdate(anyString()); + verify(analysisAsyncTaskService, never()).markFailed(anyString(), any(), anyString(), org.mockito.ArgumentMatchers.anyInt()); + } +} From 8e5574adcd56c8d8eb2492a5269c0d5004814ad4 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:37:43 +0900 Subject: [PATCH 34/55] =?UTF-8?q?[Refactor]=20async=20sweep=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20Clock?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B9=98=ED=99=98=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisAsyncTaskSweepCoordinator.java | 10 ++++-- .../async/AnalysisAsyncSweepService.java | 8 +++-- .../jobdri_api/global/config/ClockConfig.java | 15 +++++++++ ...AnalysisAsyncTaskSweepCoordinatorTest.java | 32 +++++++++++++++---- 4 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java index 65d1589c..39a028b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Clock; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -29,6 +30,7 @@ public class AnalysisAsyncTaskSweepCoordinator { private final UserService userService; private final TransactionTemplate transactionTemplate; private final AnalysisQueueProperties analysisQueueProperties; + private final Clock clock; public AnalysisAsyncTaskSweepCoordinator( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, @@ -36,7 +38,8 @@ public AnalysisAsyncTaskSweepCoordinator( AnalysisCreditService analysisCreditService, UserService userService, TransactionTemplate transactionTemplate, - AnalysisQueueProperties analysisQueueProperties + AnalysisQueueProperties analysisQueueProperties, + Clock clock ) { this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; this.analysisAsyncTaskService = analysisAsyncTaskService; @@ -44,10 +47,11 @@ public AnalysisAsyncTaskSweepCoordinator( this.userService = userService; this.transactionTemplate = transactionTemplate; this.analysisQueueProperties = analysisQueueProperties; + this.clock = clock; } public int sweepTimedOutTasks() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(clock); int expiredCount = sweepTimedOutPendingTasks(now); expiredCount += sweepTimedOutRunningTasks(now); return expiredCount; @@ -120,7 +124,7 @@ private int sweepTimedOutTaskInTransaction(String taskId) { } private ExpirationDecision resolveExpiration(AnalysisAsyncTask task) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(clock); if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING && isExpired(task.getSubmittedAt(), now, analysisQueueProperties.getQueueTimeoutSeconds())) { return new ExpirationDecision( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index af835182..da93ed30 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -7,6 +7,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Clock; + @Service public class AnalysisAsyncSweepService extends AnalysisAsyncTaskSweepCoordinator { @@ -16,7 +18,8 @@ public AnalysisAsyncSweepService( AnalysisCreditService analysisCreditService, UserService userService, TransactionTemplate transactionTemplate, - AnalysisQueueProperties analysisQueueProperties + AnalysisQueueProperties analysisQueueProperties, + Clock clock ) { super( analysisAsyncTaskRepository, @@ -24,7 +27,8 @@ public AnalysisAsyncSweepService( analysisCreditService, userService, transactionTemplate, - analysisQueueProperties + analysisQueueProperties, + clock ); } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java new file mode 100644 index 00000000..c4c82175 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock systemClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java index 672c47cd..4d2e0053 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java @@ -19,13 +19,17 @@ import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -52,17 +56,20 @@ class AnalysisAsyncTaskSweepCoordinatorTest { private AnalysisAsyncTaskSweepCoordinator analysisAsyncTaskSweepCoordinator; private AnalysisQueueProperties analysisQueueProperties; + private Clock clock; @BeforeEach void setUp() { analysisQueueProperties = new AnalysisQueueProperties(); + clock = Clock.fixed(Instant.parse("2026-08-12T00:00:00Z"), ZoneId.of("Asia/Seoul")); analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( analysisAsyncTaskRepository, analysisAsyncTaskService, analysisCreditService, userService, transactionTemplate, - analysisQueueProperties + analysisQueueProperties, + clock ); lenient().doAnswer(invocation -> { @SuppressWarnings("unchecked") @@ -74,15 +81,19 @@ void setUp() { @Test @DisplayName("sweep는 timeout 대상 task id만 batch 조회해 처리한다") void sweepTimedOutTasksLoadsOnlyTimedOutTaskIdsInBatches() { + LocalDateTime now = LocalDateTime.now(clock); AnalysisAsyncTask pendingTask = AnalysisAsyncTask.pending(1L, 10L, 3); - ReflectionTestUtils.setField(pendingTask, "submittedAt", LocalDateTime.now().minusMinutes(10)); + ReflectionTestUtils.setField(pendingTask, "submittedAt", now.minusMinutes(10)); AnalysisAsyncTask runningTask = AnalysisAsyncTask.pending(2L, 20L, 3); runningTask.markRunning("worker-1", 0, null); - ReflectionTestUtils.setField(runningTask, "lastAttemptAt", LocalDateTime.now().minusMinutes(20)); + ReflectionTestUtils.setField(runningTask, "lastAttemptAt", now.minusMinutes(20)); - when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(any(LocalDateTime.class), any(Pageable.class))) + LocalDateTime pendingDeadline = now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds()); + LocalDateTime runningDeadline = now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds()); + + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(eq(pendingDeadline), any(Pageable.class))) .thenReturn(List.of(pendingTask.getTaskId()), List.of()); - when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(any(LocalDateTime.class), any(Pageable.class))) + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(eq(runningDeadline), any(Pageable.class))) .thenReturn(List.of(runningTask.getTaskId()), List.of()); when(analysisAsyncTaskRepository.findByIdForUpdate(pendingTask.getTaskId())).thenReturn(Optional.of(pendingTask)); when(analysisAsyncTaskRepository.findByIdForUpdate(runningTask.getTaskId())).thenReturn(Optional.of(runningTask)); @@ -107,9 +118,16 @@ void sweepTimedOutTasksLoadsOnlyTimedOutTaskIdsInBatches() { @Test @DisplayName("batch가 비어 있으면 추가 처리 없이 종료한다") void sweepTimedOutTasksStopsWhenNoTimedOutTaskIdsExist() { - when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds(any(LocalDateTime.class), any(Pageable.class))) + LocalDateTime now = LocalDateTime.now(clock); + when(analysisAsyncTaskRepository.findTimedOutPendingTaskIds( + eq(now.minusSeconds(analysisQueueProperties.getQueueTimeoutSeconds())), + any(Pageable.class) + )) .thenReturn(List.of()); - when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds(any(LocalDateTime.class), any(Pageable.class))) + when(analysisAsyncTaskRepository.findTimedOutRunningTaskIds( + eq(now.minusSeconds(analysisQueueProperties.getProcessingTimeoutSeconds())), + any(Pageable.class) + )) .thenReturn(List.of()); int expiredCount = analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); From 2fcd0ca0b95e3e6680f603f56d4666434212bf72 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:45:59 +0900 Subject: [PATCH 35/55] =?UTF-8?q?[Refactor]=20async=20credit=20refund=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EA=B3=B5=EC=9A=A9=ED=99=94=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisAsyncTaskSweepCoordinator.java | 26 ++------ .../async/AnalysisAsyncWorkerBridge.java | 17 ++--- .../async/AnalysisAsyncCreditCoordinator.java | 33 ++++++++++ .../async/AnalysisAsyncSweepService.java | 8 +-- .../async/AnalysisAsyncTaskService.java | 17 +---- .../async/AnalysisWorkerBridgeService.java | 2 + ...AnalysisAsyncTaskSweepCoordinatorTest.java | 11 +--- .../AnalysisAsyncCreditCoordinatorTest.java | 64 +++++++++++++++++++ .../AnalysisWorkerBridgeServiceTest.java | 13 ++-- 9 files changed, 124 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java index 39a028b1..90b0f9df 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinator.java @@ -1,15 +1,12 @@ package com.jobdri.jobdri_api.domain.analysis.infrastructure.async; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.transaction.support.TransactionTemplate; @@ -26,8 +23,7 @@ public class AnalysisAsyncTaskSweepCoordinator { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisAsyncTaskService analysisAsyncTaskService; - private final AnalysisCreditService analysisCreditService; - private final UserService userService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; private final TransactionTemplate transactionTemplate; private final AnalysisQueueProperties analysisQueueProperties; private final Clock clock; @@ -35,16 +31,14 @@ public class AnalysisAsyncTaskSweepCoordinator { public AnalysisAsyncTaskSweepCoordinator( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisAsyncTaskService analysisAsyncTaskService, - AnalysisCreditService analysisCreditService, - UserService userService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, TransactionTemplate transactionTemplate, AnalysisQueueProperties analysisQueueProperties, Clock clock ) { this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; this.analysisAsyncTaskService = analysisAsyncTaskService; - this.analysisCreditService = analysisCreditService; - this.userService = userService; + this.analysisAsyncCreditCoordinator = analysisAsyncCreditCoordinator; this.transactionTemplate = transactionTemplate; this.analysisQueueProperties = analysisQueueProperties; this.clock = clock; @@ -113,7 +107,7 @@ private int sweepTimedOutTaskInTransaction(String taskId) { return 0; } - releaseCreditIfNeeded(task); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); analysisAsyncTaskService.markFailed( task.getTaskId(), expirationDecision.failureReason(), @@ -152,16 +146,6 @@ private boolean isExpired(LocalDateTime baseTime, LocalDateTime now, long timeou return Duration.between(baseTime, now).getSeconds() >= timeoutSeconds; } - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - - User user = userService.getUser(task.getUserId()); - analysisCreditService.refund(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - private record ExpirationDecision(AnalysisAsyncFailureReason failureReason, String errorMessage) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index b742e801..c25796b1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -14,6 +14,7 @@ import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; @@ -49,6 +50,7 @@ public class AnalysisAsyncWorkerBridge { private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisService analysisService; private final AnalysisCreditService analysisCreditService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; private final UserService userService; private final WorkerTaskResultService workerTaskResultService; private final AnalysisInputFingerprintProvider analysisInputFingerprintProvider; @@ -60,6 +62,7 @@ public AnalysisAsyncWorkerBridge( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisService analysisService, AnalysisCreditService analysisCreditService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, UserService userService, WorkerTaskResultService workerTaskResultService, AnalysisInputFingerprintProvider analysisInputFingerprintProvider, @@ -70,6 +73,7 @@ public AnalysisAsyncWorkerBridge( this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; this.analysisService = analysisService; this.analysisCreditService = analysisCreditService; + this.analysisAsyncCreditCoordinator = analysisAsyncCreditCoordinator; this.userService = userService; this.workerTaskResultService = workerTaskResultService; this.analysisInputFingerprintProvider = analysisInputFingerprintProvider; @@ -124,7 +128,7 @@ public void failTask( } analysisAsyncTaskService.updateWorkerMetadata(taskId, workerId, queueLatencyMillis); - releaseCreditIfNeeded(task); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); analysisAsyncTaskService.markFailed(taskId, failureReason, errorMessage, retryCount); try (var ignored = LoggingContext.with("worker.task.failed", null, workerContext(taskId, "ANALYSIS", workerId, retryCount, queueLatencyMillis))) { log.warn("Analysis worker failed task: failureReason={}", failureReason); @@ -414,22 +418,13 @@ private void confirmCreditIfNeeded(AnalysisAsyncTask task) { analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); } - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - User user = userService.getUser(task.getUserId()); - analysisCreditService.refund(user, task.getCreditReferenceId()); - analysisAsyncTaskService.markCreditReleased(task.getTaskId()); - } - private void releaseCreditAfterContextFailure(String taskId) { transactionTemplate.execute(status -> { AnalysisAsyncTask task = getTaskForUpdate(taskId); if (task.getExecutionContextSnapshot() != null) { return null; } - releaseCreditIfNeeded(task); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); return null; }); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java new file mode 100644 index 00000000..a3585af7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.springframework.stereotype.Service; + +@Service +public class AnalysisAsyncCreditCoordinator { + private final AnalysisCreditService analysisCreditService; + private final UserService userService; + + public AnalysisAsyncCreditCoordinator( + AnalysisCreditService analysisCreditService, + UserService userService + ) { + this.analysisCreditService = analysisCreditService; + this.userService = userService; + } + + public boolean releaseReservedCreditIfNeeded(AnalysisAsyncTask task) { + if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { + return false; + } + + User user = userService.getUser(task.getUserId()); + analysisCreditService.refund(user, task.getCreditReferenceId()); + task.markCreditReleased(); + return true; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index da93ed30..abee116d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -2,8 +2,6 @@ import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncTaskSweepCoordinator; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.user.service.UserService; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionTemplate; @@ -15,8 +13,7 @@ public class AnalysisAsyncSweepService extends AnalysisAsyncTaskSweepCoordinator public AnalysisAsyncSweepService( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisAsyncTaskService analysisAsyncTaskService, - AnalysisCreditService analysisCreditService, - UserService userService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, TransactionTemplate transactionTemplate, AnalysisQueueProperties analysisQueueProperties, Clock clock @@ -24,8 +21,7 @@ public AnalysisAsyncSweepService( super( analysisAsyncTaskRepository, analysisAsyncTaskService, - analysisCreditService, - userService, + analysisAsyncCreditCoordinator, transactionTemplate, analysisQueueProperties, clock diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index ea312e61..38c54b07 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -12,9 +12,6 @@ import com.jobdri.jobdri_api.domain.notification.entity.NotificationType; import com.jobdri.jobdri_api.domain.notification.service.NotificationService; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; -import com.jobdri.jobdri_api.domain.user.entity.User; -import com.jobdri.jobdri_api.domain.user.service.UserService; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; @@ -57,8 +54,7 @@ public class AnalysisAsyncTaskService { private final NotificationService notificationService; private final AsyncMetricsRecorder asyncMetricsRecorder; private final AnalysisQueueProperties analysisQueueProperties; - private final AnalysisCreditService analysisCreditService; - private final UserService userService; + private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; private final AsyncProgressCalculator asyncProgressCalculator; @Transactional @@ -163,7 +159,7 @@ public AnalysisAsyncCancelResponse cancelTask(Long userId, Long mockApplyId, Str boolean cancelled = task.getStatus() == AnalysisAsyncTaskStatus.CANCELLED; boolean newlyCancelled = previousStatus != AnalysisAsyncTaskStatus.CANCELLED && cancelled; if (newlyCancelled) { - releaseCreditIfNeeded(task); + analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); recordProcessingMetric(task, "cancelled"); } if (newlyCancelled || previousCancelledAt == null && cancelled) { @@ -282,15 +278,6 @@ private AnalysisAsyncStatusResponse toStatusResponse(AnalysisAsyncTask task, Ana .build(); } - private void releaseCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - User user = userService.getUser(task.getUserId()); - analysisCreditService.refund(user, task.getCreditReferenceId()); - task.markCreditReleased(); - } - private String resolveCurrentStep(AnalysisAsyncTask task) { return asyncProgressCalculator.resolveCurrentStep( toProgressStatus(task.getStatus()), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index b21ccb91..77f188e9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -21,6 +21,7 @@ public AnalysisWorkerBridgeService( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisService analysisService, AnalysisCreditService analysisCreditService, + AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, UserService userService, WorkerTaskResultService workerTaskResultService, AnalysisInputFingerprintProvider analysisInputFingerprintProvider, @@ -32,6 +33,7 @@ public AnalysisWorkerBridgeService( analysisAsyncTaskRepository, analysisService, analysisCreditService, + analysisAsyncCreditCoordinator, userService, workerTaskResultService, analysisInputFingerprintProvider, diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java index 4d2e0053..9712b2eb 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncTaskSweepCoordinatorTest.java @@ -2,11 +2,10 @@ import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisQueueProperties; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; -import com.jobdri.jobdri_api.domain.user.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,10 +45,7 @@ class AnalysisAsyncTaskSweepCoordinatorTest { private AnalysisAsyncTaskService analysisAsyncTaskService; @Mock - private AnalysisCreditService analysisCreditService; - - @Mock - private UserService userService; + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; @Mock private TransactionTemplate transactionTemplate; @@ -65,8 +61,7 @@ void setUp() { analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( analysisAsyncTaskRepository, analysisAsyncTaskService, - analysisCreditService, - userService, + analysisAsyncCreditCoordinator, transactionTemplate, analysisQueueProperties, clock diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java new file mode 100644 index 00000000..339874d8 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java @@ -0,0 +1,64 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncCreditCoordinatorTest { + + @Mock + private AnalysisCreditService analysisCreditService; + + @Mock + private UserService userService; + + @InjectMocks + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + + @Test + @DisplayName("예약된 크레딧이 있으면 환불 후 RELEASED 상태로 전이한다") + void releaseReservedCreditIfNeededRefundsReservedCredit() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); + User user = User.signup("테스트 사용자", "analysis-credit-release@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(userService.getUser(1L)).thenReturn(user); + + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + + assertThat(released).isTrue(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RELEASED); + verify(userService).getUser(1L); + verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId()); + } + + @Test + @DisplayName("예약된 크레딧이 없으면 환불을 수행하지 않는다") + void releaseReservedCreditIfNeededSkipsWhenNoReservedCredit() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + + assertThat(released).isFalse(); + verify(userService, never()).getUser(any()); + verify(analysisCreditService, never()).refund(any(), anyString()); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index b748aeec..a8a928b7 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -69,6 +69,9 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisCreditService analysisCreditService; + @Mock + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + @Mock private UserService userService; @@ -275,11 +278,10 @@ void failTaskReleasesReservedCreditOnlyOnce() { ReflectionTestUtils.setField(user, "id", 1L); when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); - when(userService.getUser(1L)).thenReturn(user); - doAnswer(invocation -> { + when(analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task)).thenAnswer(invocation -> { task.markCreditReleased(); - return null; - }).when(analysisAsyncTaskService).markCreditReleased(task.getTaskId()); + return true; + }); doAnswer(invocation -> { task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); return null; @@ -288,8 +290,7 @@ void failTaskReleasesReservedCreditOnlyOnce() { analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); - verify(analysisCreditService, times(1)).refund(user, "analysisTaskId=" + task.getTaskId()); - verify(analysisAsyncTaskService, times(1)).markCreditReleased(task.getTaskId()); + verify(analysisAsyncCreditCoordinator, times(1)).releaseReservedCreditIfNeeded(task); } @Test From 6b8c6c53af14cb2fb2eea7b6fee1d234dc640706 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:53:51 +0900 Subject: [PATCH 36/55] =?UTF-8?q?[Fix]=20async=20credit=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=EC=9D=B4=20=EB=A9=B1=EB=93=B1=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/entity/AnalysisAsyncTask.java | 32 ++++++++++- .../async/AnalysisAsyncWorkerBridge.java | 26 +-------- .../async/AnalysisAsyncCreditCoordinator.java | 21 ++++++- .../async/AnalysisWorkerBridgeService.java | 3 - .../service/core/AnalysisCreditService.java | 4 +- src/main/resources/schema.sql | 7 +++ .../AnalysisAsyncCreditCoordinatorTest.java | 57 ++++++++++++++++++- .../AnalysisWorkerBridgeServiceTest.java | 42 +++++++------- 8 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java index 99d5c4e0..248f47dd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/entity/AnalysisAsyncTask.java @@ -38,6 +38,9 @@ public class AnalysisAsyncTask extends CreatedAtEntity { @Column(name = "credit_reference_id", length = 100) private String creditReferenceId; + @Column(name = "credit_reference_version", nullable = false) + private int creditReferenceVersion; + @Enumerated(EnumType.STRING) @Column(name = "credit_status", nullable = false, length = 20) private AnalysisAsyncCreditStatus creditStatus; @@ -107,6 +110,7 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe task.userId = userId; task.mockApplyId = mockApplyId; task.creditStatus = AnalysisAsyncCreditStatus.NONE; + task.creditReferenceVersion = 0; task.status = AnalysisAsyncTaskStatus.PENDING; task.message = "자소서 분석 비동기 작업이 접수되었습니다."; task.retryCount = 0; @@ -118,17 +122,39 @@ public static AnalysisAsyncTask pending(Long userId, Long mockApplyId, int maxRe return task; } - public void markCreditReserved(String creditReferenceId) { + public boolean markCreditReserved(String creditReferenceId) { + if (!canReserveCredit() || creditReferenceId == null || creditReferenceId.isBlank()) { + return false; + } this.creditReferenceId = creditReferenceId; + this.creditReferenceVersion += 1; this.creditStatus = AnalysisAsyncCreditStatus.RESERVED; + return true; } - public void markCreditConfirmed() { + public boolean markCreditConfirmed() { + if (creditStatus != AnalysisAsyncCreditStatus.RESERVED || creditReferenceId == null) { + return false; + } this.creditStatus = AnalysisAsyncCreditStatus.CONFIRMED; + return true; } - public void markCreditReleased() { + public boolean markCreditReleased() { + if (creditStatus != AnalysisAsyncCreditStatus.RESERVED || creditReferenceId == null) { + return false; + } this.creditStatus = AnalysisAsyncCreditStatus.RELEASED; + return true; + } + + public boolean canReserveCredit() { + return creditStatus == AnalysisAsyncCreditStatus.NONE + || creditStatus == AnalysisAsyncCreditStatus.RELEASED; + } + + public int nextCreditReferenceVersion() { + return creditReferenceVersion + 1; } public void markRunning(String workerId, int retryCount, Instant messageSubmittedAt) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java index c25796b1..081e3ee7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/async/AnalysisAsyncWorkerBridge.java @@ -9,14 +9,12 @@ import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.CorpusReferenceContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; -import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncTaskStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncCreditCoordinator; import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; @@ -49,7 +47,6 @@ public class AnalysisAsyncWorkerBridge { private final AnalysisAsyncTaskService analysisAsyncTaskService; private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; private final AnalysisService analysisService; - private final AnalysisCreditService analysisCreditService; private final AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; private final UserService userService; private final WorkerTaskResultService workerTaskResultService; @@ -61,7 +58,6 @@ public AnalysisAsyncWorkerBridge( AnalysisAsyncTaskService analysisAsyncTaskService, AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisService analysisService, - AnalysisCreditService analysisCreditService, AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, UserService userService, WorkerTaskResultService workerTaskResultService, @@ -72,7 +68,6 @@ public AnalysisAsyncWorkerBridge( this.analysisAsyncTaskService = analysisAsyncTaskService; this.analysisAsyncTaskRepository = analysisAsyncTaskRepository; this.analysisService = analysisService; - this.analysisCreditService = analysisCreditService; this.analysisAsyncCreditCoordinator = analysisAsyncCreditCoordinator; this.userService = userService; this.workerTaskResultService = workerTaskResultService; @@ -287,7 +282,7 @@ private ContextAccess loadContextAccess(String taskId, Long userId, Long mockApp if (task.getExecutionContextSnapshot() != null) { return new ContextAccess(readContextSnapshot(task)); } - reserveCreditIfNeeded(task); + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); return new ContextAccess(null); } @@ -304,7 +299,7 @@ private AnalysisWorkerContextResponse persistContextSnapshot( if (task.getExecutionContextSnapshot() != null) { return readContextSnapshot(task); } - reserveCreditIfNeeded(task); + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); task.captureExecutionSnapshot(contextSnapshot, inputFingerprint); return context; } @@ -399,23 +394,8 @@ private void rejectIfCancelled(AnalysisAsyncTask task, String message) { } } - private void reserveCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() == AnalysisAsyncCreditStatus.RESERVED - || task.getCreditStatus() == AnalysisAsyncCreditStatus.CONFIRMED) { - return; - } - - User user = userService.getUser(task.getUserId()); - String creditReferenceId = analysisCreditService.createAsyncReferenceId(task.getTaskId()); - analysisCreditService.deduct(user, creditReferenceId); - analysisAsyncTaskService.markCreditReserved(task.getTaskId(), creditReferenceId); - } - private void confirmCreditIfNeeded(AnalysisAsyncTask task) { - if (task.getCreditStatus() != AnalysisAsyncCreditStatus.RESERVED || task.getCreditReferenceId() == null) { - return; - } - analysisAsyncTaskService.markCreditConfirmed(task.getTaskId()); + analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); } private void releaseCreditAfterContextFailure(String taskId) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java index a3585af7..88a78c5a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinator.java @@ -27,7 +27,24 @@ public boolean releaseReservedCreditIfNeeded(AnalysisAsyncTask task) { User user = userService.getUser(task.getUserId()); analysisCreditService.refund(user, task.getCreditReferenceId()); - task.markCreditReleased(); - return true; + return task.markCreditReleased(); + } + + public boolean reserveCreditIfNeeded(AnalysisAsyncTask task) { + if (!task.canReserveCredit()) { + return false; + } + + User user = userService.getUser(task.getUserId()); + String creditReferenceId = analysisCreditService.createAsyncReferenceId( + task.getTaskId(), + task.nextCreditReferenceVersion() + ); + analysisCreditService.deduct(user, creditReferenceId); + return task.markCreditReserved(creditReferenceId); + } + + public boolean confirmReservedCreditIfNeeded(AnalysisAsyncTask task) { + return task.markCreditConfirmed(); } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index 77f188e9..89c55cdc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncWorkerBridge; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisCreditService; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.service.UserService; @@ -20,7 +19,6 @@ public AnalysisWorkerBridgeService( AnalysisAsyncTaskService analysisAsyncTaskService, AnalysisAsyncTaskRepository analysisAsyncTaskRepository, AnalysisService analysisService, - AnalysisCreditService analysisCreditService, AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator, UserService userService, WorkerTaskResultService workerTaskResultService, @@ -32,7 +30,6 @@ public AnalysisWorkerBridgeService( analysisAsyncTaskService, analysisAsyncTaskRepository, analysisService, - analysisCreditService, analysisAsyncCreditCoordinator, userService, workerTaskResultService, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java index 2b14be7a..6799a265 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisCreditService.java @@ -16,8 +16,8 @@ public String createSyncReferenceId(Long mockApplyId, String inputFingerprint) { return "mockApplyId=" + mockApplyId + ":fingerprint=" + inputFingerprint; } - public String createAsyncReferenceId(String taskId) { - return "analysisTaskId=" + taskId; + public String createAsyncReferenceId(String taskId, int creditReferenceVersion) { + return "analysisTaskId=" + taskId + ":creditVersion=" + creditReferenceVersion; } @Transactional diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index f5d2c800..5da74226 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -58,6 +58,10 @@ CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status ON analysis_async_tasks (user_id, mock_apply_id, status); +CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_async_tasks_credit_reference_id_unique + ON analysis_async_tasks (credit_reference_id) + WHERE credit_reference_id IS NOT NULL; + ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; @@ -109,6 +113,9 @@ ALTER TABLE IF EXISTS analysis_async_tasks ALTER TABLE IF EXISTS analysis_async_tasks ADD COLUMN IF NOT EXISTS input_fingerprint_snapshot VARCHAR(64); +ALTER TABLE IF EXISTS analysis_async_tasks + ADD COLUMN IF NOT EXISTS credit_reference_version INTEGER NOT NULL DEFAULT 0; + ALTER TABLE IF EXISTS job_posting_async_tasks ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java index 339874d8..13f9d7da 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncCreditCoordinatorTest.java @@ -36,18 +36,20 @@ class AnalysisAsyncCreditCoordinatorTest { @DisplayName("예약된 크레딧이 있으면 환불 후 RELEASED 상태로 전이한다") void releaseReservedCreditIfNeededRefundsReservedCredit() { AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); - ReflectionTestUtils.invokeMethod(task, "markCreditReserved", "analysisTaskId=" + task.getTaskId()); User user = User.signup("테스트 사용자", "analysis-credit-release@example.com", "encoded-password"); ReflectionTestUtils.setField(user, "id", 1L); when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); assertThat(released).isTrue(); assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RELEASED); - verify(userService).getUser(1L); - verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId()); + verify(userService, org.mockito.Mockito.times(2)).getUser(1L); + verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); } @Test @@ -61,4 +63,53 @@ void releaseReservedCreditIfNeededSkipsWhenNoReservedCredit() { verify(userService, never()).getUser(any()); verify(analysisCreditService, never()).refund(any(), anyString()); } + + @Test + @DisplayName("크레딧 재예약은 version을 올린 새 reference로 한 번만 수행한다") + void reserveCreditIfNeededUsesIncrementedReferenceVersion() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-credit-reserve@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 2)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + + boolean firstReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + boolean secondReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + boolean released = analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task); + boolean thirdReserved = analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + + assertThat(firstReserved).isTrue(); + assertThat(secondReserved).isFalse(); + assertThat(released).isTrue(); + assertThat(thirdReserved).isTrue(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RESERVED); + assertThat(task.getCreditReferenceId()).isEqualTo("analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + verify(analysisCreditService).refund(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=2"); + } + + @Test + @DisplayName("예약된 크레딧만 confirm 되고 이후 중복 confirm은 무시된다") + void confirmReservedCreditIfNeededIsIdempotent() { + AnalysisAsyncTask task = AnalysisAsyncTask.pending(1L, 10L, 3); + User user = User.signup("테스트 사용자", "analysis-credit-confirm@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + when(userService.getUser(1L)).thenReturn(user); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); + + analysisAsyncCreditCoordinator.reserveCreditIfNeeded(task); + + boolean firstConfirmed = analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); + boolean secondConfirmed = analysisAsyncCreditCoordinator.confirmReservedCreditIfNeeded(task); + + assertThat(firstConfirmed).isTrue(); + assertThat(secondConfirmed).isFalse(); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.CONFIRMED); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index a8a928b7..ffc260ed 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -7,6 +7,7 @@ import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncCreditStatus; import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; @@ -34,6 +35,7 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; @@ -69,7 +71,6 @@ class AnalysisWorkerBridgeServiceTest { @Mock private AnalysisCreditService analysisCreditService; - @Mock private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; @Mock @@ -87,15 +88,26 @@ class AnalysisWorkerBridgeServiceTest { @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @InjectMocks private AnalysisWorkerBridgeService analysisWorkerBridgeService; @BeforeEach void setUp() { + analysisAsyncCreditCoordinator = new AnalysisAsyncCreditCoordinator(analysisCreditService, userService); + analysisWorkerBridgeService = new AnalysisWorkerBridgeService( + analysisAsyncTaskService, + analysisAsyncTaskRepository, + analysisService, + analysisAsyncCreditCoordinator, + userService, + workerTaskResultService, + analysisInputFingerprintProvider, + objectMapper, + transactionTemplate + ); lenient().when(transactionTemplate.execute(any(TransactionCallback.class))) .thenAnswer(invocation -> { TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(null); + return callback.doInTransaction(org.mockito.Mockito.mock(TransactionStatus.class)); }); } @@ -153,7 +165,6 @@ void getContextRejectsCancelledTaskWithoutCreditReservation() { .isInstanceOf(GeneralException.class); verify(analysisCreditService, never()).deduct(any(), anyString()); - verify(analysisAsyncTaskService, never()).markCreditReserved(anyString(), anyString()); } @Test @@ -210,21 +221,17 @@ void getContextReservesCreditBeforePreparingExecution() { when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); when(userService.getUser(1L)).thenReturn(user); when(analysisService.prepareAnalysisExecution(user, 10L)).thenReturn(payload); - when(analysisCreditService.createAsyncReferenceId(task.getTaskId())) - .thenReturn("analysisTaskId=" + task.getTaskId()); + when(analysisCreditService.createAsyncReferenceId(task.getTaskId(), 1)) + .thenReturn("analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); when(analysisInputFingerprintProvider.create(payload)).thenReturn("initial-fingerprint"); - doAnswer(invocation -> { - ReflectionTestUtils.invokeMethod(task, "markCreditReserved", invocation.getArgument(1, String.class)); - return null; - }).when(analysisAsyncTaskService).markCreditReserved(eq(task.getTaskId()), anyString()); var firstContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); var secondContext = analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); - verify(analysisCreditService).createAsyncReferenceId(task.getTaskId()); - verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId()); - verify(analysisAsyncTaskService).markCreditReserved(task.getTaskId(), "analysisTaskId=" + task.getTaskId()); + verify(analysisCreditService).createAsyncReferenceId(task.getTaskId(), 1); + verify(analysisCreditService).deduct(user, "analysisTaskId=" + task.getTaskId() + ":creditVersion=1"); verify(analysisService, times(1)).prepareAnalysisExecution(user, 10L); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.RESERVED); assertThat(firstContext).isEqualTo(secondContext); assertThat(firstContext.corpusReferences()).hasSize(1); assertThat(firstContext.corpusReferences().getFirst().corpusId()).isEqualTo(11L); @@ -265,7 +272,6 @@ void getContextDoesNotReserveCreditTwice() { analysisWorkerBridgeService.getContext(task.getTaskId(), 1L, 10L); verify(analysisCreditService, never()).deduct(eq(user), anyString()); - verify(analysisAsyncTaskService, never()).markCreditReserved(eq(task.getTaskId()), anyString()); verify(analysisService).prepareAnalysisExecution(user, 10L); } @@ -278,10 +284,7 @@ void failTaskReleasesReservedCreditOnlyOnce() { ReflectionTestUtils.setField(user, "id", 1L); when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenReturn(Optional.of(task)); - when(analysisAsyncCreditCoordinator.releaseReservedCreditIfNeeded(task)).thenAnswer(invocation -> { - task.markCreditReleased(); - return true; - }); + when(userService.getUser(1L)).thenReturn(user); doAnswer(invocation -> { task.markFailed(AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1); return null; @@ -290,7 +293,8 @@ void failTaskReleasesReservedCreditOnlyOnce() { analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); analysisWorkerBridgeService.failTask(task.getTaskId(), AnalysisAsyncFailureReason.INTERNAL_ERROR, "error", 1, "worker-1", 10L); - verify(analysisAsyncCreditCoordinator, times(1)).releaseReservedCreditIfNeeded(task); + verify(analysisCreditService, times(1)) + .refund(user, "analysisTaskId=" + task.getTaskId()); } @Test From 43d44f6951900a9acd58ea53155fe597068a4a76 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 10:59:32 +0900 Subject: [PATCH 37/55] =?UTF-8?q?[Fix]=20PUBLISH=5FFAILED=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=91=EC=88=98=20=EC=9B=90=EC=9E=90=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/async/AnalysisAsyncUseCase.java | 8 +- .../async/AnalysisAsyncTaskService.java | 31 +++-- .../async/AnalysisAsyncFacadeServiceTest.java | 33 ++++- .../async/AnalysisAsyncTaskServiceTest.java | 119 ++++++++++++++++++ 4 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java index 602b9db3..ce88e074 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/application/usecase/async/AnalysisAsyncUseCase.java @@ -88,8 +88,12 @@ private AnalysisAsyncSubmitResponse createAndProcessTask(User user, Long mockApp } private AnalysisAsyncSubmitResponse reopenAndProcessTask(User user, Long mockApplyId, AnalysisAsyncTask task) { - AnalysisAsyncTask reopenedTask = analysisAsyncTaskService.reopenPublishFailureTask(task.getTaskId()); - return processTask(user, mockApplyId, reopenedTask); + AnalysisAsyncTaskService.ReopenPublishFailureResult reopenResult = + analysisAsyncTaskService.reopenPublishFailureTask(task.getTaskId()); + if (!reopenResult.reopened()) { + return toInProgressResponse(reopenResult.task()); + } + return processTask(user, mockApplyId, reopenResult.task()); } private AnalysisAsyncSubmitResponse processTask(User user, Long mockApplyId, AnalysisAsyncTask task) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java index 38c54b07..74beed0a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskService.java @@ -193,17 +193,25 @@ public void markCreditReleased(String taskId) { } @Transactional - public AnalysisAsyncTask reopenPublishFailureTask(String taskId) { - AnalysisAsyncTask task = getTask(taskId); - if (!task.isRecoverablePublishFailure()) { - throw new GeneralException( - GeneralErrorCode.INVALID_PARAMETER, - "재접수할 수 없는 자소서 분석 비동기 작업입니다. taskId=" + taskId - ); + public ReopenPublishFailureResult reopenPublishFailureTask(String taskId) { + AnalysisAsyncTask task = analysisAsyncTaskRepository.findByIdForUpdate(taskId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.ANALYSIS_ASYNC_TASK_NOT_FOUND, + "해당 자소서 분석 비동기 작업을 찾을 수 없습니다. taskId=" + taskId + )); + if (task.isRecoverablePublishFailure()) { + task.reopenForRepublish(); + publishAfterCommit(toStatusResponse(task)); + return new ReopenPublishFailureResult(task, true); } - task.reopenForRepublish(); - publishAfterCommit(toStatusResponse(task)); - return task; + if (task.getStatus() == AnalysisAsyncTaskStatus.PENDING + || task.getStatus() == AnalysisAsyncTaskStatus.RUNNING) { + return new ReopenPublishFailureResult(task, false); + } + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "재접수할 수 없는 자소서 분석 비동기 작업입니다. taskId=" + taskId + ); } @Transactional(readOnly = true) @@ -370,4 +378,7 @@ private void createFailureNotification(AnalysisAsyncTask task) { ); } + public record ReopenPublishFailureResult(AnalysisAsyncTask task, boolean reopened) { + } + } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java index c54c894f..7137ac67 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeServiceTest.java @@ -128,7 +128,9 @@ void submitReopensRecoverablePublishFailureTask() { when(userService.validateUser(user)).thenReturn(user); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); - when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())).thenReturn(failedTask); + failedTask.reopenForRepublish(); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(failedTask, true)); AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); @@ -142,6 +144,31 @@ void submitReopensRecoverablePublishFailureTask() { verify(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); } + @Test + @DisplayName("다른 요청이 이미 PUBLISH_FAILED task를 재접수했으면 재발행하지 않고 진행 중 응답을 반환한다") + void submitReturnsInProgressWhenPublishFailureTaskAlreadyReopened() { + User user = User.signup("테스트 사용자", "analysis-async-reopened@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + AnalysisAsyncTask failedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + AnalysisAsyncTask reopenedTask = AnalysisAsyncTask.pending(1L, 10L, 3); + ReflectionTestUtils.setField(reopenedTask, "taskId", failedTask.getTaskId()); + + when(userService.validateUser(user)).thenReturn(user); + when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); + when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(reopenedTask, false)); + + AnalysisAsyncSubmitResponse response = analysisAsyncFacadeService.submit(user, 10L); + + assertThat(response.taskId()).isEqualTo(failedTask.getTaskId()); + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.cached()).isFalse(); + assertThat(response.resultAvailable()).isFalse(); + verify(analysisAsyncProcessor, never()).process(failedTask.getTaskId(), 1L, 10L, 3); + } + @Test @DisplayName("메시지 발행 실패 시 task를 삭제하지 않고 PUBLISH_FAILED로 실패 처리한다") void submitMarksTaskFailedWhenPublishFails() { @@ -180,7 +207,9 @@ void submitMarksReopenedTaskFailedWhenPublishFailsAgain() { when(userService.validateUser(user)).thenReturn(user); when(analysisAsyncTaskService.findActiveTask(1L, 10L)).thenReturn(Optional.empty()); when(analysisAsyncTaskService.findRecoverablePublishFailureTask(1L, 10L)).thenReturn(Optional.of(failedTask)); - when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())).thenReturn(failedTask); + failedTask.reopenForRepublish(); + when(analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId())) + .thenReturn(new AnalysisAsyncTaskService.ReopenPublishFailureResult(failedTask, true)); doThrow(publishException).when(analysisAsyncProcessor).process(failedTask.getTaskId(), 1L, 10L, 3); assertThatThrownBy(() -> analysisAsyncFacadeService.submit(user, 10L)) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java new file mode 100644 index 00000000..c9a13352 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncTaskServiceTest.java @@ -0,0 +1,119 @@ +package com.jobdri.jobdri_api.domain.analysis.service.async; + +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; +import com.jobdri.jobdri_api.domain.notification.service.NotificationService; +import com.jobdri.jobdri_api.global.async.AsyncProgressCalculator; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAsyncTaskServiceTest { + + @Mock + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + + @Mock + private AnalysisAsyncSseService analysisAsyncSseService; + + @Mock + private NotificationService notificationService; + + @Mock + private AsyncMetricsRecorder asyncMetricsRecorder; + + @Mock + private AnalysisAsyncCreditCoordinator analysisAsyncCreditCoordinator; + + @Test + @DisplayName("동시에 재접수해도 PUBLISH_FAILED task는 한 번만 reopen 된다") + void reopenPublishFailureTaskIsAtomicAcrossConcurrentRequests() throws Exception { + AnalysisQueueProperties queueProperties = new AnalysisQueueProperties(); + AnalysisAsyncTaskService analysisAsyncTaskService = new AnalysisAsyncTaskService( + analysisAsyncTaskRepository, + analysisAsyncSseService, + notificationService, + asyncMetricsRecorder, + queueProperties, + analysisAsyncCreditCoordinator, + new AsyncProgressCalculator() + ); + AnalysisAsyncTask failedTask = spy(AnalysisAsyncTask.pending(1L, 10L, 3)); + failedTask.markFailed(AnalysisAsyncFailureReason.PUBLISH_FAILED, "publish failed", 0); + + CountDownLatch firstReopened = new CountDownLatch(1); + AtomicBoolean firstLookup = new AtomicBoolean(true); + doAnswer(invocation -> { + invocation.callRealMethod(); + firstReopened.countDown(); + return null; + }).when(failedTask).reopenForRepublish(); + when(analysisAsyncTaskRepository.findByIdForUpdate(failedTask.getTaskId())).thenAnswer(invocation -> { + if (firstLookup.getAndSet(false)) { + return Optional.of(failedTask); + } + assertThat(firstReopened.await(5, TimeUnit.SECONDS)).isTrue(); + return Optional.of(failedTask); + }); + + List results = runConcurrently(2, () -> + analysisAsyncTaskService.reopenPublishFailureTask(failedTask.getTaskId()) + ); + + assertThat(results).hasSize(2); + assertThat(results).extracting(AnalysisAsyncTaskService.ReopenPublishFailureResult::reopened) + .containsExactlyInAnyOrder(true, false); + assertThat(failedTask.getStatus().name()).isEqualTo("PENDING"); + verify(analysisAsyncSseService, times(1)).publish(org.mockito.ArgumentMatchers.any()); + } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(executorService.submit(() -> { + ready.countDown(); + assertThat(start.await(5, TimeUnit.SECONDS)).isTrue(); + return task.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List results = new ArrayList<>(threadCount); + for (Future future : futures) { + results.add(future.get(5, TimeUnit.SECONDS)); + } + return results; + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + } + } +} From 9f148794484916683c4064a343cae9f4f4bcebcd Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 11:05:28 +0900 Subject: [PATCH 38/55] =?UTF-8?q?[Fix]=20completeTask=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EA=B0=95=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalysisWorkerBridgeServiceTest.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java index ffc260ed..7d752248 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeServiceTest.java @@ -39,8 +39,16 @@ import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -55,6 +63,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; @ExtendWith(MockitoExtension.class) class AnalysisWorkerBridgeServiceTest { @@ -546,4 +555,96 @@ void completeTaskRecoversAfterPublishFailure() { verify(analysisAsyncTaskService).markSuccess(task.getTaskId(), response); verify(workerTaskResultService).markDeliveredIfPresent(TaskType.ANALYSIS_COMPLETE, task.getTaskId()); } + + @Test + @DisplayName("동시에 complete가 들어와도 분석 완료 처리는 한 번만 수행한다") + void completeTaskProcessesSuccessOnlyOnceAcrossConcurrentRequests() throws Exception { + AnalysisAsyncTask task = spy(AnalysisAsyncTask.pending(1L, 10L, 3)); + task.markCreditReserved("credit-ref"); + ReflectionTestUtils.setField(task, "executionContextSnapshot", """ + {"userId":1,"mockApplyId":10,"companyName":"","jobTitle":"","task":"","requirements":"","preferredQualifications":"","bigClassificationName":"","middleClassificationName":"","detailClassificationName":"","questions":[],"corpusReferences":[],"similarJobPostings":[]} + """); + ReflectionTestUtils.setField(task, "inputFingerprintSnapshot", "complete-fingerprint"); + + User user = User.signup("테스트 사용자", "analysis-concurrent-complete@example.com", "encoded-password"); + ReflectionTestUtils.setField(user, "id", 1L); + + JobPosting jobPosting = mock(JobPosting.class, org.mockito.Answers.RETURNS_DEEP_STUBS); + AnalysisExecutionPayload completionPayload = new AnalysisExecutionPayload( + 1L, + 10L, + jobPosting, + List.of(), + List.of() + ); + AnalysisLlmResponse llmResponse = mock(AnalysisLlmResponse.class); + AnalysisResponse finalizedResponse = mock(AnalysisResponse.class); + AnalysisResponse cachedResponse = mock(AnalysisResponse.class); + AnalysisWorkerCompleteRequest request = new AnalysisWorkerCompleteRequest( + 1L, + 10L, + llmResponse, + "worker-1", + 15L + ); + + CountDownLatch successMarked = new CountDownLatch(1); + AtomicBoolean firstLookup = new AtomicBoolean(true); + when(analysisAsyncTaskRepository.findByIdForUpdate(task.getTaskId())).thenAnswer(invocation -> { + if (firstLookup.getAndSet(false)) { + return Optional.of(task); + } + assertThat(successMarked.await(5, TimeUnit.SECONDS)).isTrue(); + return Optional.of(task); + }); + when(userService.getUser(1L)).thenReturn(user); + when(analysisService.prepareAnalysisExecution(user, 10L, List.of())).thenReturn(completionPayload); + when(analysisService.finalizeAnalysis(user, 10L, completionPayload, llmResponse, "complete-fingerprint")) + .thenAnswer(invocation -> { + task.markSuccess(); + successMarked.countDown(); + return finalizedResponse; + }); + when(analysisService.getAnalysis(user, 10L)).thenReturn(cachedResponse); + + List results = runConcurrently( + 2, + () -> analysisWorkerBridgeService.completeTask(task.getTaskId(), request) + ); + + assertThat(results).containsExactlyInAnyOrder(finalizedResponse, cachedResponse); + assertThat(task.getCreditStatus()).isEqualTo(AnalysisAsyncCreditStatus.CONFIRMED); + verify(analysisService, times(1)) + .finalizeAnalysis(user, 10L, completionPayload, llmResponse, "complete-fingerprint"); + verify(analysisService, times(1)).getAnalysis(user, 10L); + verify(analysisAsyncTaskService, times(1)).markSuccess(task.getTaskId(), finalizedResponse); + verify(task, times(1)).markCreditConfirmed(); + } + + private List runConcurrently(int threadCount, Callable task) throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(executorService.submit(() -> { + ready.countDown(); + assertThat(start.await(5, TimeUnit.SECONDS)).isTrue(); + return task.call(); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List results = new ArrayList<>(threadCount); + for (Future future : futures) { + results.add(future.get(5, TimeUnit.SECONDS)); + } + return results; + } finally { + executorService.shutdownNow(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + } + } } From 0f40105300d4d05546f16ffc44399e6f26e61898 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 11:10:49 +0900 Subject: [PATCH 39/55] =?UTF-8?q?[Refactor]=20AnalysisAsyncFacadeService?= =?UTF-8?q?=20composition=20=EC=A0=84=ED=99=98=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisAsyncFacadeService.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java index c5ffe757..0d2b5c9b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncFacadeService.java @@ -1,14 +1,18 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.jobdri.jobdri_api.domain.analysis.application.usecase.async.AnalysisAsyncUseCase; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncCancelResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncSubmitResponse; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; +import com.jobdri.jobdri_api.domain.user.entity.User; import com.jobdri.jobdri_api.domain.user.service.UserService; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @Service // 분석 비동기 작업의 접수와 상태 조회를 외부 API 관점에서 조율하는 서비스다. -public class AnalysisAsyncFacadeService extends AnalysisAsyncUseCase { +public class AnalysisAsyncFacadeService { + private final AnalysisAsyncUseCase analysisAsyncUseCase; public AnalysisAsyncFacadeService( AnalysisAsyncTaskService analysisAsyncTaskService, @@ -16,6 +20,23 @@ public AnalysisAsyncFacadeService( AnalysisService analysisService, UserService userService ) { - super(analysisAsyncTaskService, analysisAsyncProcessor, analysisService, userService); + this.analysisAsyncUseCase = new AnalysisAsyncUseCase( + analysisAsyncTaskService, + analysisAsyncProcessor, + analysisService, + userService + ); + } + + public AnalysisAsyncSubmitResponse submit(User user, Long mockApplyId) { + return analysisAsyncUseCase.submit(user, mockApplyId); + } + + public AnalysisAsyncStatusResponse getTask(User user, Long mockApplyId, String taskId) { + return analysisAsyncUseCase.getTask(user, mockApplyId, taskId); + } + + public AnalysisAsyncCancelResponse cancel(User user, Long mockApplyId, String taskId) { + return analysisAsyncUseCase.cancel(user, mockApplyId, taskId); } } From 7130bdbcdab880a6b01e740667f5d8a0b035a8c6 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 11:11:22 +0900 Subject: [PATCH 40/55] =?UTF-8?q?[Refactor]=20AnalysisAsyncProcessor=20com?= =?UTF-8?q?position=20=EC=A0=84=ED=99=98=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/service/async/AnalysisAsyncProcessor.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java index 2c440765..068a958c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncProcessor.java @@ -5,9 +5,14 @@ @Service // 분석 비동기 작업을 MQ 메시지로 변환해 워커 실행 경로로 넘기는 서비스다. -public class AnalysisAsyncProcessor extends AnalysisAsyncQueueProcessor { +public class AnalysisAsyncProcessor { + private final AnalysisAsyncQueueProcessor analysisAsyncQueueProcessor; public AnalysisAsyncProcessor(AnalysisTaskMessagePublisher analysisTaskMessagePublisher) { - super(analysisTaskMessagePublisher); + this.analysisAsyncQueueProcessor = new AnalysisAsyncQueueProcessor(analysisTaskMessagePublisher); + } + + public void process(String taskId, Long userId, Long mockApplyId, int maxRetryCount) { + analysisAsyncQueueProcessor.process(taskId, userId, mockApplyId, maxRetryCount); } } From 18704738cf48a75a72b8cd4f55a0785fa02b103e Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 11:12:11 +0900 Subject: [PATCH 41/55] =?UTF-8?q?[Refactor]=20AnalysisWorkerBridgeService?= =?UTF-8?q?=20composition=20=EC=A0=84=ED=99=98=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/AnalysisWorkerBridgeService.java | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java index 89c55cdc..0ab933f9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisWorkerBridgeService.java @@ -1,19 +1,28 @@ package com.jobdri.jobdri_api.domain.analysis.service.async; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerCompleteRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerContextResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.AnalysisWorkerResultStoreRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.infrastructure.async.AnalysisAsyncWorkerBridge; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; +import com.jobdri.jobdri_api.domain.analysis.type.AnalysisAsyncFailureReason; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisInputFingerprintProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisService; import com.jobdri.jobdri_api.domain.user.service.UserService; +import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; import com.jobdri.jobdri_api.domain.workerresult.service.WorkerTaskResultService; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; + @Service // 외부 분석 워커와 내부 분석 도메인 상태를 연결해 주는 브리지 서비스다. -public class AnalysisWorkerBridgeService extends AnalysisAsyncWorkerBridge { +public class AnalysisWorkerBridgeService { + private final AnalysisAsyncWorkerBridge analysisAsyncWorkerBridge; public AnalysisWorkerBridgeService( AnalysisAsyncTaskService analysisAsyncTaskService, @@ -26,7 +35,7 @@ public AnalysisWorkerBridgeService( ObjectMapper objectMapper, TransactionTemplate transactionTemplate ) { - super( + this.analysisAsyncWorkerBridge = new AnalysisAsyncWorkerBridge( analysisAsyncTaskService, analysisAsyncTaskRepository, analysisService, @@ -38,4 +47,66 @@ public AnalysisWorkerBridgeService( transactionTemplate ); } + + @Transactional + public void markRunning(String taskId, String workerId, int retryCount, Instant submittedAt) { + analysisAsyncWorkerBridge.markRunning(taskId, workerId, retryCount, submittedAt); + } + + @Transactional + public void markRetry( + String taskId, + AnalysisAsyncFailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + analysisAsyncWorkerBridge.markRetry( + taskId, + failureReason, + errorMessage, + retryCount, + workerId, + queueLatencyMillis + ); + } + + @Transactional + public void failTask( + String taskId, + AnalysisAsyncFailureReason failureReason, + String errorMessage, + int retryCount, + String workerId, + Long queueLatencyMillis + ) { + analysisAsyncWorkerBridge.failTask( + taskId, + failureReason, + errorMessage, + retryCount, + workerId, + queueLatencyMillis + ); + } + + public AnalysisWorkerContextResponse getContext(String taskId, Long userId, Long mockApplyId) { + return analysisAsyncWorkerBridge.getContext(taskId, userId, mockApplyId); + } + + @Transactional + public AnalysisResponse completeTask(String taskId, AnalysisWorkerCompleteRequest request) { + return analysisAsyncWorkerBridge.completeTask(taskId, request); + } + + @Transactional + public void storeGeneratedResult(String taskId, AnalysisWorkerResultStoreRequest request) { + analysisAsyncWorkerBridge.storeGeneratedResult(taskId, request); + } + + @Transactional(readOnly = true) + public WorkerTaskResultResponse getStoredResult(String taskId) { + return analysisAsyncWorkerBridge.getStoredResult(taskId); + } } From dbf7a01e007fb3052c09f953d96d01e29ac39397 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 11:12:44 +0900 Subject: [PATCH 42/55] =?UTF-8?q?[Refactor]=20AnalysisAsyncSweepService=20?= =?UTF-8?q?composition=20=EC=A0=84=ED=99=98=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/async/AnalysisAsyncSweepService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java index abee116d..32b77267 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/async/AnalysisAsyncSweepService.java @@ -8,7 +8,8 @@ import java.time.Clock; @Service -public class AnalysisAsyncSweepService extends AnalysisAsyncTaskSweepCoordinator { +public class AnalysisAsyncSweepService { + private final AnalysisAsyncTaskSweepCoordinator analysisAsyncTaskSweepCoordinator; public AnalysisAsyncSweepService( AnalysisAsyncTaskRepository analysisAsyncTaskRepository, @@ -18,7 +19,7 @@ public AnalysisAsyncSweepService( AnalysisQueueProperties analysisQueueProperties, Clock clock ) { - super( + this.analysisAsyncTaskSweepCoordinator = new AnalysisAsyncTaskSweepCoordinator( analysisAsyncTaskRepository, analysisAsyncTaskService, analysisAsyncCreditCoordinator, @@ -27,4 +28,8 @@ public AnalysisAsyncSweepService( clock ); } + + public int sweepTimedOutTasks() { + return analysisAsyncTaskSweepCoordinator.sweepTimedOutTasks(); + } } From ab84de0d2f0d6112848624c1ec90d3f1a3211dd8 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 12:43:26 +0900 Subject: [PATCH 43/55] =?UTF-8?q?[Refactor]=20AnalysisAiClient=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/service/ai/AnalysisAiClient.java | 531 ------------------ .../service/ai/AnalysisAiClientTest.java | 10 - 2 files changed, 541 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index e08a3ffb..88d93be7 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -1,7 +1,5 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateRecheckResponse; @@ -13,26 +11,14 @@ import com.jobdri.jobdri_api.domain.analysis.infrastructure.ai.OpenAiAnalysisAdapter; import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; import com.jobdri.jobdri_api.domain.analysis.entity.Question; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotProperties; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchQuery; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.FewShotSearchService; -import com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot.SelectedFewShotCase; import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedJobPostingReference; -import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievedQuestionReference; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; -import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; -import com.openai.client.OpenAIClient; -import com.openai.models.responses.ResponseCreateParams; -import com.openai.models.responses.StructuredResponse; -import com.openai.models.responses.StructuredResponseOutputMessage; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,258 +35,18 @@ import java.util.ArrayList; import java.util.Optional; import java.util.stream.Collectors; -import java.util.concurrent.TimeUnit; @Component @Slf4j @RequiredArgsConstructor // 자소서 분석에 필요한 프롬프트를 만들고 LLM 호출을 수행하는 클라이언트다. public class AnalysisAiClient { - private static final int MAX_REFERENCE_SECTION_LENGTH = 3000; - private static final int MAX_REFERENCE_FIELD_LENGTH = 300; - private static final int MAX_CRITERIA_ITEMS = 5; private static final int MAX_CANDIDATES_PER_QUESTION = 3; private static final int RECHECK_MIN_PROBLEM_CLARITY = 4; private static final int RECHECK_MIN_JOB_RELEVANCE = 4; private static final int RECHECK_MIN_IMPROVEMENT_USEFULNESS = 4; private static final int RECHECK_MIN_FABRICATION_CONFIDENCE = 4; - private static final String OUTPUT_SCHEMA = """ - [출력 규칙] - - Structured Output 스키마에 맞는 JSON object만 반환한다. - - jobFit: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - impact: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - completeness: 실제 JD와 실제 답변 전체를 기준으로 독립 산정한 0~100 정수 - - feedback: 한 줄 피드백 - - keyStrengths: 없으면 [] - - keyWeaknesses: 없으면 [] - - missingKeywords: 없으면 [] - - questionAnalyses: 비어 있지 않은 문항마다 대표 평가 문장을 1~3개 - - improvement: 안전한 개선문을 만들 수 없으면 null - - 프롬프트 안에 특정 점수 숫자 조합을 JSON 예시로 넣지 않는다. - - 0, 50, 70, 100 같은 임의 점수도 출력 예시로 사용하지 않는다. - - Structured Output은 JSON 형식과 타입만 보장한다. - - sentence 원문 존재 여부, status/reason 정합성, improvement 사실 생성 여부, missingKeyword JD 근거 여부는 서버가 다시 검증한다. - """; - private static final String EVALUATION_CRITERIA = """ - [평가 절차] - 최종 JSON을 작성하기 전에 내부적으로 다음 순서를 따른다. - 내부 판단 과정이나 chain-of-thought를 응답에 출력하지 않는다. - 별도의 reasoning 필드나 analysis 필드를 API 응답에 추가하지 않는다. - 모델의 상세 사고과정을 로그나 DB에 저장하지 않는다. - 1. 문장 유형을 경험/성과, 포부/계획, 지원동기, 역량/자격으로 판단한다. - 2. mainTask와 qualification 중 직접 관련된 요구사항을 찾는다. - 3. preference만 근거인 경우 첨삭 대상에서 제외한다. - 4. 문장 유형에 맞는 평가 기준만 적용한다. - 5. 문항을 대표해 평가할 문장인지 판단한다. - 6. proven, mentioned 또는 fabricated 상태를 결정한다. - 7. reason이 status 및 문장 유형과 일치하는지 확인한다. - 8. 원문 사실만으로 안전한 improvement를 작성할 수 있는지 확인한다. - 9. 새 경험, 수치, 기술, 계획, 시제 변경, 메타 조언이 없는지 재검사한다. - 10. 최종 JSON만 반환한다. - - [문장 유형 구분] - - 경험/성과: 과거에 수행한 행동, 역할, 문제 해결, 결과, 수치, 산출물이 드러나는 문장 - - 포부/계획: 입사 후 하겠다, 기여하겠다, 성장하겠다처럼 미래 실행 의지를 말하는 문장 - - 지원동기: 회사/직무를 선택한 이유, 관심, 가치관, 동기를 설명하는 문장 - - 역량/자격: 보유 기술, 자격, 면허, 전공, 경력, 교육 이수 여부를 설명하는 문장 - - [포부/계획 문장 평가 규칙] - - "~하겠습니다", "~되겠습니다", "~기여하겠습니다", "~노력하겠습니다", "~성장하고 싶습니다" 등 미래 시점 문장은 포부/계획으로 우선 판단한다. - - 포부/계획 문장에는 과거 성과 수치, 과거 결과, Before-After를 요구하지 않는다. - - 포부/계획은 실행 대상, 실행 방법, 단계, 직무 연결성이 구체적인지 중심으로 판단한다. - - 포부/계획 문장을 "성과 수치가 부족하다"는 이유로 questionAnalyses에 포함하지 않는다. - - 포부/계획 문장의 reason에는 "성과 수치가 부족", "정량적 결과가 부족", "Before-After가 부족", "과거 성과가 드러나지 않음"을 사용하지 않는다. - - 포부가 너무 추상적일 때만 실행 대상, 방법, 단계, 직무 연결성을 보완하도록 reason을 작성한다. - - [문장 유형별 평가 기준] - - 경험/성과: 과거 수행 행동, 역할, 문제, 결과를 평가한다. 모든 경험에 반드시 수치를 요구하지 않는다. - - 경험/성과: 과정, 역할, 산출물, 검증 결과 중 하나 이상이 충분히 구체적이면 인정한다. - - 경험/성과: 이미 수치나 명확한 결과가 있으면 "성과 수치 부족" reason을 생성하지 않는다. - - 포부/계획: 미래 시점 문장에는 과거 성과 수치나 Before-After를 요구하지 않고 실행 대상, 방법, 단계, 직무 연결성을 평가한다. - - 지원동기: 회사 또는 직무를 선택한 이유가 개인 경험·관심과 연결되는지 평가하며, 지원동기를 성과 문장처럼 평가하지 않는다. - - 지원동기: 해당 회사만의 이유가 없는 일반론인지 확인한다. - - 역량/자격: 보유 기술이나 역량의 실제 사용 맥락을 평가한다. - - 역량/자격: 자격증이나 전공 자체를 문장 첨삭의 핵심 문제로 삼지 않는다. - - 역량/자격: 해당 기술을 실제로 어떻게 사용했는지가 있으면 그 근거를 평가한다. - - [JD 반영 우선순위] - - 판단 우선순위는 mainTask > qualification >>> preference다. - - mainTask와 qualification은 직무 적합성 판단의 핵심 기준이다. - - preference는 reason과 점수에 보조적으로만 반영한다. - - preference만 누락된 경우 questionAnalyses의 첨삭 대상으로 선택하지 않는다. - - preference만 근거로 jobFit을 크게 감점하지 않는다. - - [점수 산정 규칙] - - Few-shot 예시는 문장 상태 판정과 출력 형식 참고용이며 점수 예시가 아니다. - - 점수는 실제 JD와 실제 답변 전체를 기준으로 독립적으로 산정한다. - - 예시의 수치나 특정 고정 점수를 추론해서 사용하지 않는다. - - 서로 다른 입력에 동일한 점수를 기계적으로 반복하지 않는다. - - [jobFit 평가 기준] - JD가 요구하는 역량, 경험, 기술을 자기소개서가 얼마나 증명하는지 평가한다. - 체크 항목: - - 필수 자격요건 매칭 - - 우대사항 반영 - - 주요 업무 연관성 - - 직무 키워드 활용 - - 암묵적 직무 역량 충족 - 점수 구간: - - 85~100: 필수 자격요건 대부분 proven, 직무 키워드가 풍부하고 업무 경험과 JD가 직접 연결됨 - - 70~84: 주요 자격요건이 증명되고 일부 우대사항도 반영됨 - - 55~69: 자격요건 일부만 증명되고 JD와 간접적으로 연결됨 - - 40~54: 자격요건 증명이 거의 없고 직무 관련성이 낮음 - - 40 미만: JD와 자기소개서가 거의 무관함 - - [impact 평가 기준] - 주장을 뒷받침하는 근거가 얼마나 구체적이고 설득력 있는지 평가한다. - 체크 항목: - - 정량적 성과 - - STAR 구조 활용 - - 주장과 근거의 연결 - - Before-After 비교 - - 차별적 경험 - 점수 구간: - - 85~100: 주요 주장에 정량 성과 또는 구체적 에피소드가 있고 STAR 구조가 명확함 - - 70~84: 핵심 주장 대부분에 근거가 있고 일부 수치와 STAR 구조가 존재함 - - 55~69: 경험은 있으나 근거가 모호하고 수치가 거의 없음 - - 40~54: 대부분 근거 없는 주장과 추상 표현으로 구성됨 - - 40 미만: 구체적 근거가 전혀 없음 - 감점 금지: - - 문제 상황 설명에 수치가 없다는 이유만으로 감점하지 않는다. - - 과정이나 학습 경험에 정량 지표가 없다는 이유만으로 감점하지 않는다. - - STAR 구조를 형식적으로 완벽하게 따르지 않는다는 이유만으로 감점하지 않는다. - 단, 전체 맥락과 행동, 결과는 이해 가능해야 한다. - - [completeness 평가 기준] - 질문 적합성, 논리 흐름, 문장 표현 품질을 종합 평가한다. - 체크 항목: - - 질문 적합성 - - 문단 구조와 흐름 - - 논리적 일관성 - - 문장 가독성 - - 설득력 있는 마무리 - 점수 구간: - - 85~100: 모든 문항에 정확히 답하고 논리 흐름과 마무리가 매우 자연스러움 - - 70~84: 대부분 적절하게 답하고 전반적으로 읽기 좋음 - - 55~69: 일부 동문서답, 논리 비약, 반복 표현 또는 마무리 부족이 있음 - - 40~54: 질문과 답변 불일치가 많고 구조와 논리 문제가 큼 - - 40 미만: 대부분 질문 의도와 무관하거나 미완성임 - """; - private static final String STATUS_AND_WRITING_RULES = """ - [status 판정 기준] - - questionAnalyses의 허용 status는 proven, mentioned, fabricated다. - - proven: JD와 관련된 구체적인 행동, 근거 또는 결과가 충분히 드러난 문장 - - mentioned: 관련 경험이나 의도는 있으나 구체성이 부족한 문장 - - missing: 해당 역량이나 요건을 자기소개서에서 전혀 다루지 않음 - - fabricated: JD 또는 답변 내부의 명시적 사실과 직접 충돌하거나, 지원자가 실제로 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우 - - [status 중요 규칙] - - 충분히 좋은 대표 문장은 proven으로 questionAnalyses에 반환할 수 있다. - - proven 문장의 improvement는 null로 반환한다. - - MISSING은 sentence가 없으므로 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - - 직접적인 증거가 부족해도 관련 경험이 있으면 mentioned로 분류한다. - - missing은 관련 언급이 전혀 없을 때만 사용한다. - - fabricated는 단순히 근거가 부족하다는 이유로 사용하지 않는다. - - fabricated는 명시적 사실과 직접 충돌하거나 지원자가 하지 않았다고 밝힌 경험을 한 것처럼 주장한 경우에만 사용한다. - - status 다양성을 만들기 위해 억지로 fabricated를 생성하지 않는다. - - 구체적인 경험이나 수치가 부족하다는 이유만으로 fabricated를 사용하지 않는다. - - [sentence 규칙] - - questionAnalyses의 sentence는 반드시 해당 questionId의 answer에 실제 포함된 정확한 substring이어야 한다. - - 원문에 없는 문장을 생성하지 않는다. - - sentence를 요약하거나 수정하지 않는다. - - 원문 매칭이 불확실하면 questionAnalyses에 포함하지 않는다. - - answer가 비어 있지 않은 모든 문항은 가장 평가 가치가 큰 실제 문장을 최소 1개 반환한다. - - questionAnalyses는 비어 있지 않은 문항마다 1~3개 반환한다. - - 구체적인 강점 문장은 proven, 보완이 필요한 문장은 mentioned 또는 fabricated로 반환한다. - - 실제로 독립적인 평가 문장이 여러 개라면 대표 1개만 선택하지 말고 최대 3개까지 반환한다. - - 동일한 문제를 반복하는 문장은 하나만 선택한다. - - answer가 비어 있는 문항만 분석을 반환하지 않는다. - - 동일하거나 거의 동일한 문장을 중복 반환하지 않는다. - - start/end index는 출력하지 않는다. 서버가 Java String character index 기준으로 계산한다. - - missing은 원문에 해당 문장이 없을 수 있으므로 sentence를 임의로 만들지 않는다. - - missing은 questionAnalyses에 억지로 넣지 않는다. - - [소제목 처리 규칙] - - 답변에서 한 줄 전체가 대괄호로 감싸진 형식(예: [문제를 기회로 바꾼 경험])은 본문 문장이 아니라 소제목이다. - - 소제목 자체를 EXPERIENCE, PLAN, MOTIVATION, COMPETENCY 문장으로 분류하거나 questionAnalyses의 sentence로 반환하지 않는다. - - 소제목 자체를 keyStrengths의 quote 또는 keyWeaknesses의 근거로 반환하지 않는다. - - 소제목에 행동, 역할, 방법, 성과가 없다는 이유로 구체성이 부족하다고 판단하거나 점수를 감점하지 않는다. - - 소제목은 바로 뒤 문단의 주제와 흐름을 이해하는 보조 맥락으로만 사용하고, 실제 평가는 본문 문장을 기준으로 한다. - - 소제목이 문단 내용을 요약하는 표현인지 여부는 문단 이해에만 참고하며, 소제목 문구 자체를 별도 첨삭 대상으로 만들지 않는다. - - [missingKeywords 규칙] - - 실제 입력 JD의 주요 업무, 자격 요건 원문에 존재하지만 자소서에 충분히 드러나지 않은 경험형 역량만 추출한다. - - 유사 JD 검색 결과, 직무별 보조 평가 기준, few-shot 예시, 모델의 일반 지식에서 키워드를 생성하지 않는다. - - JD 원문에 없는 SQL, Python, AWS, 대용량 트래픽 같은 키워드를 생성하지 않는다. - - 자격증, 면허, 어학성적, 학위, 전공, 경력 연차, 근무 가능 여부, 나이, 국적, 졸업 여부 같은 정형 자격요건은 missingKeywords에 넣지 않는다. - - preference에만 존재하는 항목은 원칙적으로 missingKeywords에서 제외한다. - - mainTask와 qualification에서 근거를 찾지 못한 경우 preference만으로 문장을 문제 삼지 않는다. - - reason에서 preference를 핵심 결격 사유처럼 표현하지 않는다. - - preference가 없다는 이유만으로 mentioned를 생성하지 않는다. - - questionAnalyses와 분리해서 missingKeywords에만 넣는다. - - 최대 3개만 반환한다. - - 누락 키워드가 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. - - 우선순위는 주요 업무(mainTask) > 자격요건(qualification) >>> 우대사항(preference)다. - - keyword는 단순 단어보다 짧은 역량 문구 형태로 작성한다. - - 반드시 실제 입력 JD에 들어간 표현을 유지한다. - - 중복되거나 유사한 keyword는 하나로 묶고, 대표 문구는 자격요건 표현을 우선한다. - - source는 qualification, preference, mainTask 중 하나만 사용한다. - - [핵심 강점/약점 작성 규칙] - - keyStrengths와 keyWeaknesses는 각각 최대 3개만 반환한다. - - title은 화면 카드 제목으로 바로 노출할 짧은 한국어 문장으로 작성한다. - - quote는 반드시 실제 텍스트에서 가져온 짧은 직접 인용이어야 하며 새로 만들거나 요약하지 않는다. - - keyStrengths의 quote는 자소서 answer에 실제 포함된 정확한 부분 문자열만 사용한다. - - 충분히 구체적인 좋은 문장은 keyStrengths 후보로 사용한다. - - keyStrengths는 mainTask 또는 qualification과 직접 연결된 근거를 우선한다. - - preference만 충족하는 문장은 핵심 강점으로 과대평가하지 않는다. - - proven 문장은 keyStrengths의 quote와 questionAnalyses의 sentence에 함께 사용할 수 있다. - - mentioned 또는 fabricated 문장은 keyStrengths와 중복하지 않는다. - - keyWeaknesses의 첫 항목들은 missingKeywords와 같은 누락 요건을 다룬다. - - missingKeywords 기반 keyWeaknesses의 quote는 JD의 주요 업무, 자격 요건, 우대 사항에 실제 포함된 표현을 사용한다. - - missingKeywords가 없으면 keyWeaknesses는 questionAnalyses의 보완 대상 문장 quote를 우선 사용한다. - - quote는 너무 길게 붙이지 말고 사용자가 근거를 확인할 수 있는 핵심 구절만 사용한다. - - 적절한 강점이나 약점이 없으면 null이나 필드 생략이 아니라 빈 배열 []을 반환한다. - - [reason 작성 규칙] - - 사용자가 왜 해당 문장이 보완 대상인지 이해할 수 있게 작성한다. - - 가능하면 JD의 어떤 업무, 자격요건, 우대사항과 관련된 문제인지 설명한다. - - 1~2문장으로 간결하게 작성한다. - - 사용자의 경험을 부정하거나 비난하지 않는다. - - "잘못되었다"보다 "근거가 부족하다", "결과가 드러나지 않는다"처럼 진단형 표현을 사용한다. - - 단순 문법 교정보다 직무 적합성, 구체성, 논리 완성도 관점의 이유를 우선한다. - - [improvement 작성 규칙] - - improvement는 첨삭 조언이 아니라 사용자가 sentence를 대체해 사용할 수 있는 완성된 한국어 문장이어야 한다. - - improvement는 사용자가 그대로 교체해 쓸 수 있는 자기소개서 문장이어야 하며 첨삭 행위를 설명하는 메타 문장이 아니어야 한다. - - 개선이 필요하지 않으면 improvement는 null로 반환한다. - - 원문 정보만으로 개선문을 만들 수 없으면 improvement는 null로 반환한다. - - 반드시 한국어 평서문으로 작성한다. - - "추가하세요", "보완하세요", "수정해주세요", "필요합니다" 같은 지시문을 사용하지 않는다. - - "구체적으로 작성했습니다", "명확히 설명했습니다" 같은 메타 조언을 improvement로 반환하지 않는다. - - 원문과 실질적으로 동일한 문장을 improvement로 반환하지 않는다. - - 답변의 다른 문장을 그대로 복사해 improvement로 반환하지 않는다. - - JD 요구사항을 지원자가 실제 수행한 경험처럼 생성하지 않는다. - - 사용자가 언급하지 않은 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 임의로 만들지 않는다. - - 원문이 과거 경험이면 개선문도 과거 경험을 유지한다. - - 원문이 포부이면 개선문도 포부를 유지한다. - - 새로운 경험이나 계획을 추가하지 않는다. - - 수치가 필요하지만 원문에 없다면 N건, X%%, 약 N시간 같은 빈칸 표현을 사용한다. - - 원래 경험과 맥락을 최대한 유지한다. - - 가능하면 행동, 역할, 결과가 드러나도록 개선한다. - - 너무 길거나 과도하게 화려한 문장으로 만들지 않는다. - - 금지 표현: %s - """; - - private final OpenAIClient openAIClient; private final CorpusRetrievalService corpusRetrievalService; - private final LlmConcurrencyLimiter llmConcurrencyLimiter; - private final FewShotPromptProvider fewShotPromptProvider; - private final FewShotSearchService fewShotSearchService; - private final FewShotProperties fewShotProperties; - private final AsyncMetricsRecorder asyncMetricsRecorder; - private final ObjectMapper objectMapper; private final AnalysisPromptBuilder analysisPromptBuilder; private final AnalysisResponseParser analysisResponseParser; private final OpenAiAnalysisAdapter openAiAnalysisAdapter; @@ -639,149 +385,6 @@ String buildRecheckPrompt( ); } - String buildSinglePassPrompt( - AnalysisPromptInput promptInput, - RetrievalContext referenceContext, - JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria - ) { - String questionText = promptInput.questions().stream() - .map(question -> """ - - questionId: %d - question: %s - answer: %s - """.formatted( - question.questionId(), - defaultString(question.question()), - defaultString(question.answer()) - )) - .collect(Collectors.joining("\n")); - - String similarJobPostingText = formatJobPostingReferences(referenceContext.jobPostingReferences()); - String similarQuestionText = formatQuestionReferences(referenceContext.questionReferences()); - String jobCategoryCriteriaSection = formatJobCategoryEvaluationCriteriaSection(jobCategoryEvaluationCriteria); - String fewShotPromptBlock = resolveFewShotPromptBlock(promptInput); - - return """ - [시스템 지시] - 너는 한국 채용 담당자이자 자기소개서 평가 전문가다. - 반드시 JSON만 출력한다. - 자소서 원문에 없는 sentence를 만들지 않는다. - sentence는 반드시 해당 question의 answer에 포함된 정확한 부분 문자열이어야 한다. - 한국어 사용자 노출 라벨을 만들거나 추정하지 않는다. - - %s - - %s - - %s - - [Few-shot 예시] - %s - - [채용 공고] - 회사명: %s - 직무명: %s - 아래 JD 영역의 역할: - - 는 최우선 업무 기준이다. - - 는 핵심 자격요건 기준이다. - - 는 보조 기준이며 단독 결격 사유로 사용하지 않는다. - - - %s - - - - %s - - - - %s - - - [유사 JD 검색 결과] - %s - - [유사 자소서 문항 검색 결과] - %s - - %s - - [자소서 문항과 답변] - %s - - [출력 전 자체 검증] - - JSON 외 텍스트, 마크다운, 코드블럭을 출력하지 않는다. - - questionAnalyses의 questionId는 입력된 questionId 중 하나만 사용한다. - - questionAnalyses의 status는 proven, mentioned, fabricated 중 하나만 사용한다. - - sentence는 answer에 포함된 정확한 substring만 사용한다. - - missing은 questionAnalyses에 넣지 않고 missingKeywords로만 반환한다. - - keyStrengths와 keyWeaknesses는 각각 최대 3개이며, 없으면 []로 출력한다. - - keyStrengths의 quote는 answer에 실제 포함된 substring만 사용한다. - - keyWeaknesses에서 missingKeywords를 다루는 항목의 quote는 실제 JD 문구만 사용한다. - - missingKeywords는 최대 3개이며, 없으면 []로 출력한다. - - missingKeywords의 source는 qualification, preference, mainTask 중 하나만 사용한다. - - improvement가 지시문이 아닌 완성된 한국어 평서문인지 확인한다. - - 원문에 없는 경험, 기술, 도구명, 인원수, 금액, 성과 수치를 만들지 않았는지 확인한다. - - fabricated를 단순 근거 부족에 사용하지 않았는지 확인한다. - - 한 줄 전체가 대괄호로 감싸진 소제목을 questionAnalyses 또는 keyStrengths에 포함하지 않았는지 확인한다. - - jobFit, impact, completeness는 0~100 정수로 출력한다. - - 총점 score는 서버가 jobFit 50%%, impact 30%%, completeness 20%%로 계산하므로 출력하지 않는다. - """.formatted( - OUTPUT_SCHEMA, - EVALUATION_CRITERIA, - STATUS_AND_WRITING_RULES.formatted(AnalysisImprovementRules.bannedPhrasesText()), - fewShotPromptBlock, - defaultString(promptInput.companyName()), - defaultString(promptInput.jobName()), - defaultString(promptInput.mainTasks()), - defaultString(promptInput.qualifications()), - defaultString(promptInput.preferences()), - similarJobPostingText, - similarQuestionText, - jobCategoryCriteriaSection, - questionText - ); - } - - private String resolveFewShotPromptBlock(AnalysisPromptInput promptInput) { - if (fewShotSearchService == null || fewShotProperties == null || !fewShotProperties.isDynamicSelectionEnabled()) { - return fewShotPromptProvider.getPrompt(); - } - try { - List selectedFewShots = fewShotSearchService.searchRelevantFewShots( - FewShotSearchQuery.from(promptInput), - fewShotProperties.getSearch().getTopK() - ); - if (selectedFewShots.isEmpty()) { - log.warn( - "dynamic few-shot selection returned empty result. fallback=fixed, caseId={}, datasetVersion={}", - promptInput.caseId(), - fewShotProperties.getDatasetVersion() - ); - return fewShotPromptProvider.getPrompt(); - } - log.debug( - "dynamic few-shot prompt selected. caseId={}, selectedIds={}, sources={}, scores={}, datasetVersion={}", - promptInput.caseId(), - selectedFewShots.stream().map(item -> item.fewShotCase().id()).toList(), - selectedFewShots.stream().map(item -> item.fewShotCase().source()).toList(), - selectedFewShots.stream().map(item -> "%.4f".formatted(item.score())).toList(), - fewShotProperties.getDatasetVersion() - ); - return fewShotPromptProvider.buildPromptBlock(selectedFewShots); - } catch (Exception e) { - log.warn( - "dynamic few-shot selection failed. fallback=fixed, caseId={}, datasetVersion={}, reason={}, message={}", - promptInput.caseId(), - fewShotProperties.getDatasetVersion(), - e.getClass().getSimpleName(), - e.getMessage() - ); - log.debug("dynamic few-shot selection exception", e); - return fewShotPromptProvider.getPrompt(); - } - } - AnalysisCandidateResponse sanitizeCandidates( AnalysisPromptInput promptInput, AnalysisCandidateResponse candidates @@ -1930,125 +1533,6 @@ private String contextAfter(String answer, String sentence) { return answer.substring(from, Math.min(answer.length(), nextEnd + 1)).trim(); } - private String formatJobCategoryEvaluationCriteriaSection(JobCategoryEvaluationCriteria criteria) { - if (criteria == null) { - return ""; - } - - return """ - [직무별 보조 평가 기준] - 중분류: %s - 주의: - - 이 직무별 기준은 실제 JD를 대체하지 않는다. - - 실제 JD의 자격요건, 우대사항, 주요업무를 우선한다. - - 직무별 기준은 JD가 모호하거나 암묵 역량 판단이 필요할 때만 보조적으로 참고한다. - - missingKeywords는 실제 JD 표현을 우선 사용하고, 직무별 missingKeywordExamples는 문구 정리와 유사 키워드 묶기에만 참고한다. - - 직무별 기준에 있는 키워드가 자소서에 없다는 이유만으로 무조건 missing 처리하지 않는다. - - 원문에 없는 수치, 도구, 경험을 만들어내지 않는다. - 핵심 역량: %s - 관련 행동: %s - 관련 키워드: %s - 좋은 근거 예시: %s - 누락 키워드 문구 예시: %s - """.formatted( - defaultString(criteria.jobCategoryMiddle()), - formatCriteriaList(criteria.coreCompetencies()), - formatCriteriaList(criteria.relatedActions()), - formatCriteriaList(criteria.relatedKeywords()), - truncate(defaultString(criteria.goodEvidenceExample()), MAX_REFERENCE_FIELD_LENGTH), - formatCriteriaList(criteria.missingKeywordExamples()) - ).trim(); - } - - private String formatCriteriaList(List values) { - if (values == null || values.isEmpty()) { - return "없음"; - } - String formatted = values.stream() - .filter(value -> value != null && !value.isBlank()) - .limit(MAX_CRITERIA_ITEMS) - .map(value -> "- " + truncate(value.trim(), MAX_REFERENCE_FIELD_LENGTH)) - .collect(Collectors.joining("\n")); - return formatted.isBlank() ? "없음" : "\n" + formatted; - } - - private String formatJobPostingReferences(List references) { - if (references == null || references.isEmpty()) { - return "없음"; - } - String formatted = references.stream() - .map(reference -> """ - - 회사명: %s - 직무명: %s - 주요 업무: %s - 자격 요건: %s - 우대 사항: %s - 거리: %.4f - """.formatted( - truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.responsibilities()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.requirements()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.preferred()), MAX_REFERENCE_FIELD_LENGTH), - reference.distance() - )) - .reduce("", (left, right) -> left + "\n" + right) - .trim(); - return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); - } - - private String formatQuestionReferences(List references) { - if (references == null || references.isEmpty()) { - return "없음"; - } - String formatted = references.stream() - .map(reference -> """ - - 회사명: %s - 직무명: %s - 문항 유형: %s - 글자 수 제한: %s - 문항: %s - 거리: %.4f - """.formatted( - truncate(defaultString(reference.companyName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.roleName()), MAX_REFERENCE_FIELD_LENGTH), - truncate(defaultString(reference.questionType()), MAX_REFERENCE_FIELD_LENGTH), - reference.charLimit() == null ? "" : reference.charLimit(), - truncate(defaultString(reference.questionText()), MAX_REFERENCE_FIELD_LENGTH), - reference.distance() - )) - .reduce("", (left, right) -> left + "\n" + right) - .trim(); - return truncate(formatted, MAX_REFERENCE_SECTION_LENGTH); - } - - private String formatQuestions(AnalysisPromptInput promptInput) { - return promptInput.questions().stream() - .map(question -> """ - - questionId: %d - question: %s - answer: %s - """.formatted( - question.questionId(), - defaultString(question.question()), - defaultString(question.answer()) - )) - .collect(Collectors.joining("\n")); - } - - private T extractStructuredContent(StructuredResponse response) { - return response.output().stream() - .filter(item -> item.message().isPresent()) - .flatMap(item -> item.asMessage().content().stream()) - .filter(content -> content.outputText().isPresent()) - .map(StructuredResponseOutputMessage.Content::asOutputText) - .findFirst() - .orElseThrow(() -> new GeneralException( - GeneralErrorCode.INTERNAL_SERVER_ERROR, - "AI 응답에서 자소서 분석 결과를 찾을 수 없습니다." - )); - } - private boolean isPrimarySource(String relatedSource) { return "MAIN_TASK".equalsIgnoreCase(defaultString(relatedSource)) || "QUALIFICATION".equalsIgnoreCase(defaultString(relatedSource)); @@ -2139,14 +1623,6 @@ private int size(List values) { return values == null ? 0 : values.size(); } - private String writeJson(Object value) { - try { - return objectMapper.writeValueAsString(value == null ? List.of() : value); - } catch (JsonProcessingException e) { - return "[]"; - } - } - private String normalize(String value) { return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(); } @@ -2155,13 +1631,6 @@ private String defaultString(String value) { return value == null ? "" : value; } - private String truncate(String value, int maxLength) { - if (value == null || value.length() <= maxLength) { - return value; - } - return value.substring(0, maxLength) + "..."; - } - private RetrievalContext emptyContext() { return new RetrievalContext(List.of(), List.of()); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index a6a51a63..9a9b230c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -20,9 +20,6 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; -import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; -import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -52,14 +49,7 @@ class AnalysisAiClientTest { ); private final OpenAiAnalysisAdapter openAiAnalysisAdapter = mock(OpenAiAnalysisAdapter.class); private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( - mock(OpenAIClient.class), mock(CorpusRetrievalService.class), - mock(LlmConcurrencyLimiter.class), - new FewShotPromptProvider(), - fewShotSearchService, - fewShotProperties, - mock(AsyncMetricsRecorder.class), - objectMapper, analysisPromptBuilder, analysisResponseParser, openAiAnalysisAdapter From ca68ba0a1722cefa740d0b99421eee983a3d5bb6 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 12:45:05 +0900 Subject: [PATCH 44/55] =?UTF-8?q?[Refactor]=20Question=20custom=20candidat?= =?UTF-8?q?e=20=EC=A0=80=EC=9E=A5=20=EA=B2=BD=ED=95=A9=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...omQuestionCandidatePersistenceService.java | 31 ++++++ .../question/QuestionCommandService.java | 14 ++- .../question/QuestionCommandServiceTest.java | 100 ++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java new file mode 100644 index 00000000..2082d4c4 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/CustomQuestionCandidatePersistenceService.java @@ -0,0 +1,31 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CustomQuestionCandidatePersistenceService { + private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + private final EntityManager entityManager; + + public CustomQuestionCandidatePersistenceService( + CustomQuestionCandidateRepository customQuestionCandidateRepository, + EntityManager entityManager + ) { + this.customQuestionCandidateRepository = customQuestionCandidateRepository; + this.entityManager = entityManager; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CustomQuestionCandidate saveAndFlush(Long mockApplyId, String content, int charLimit) { + MockApply mockApplyReference = entityManager.getReference(MockApply.class, mockApplyId); + return customQuestionCandidateRepository.saveAndFlush( + CustomQuestionCandidate.create(mockApplyReference, content, charLimit) + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java index c59db0e2..3a3122c3 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java @@ -41,6 +41,7 @@ public class QuestionCommandService { private final QuestionRepository questionRepository; private final QuestionAnalysisRepository questionAnalysisRepository; private final CustomQuestionCandidateRepository customQuestionCandidateRepository; + private final CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService; public QuestionCommandService( QuestionDomainSupport questionDomainSupport, @@ -48,7 +49,8 @@ public QuestionCommandService( MockApplyRepository mockApplyRepository, QuestionRepository questionRepository, QuestionAnalysisRepository questionAnalysisRepository, - CustomQuestionCandidateRepository customQuestionCandidateRepository + CustomQuestionCandidateRepository customQuestionCandidateRepository, + CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService ) { this.questionDomainSupport = questionDomainSupport; this.questionCandidateCatalogService = questionCandidateCatalogService; @@ -56,6 +58,7 @@ public QuestionCommandService( this.questionRepository = questionRepository; this.questionAnalysisRepository = questionAnalysisRepository; this.customQuestionCandidateRepository = customQuestionCandidateRepository; + this.customQuestionCandidatePersistenceService = customQuestionCandidatePersistenceService; } @Transactional @@ -218,12 +221,13 @@ private CustomQuestionCandidate saveCustomCandidate( String content, Integer charLimit ) { + int resolvedCharLimit = questionDomainSupport.resolveCharLimit(charLimit); try { - return customQuestionCandidateRepository.saveAndFlush(CustomQuestionCandidate.create( - mockApply, + return customQuestionCandidatePersistenceService.saveAndFlush( + mockApply.getId(), content, - questionDomainSupport.resolveCharLimit(charLimit) - )); + resolvedCharLimit + ); } catch (DataIntegrityViolationException e) { return customQuestionCandidateRepository .findByMockApplyIdAndContent(mockApply.getId(), content) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java new file mode 100644 index 00000000..a9c7944c --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java @@ -0,0 +1,100 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest; +import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate; +import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class QuestionCommandServiceTest { + + @Mock + private QuestionDomainSupport questionDomainSupport; + + @Mock + private QuestionCandidateCatalogService questionCandidateCatalogService; + + @Mock + private MockApplyRepository mockApplyRepository; + + @Mock + private QuestionRepository questionRepository; + + @Mock + private QuestionAnalysisRepository questionAnalysisRepository; + + @Mock + private CustomQuestionCandidateRepository customQuestionCandidateRepository; + + @Mock + private CustomQuestionCandidatePersistenceService customQuestionCandidatePersistenceService; + + @Test + @DisplayName("custom candidate 저장 충돌 시 기존 후보를 재조회해 정상 응답한다") + void addCustomQuestionCandidateRecoversAfterUniqueConflict() { + QuestionCommandService questionCommandService = new QuestionCommandService( + questionDomainSupport, + questionCandidateCatalogService, + mockApplyRepository, + questionRepository, + questionAnalysisRepository, + customQuestionCandidateRepository, + customQuestionCandidatePersistenceService + ); + User user = User.signup("테스트 사용자", "question-command@example.com", "encoded-password"); + MockApply mockApply = mock(MockApply.class); + when(mockApply.getId()).thenReturn(10L); + + CustomQuestionCandidate existingCandidate = CustomQuestionCandidate.create( + mockApply, + "새로운 지원 동기를 작성해주세요.", + 900 + ); + ReflectionTestUtils.setField(existingCandidate, "id", 7L); + + when(questionDomainSupport.getOwnedMockApply(user, 10L)).thenReturn(mockApply); + doNothing().when(questionCandidateCatalogService).validateCustomCandidate("새로운 지원 동기를 작성해주세요."); + when(questionDomainSupport.resolveCharLimit(900)).thenReturn(900); + when(customQuestionCandidateRepository.findByMockApplyIdAndContent(10L, "새로운 지원 동기를 작성해주세요.")) + .thenReturn(Optional.empty(), Optional.of(existingCandidate)); + when(customQuestionCandidatePersistenceService.saveAndFlush(10L, "새로운 지원 동기를 작성해주세요.", 900)) + .thenThrow(new DataIntegrityViolationException("duplicate candidate")); + when(questionRepository.existsByMockApplyIdAndContent(10L, "새로운 지원 동기를 작성해주세요.")) + .thenReturn(false); + + QuestionCandidateResponse response = questionCommandService.addCustomQuestionCandidate( + user, + 10L, + new QuestionCandidateCreateRequest(" 새로운 지원 동기를 작성해주세요. ", 900) + ); + + assertThat(response.questionId()).isEqualTo(7L); + assertThat(response.content()).isEqualTo("새로운 지원 동기를 작성해주세요."); + assertThat(response.charLimit()).isEqualTo(900); + assertThat(response.selected()).isFalse(); + assertThat(response.custom()).isTrue(); + verify(customQuestionCandidatePersistenceService).saveAndFlush(10L, "새로운 지원 동기를 작성해주세요.", 900); + verify(customQuestionCandidateRepository, never()).saveAndFlush(existingCandidate); + } +} From 7d39f7921848cdc0d75494052fc479a08e88e801 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 12:46:44 +0900 Subject: [PATCH 45/55] =?UTF-8?q?[Refactor]=20Question=20candidate=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EC=9E=90=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/QuestionCandidateResponse.java | 3 ++- .../QuestionCandidateCatalogService.java | 13 +++++++++- .../question/QuestionCommandService.java | 3 ++- .../question/QuestionQueryService.java | 3 ++- .../QuestionCandidateCatalogServiceTest.java | 26 +++++++++++++++++++ .../question/QuestionCommandServiceTest.java | 2 ++ 6 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java index b9265d68..3d665e3e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/dto/response/QuestionCandidateResponse.java @@ -5,6 +5,7 @@ public record QuestionCandidateResponse( String content, int charLimit, boolean selected, - boolean custom + boolean custom, + String candidateKey ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java index f8ecdbab..03efb999 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogService.java @@ -12,6 +12,8 @@ @Service // 기본 문항 후보와 후보 판별 규칙을 제공한다. public class QuestionCandidateCatalogService { + private static final String DEFAULT_CANDIDATE_PREFIX = "default:"; + private static final String CUSTOM_CANDIDATE_PREFIX = "custom:"; private static final List DEFAULT_CANDIDATES = List.of( new QuestionCandidate(1L, "지원 동기와 입사 후 목표를 작성해주세요.", 700), new QuestionCandidate(2L, "지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000), @@ -30,7 +32,8 @@ public List getDefaultCandidateResponses(Set candidate.content(), candidate.charLimit(), selectedContents.contains(candidate.content()), - false + false, + toDefaultCandidateKey(candidate.id()) )) .toList(); } @@ -45,6 +48,14 @@ public void validateCustomCandidate(String content) { } } + public String toDefaultCandidateKey(Long candidateId) { + return DEFAULT_CANDIDATE_PREFIX + candidateId; + } + + public String toCustomCandidateKey(Long candidateId) { + return CUSTOM_CANDIDATE_PREFIX + candidateId; + } + private record QuestionCandidate(Long id, String content, int charLimit) { } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java index 3a3122c3..de7b5837 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandService.java @@ -80,7 +80,8 @@ public QuestionCandidateResponse addCustomQuestionCandidate( candidate.getContent(), candidate.getLimit(), selected, - true + true, + questionCandidateCatalogService.toCustomCandidateKey(candidate.getId()) ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java index 9f16c53d..695fbd64 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionQueryService.java @@ -54,7 +54,8 @@ public List getQuestionCandidates(User user, Long moc candidate.getContent(), candidate.getLimit(), selectedContents.contains(candidate.getContent()), - true + true, + questionCandidateCatalogService.toCustomCandidateKey(candidate.getId()) )) .forEach(candidates::add); diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java new file mode 100644 index 00000000..bc20b612 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCandidateCatalogServiceTest.java @@ -0,0 +1,26 @@ +package com.jobdri.jobdri_api.domain.analysis.service.question; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestionCandidateCatalogServiceTest { + + private final QuestionCandidateCatalogService questionCandidateCatalogService = + new QuestionCandidateCatalogService(); + + @Test + @DisplayName("기본 후보와 custom 후보는 서로 다른 prefix의 candidateKey를 사용한다") + void candidateKeysUseDistinctPrefixes() { + var defaultCandidates = questionCandidateCatalogService.getDefaultCandidateResponses(Set.of()); + + assertThat(defaultCandidates).isNotEmpty(); + assertThat(defaultCandidates.getFirst().candidateKey()).isEqualTo("default:1"); + assertThat(questionCandidateCatalogService.toCustomCandidateKey(1L)).isEqualTo("custom:1"); + assertThat(defaultCandidates.getFirst().candidateKey()) + .isNotEqualTo(questionCandidateCatalogService.toCustomCandidateKey(1L)); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java index a9c7944c..c8fe2117 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/question/QuestionCommandServiceTest.java @@ -75,6 +75,7 @@ void addCustomQuestionCandidateRecoversAfterUniqueConflict() { when(questionDomainSupport.getOwnedMockApply(user, 10L)).thenReturn(mockApply); doNothing().when(questionCandidateCatalogService).validateCustomCandidate("새로운 지원 동기를 작성해주세요."); + when(questionCandidateCatalogService.toCustomCandidateKey(7L)).thenReturn("custom:7"); when(questionDomainSupport.resolveCharLimit(900)).thenReturn(900); when(customQuestionCandidateRepository.findByMockApplyIdAndContent(10L, "새로운 지원 동기를 작성해주세요.")) .thenReturn(Optional.empty(), Optional.of(existingCandidate)); @@ -94,6 +95,7 @@ void addCustomQuestionCandidateRecoversAfterUniqueConflict() { assertThat(response.charLimit()).isEqualTo(900); assertThat(response.selected()).isFalse(); assertThat(response.custom()).isTrue(); + assertThat(response.candidateKey()).isEqualTo("custom:7"); verify(customQuestionCandidatePersistenceService).saveAndFlush(10L, "새로운 지원 동기를 작성해주세요.", 900); verify(customQuestionCandidateRepository, never()).saveAndFlush(existingCandidate); } From 4b2b12d2c1d2c4d217d565ddcb2192c421e5aac3 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 13:04:08 +0900 Subject: [PATCH 46/55] =?UTF-8?q?[Refactor]=20evaluation=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=82=B4=EB=B6=80=20=EB=AA=A8=EB=8D=B8=EA=B3=BC=20?= =?UTF-8?q?mapper=20=EB=8F=84=EC=9E=85=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EvaluationCandidateSnapshotMapper.java | 29 +++++++++++ .../mapper/EvaluationLlmSnapshotMapper.java | 38 ++++++++++++++ .../EvaluationMissingKeywordMapper.java | 19 +++++++ .../EvaluationMissingKeywordSourceMapper.java | 37 ++++++++++++++ .../EvaluationQuestionAnalysisMapper.java | 22 ++++++++ .../model/EvaluationCandidateSnapshot.java | 8 +++ .../analysis/model/EvaluationLlmSnapshot.java | 10 ++++ .../model/EvaluationMissingKeyword.java | 7 +++ .../EvaluationMissingKeywordCandidate.java | 8 +++ .../model/EvaluationMissingKeywordSource.java | 33 ++++++++++++ .../model/EvaluationQuestionAnalysis.java | 10 ++++ ...EvaluationCandidateSnapshotMapperTest.java | 32 ++++++++++++ .../EvaluationLlmSnapshotMapperTest.java | 51 +++++++++++++++++++ 13 files changed, 304 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java new file mode 100644 index 00000000..d8b7d0df --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java @@ -0,0 +1,29 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; + +import java.util.List; + +public final class EvaluationCandidateSnapshotMapper { + private EvaluationCandidateSnapshotMapper() { + } + + public static EvaluationCandidateSnapshot from(AnalysisCandidateResponse response) { + if (response == null || response.missingKeywordCandidates() == null) { + return new EvaluationCandidateSnapshot(List.of()); + } + List missingKeywordCandidates = response.missingKeywordCandidates().stream() + .map(candidate -> EvaluationMissingKeywordSourceMapper.fromAnalysisSource(candidate.source()) + .map(source -> new EvaluationMissingKeywordCandidate( + candidate.keyword(), + source, + candidate.relatedRequirement() + )) + .orElse(null)) + .filter(candidate -> candidate != null) + .toList(); + return new EvaluationCandidateSnapshot(missingKeywordCandidates); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java new file mode 100644 index 00000000..4c111263 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java @@ -0,0 +1,38 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; + +import java.util.List; +import java.util.Optional; + +public final class EvaluationLlmSnapshotMapper { + private EvaluationLlmSnapshotMapper() { + } + + public static EvaluationLlmSnapshot from(AnalysisLlmResponse response) { + if (response == null) { + return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); + } + List keyStrengthQuotes = response.keyStrengths() == null + ? List.of() + : response.keyStrengths().stream() + .map(AnalysisLlmResponse.HighlightItem::quote) + .toList(); + List missingKeywords = response.missingKeywords() == null + ? List.of() + : response.missingKeywords().stream() + .map(EvaluationMissingKeywordMapper::from) + .flatMap(Optional::stream) + .toList(); + List questionAnalyses = response.questionAnalyses() == null + ? List.of() + : response.questionAnalyses().stream() + .map(EvaluationQuestionAnalysisMapper::from) + .filter(questionAnalysis -> questionAnalysis != null) + .toList(); + return new EvaluationLlmSnapshot(keyStrengthQuotes, missingKeywords, questionAnalyses); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java new file mode 100644 index 00000000..2ce25340 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; + +import java.util.Optional; + +public final class EvaluationMissingKeywordMapper { + private EvaluationMissingKeywordMapper() { + } + + public static Optional from(AnalysisLlmResponse.MissingKeywordItem item) { + if (item == null) { + return Optional.empty(); + } + return EvaluationMissingKeywordSourceMapper.fromAnalysisSource(item.source()) + .map(source -> new EvaluationMissingKeyword(item.keyword(), source)); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java new file mode 100644 index 00000000..383c5b99 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; + +import java.util.Optional; + +public final class EvaluationMissingKeywordSourceMapper { + private EvaluationMissingKeywordSourceMapper() { + } + + public static Optional fromAnalysisSource(String value) { + return EvaluationMissingKeywordSource.from(value); + } + + public static Optional fromAnalysisSource(MissingKeywordSource source) { + if (source == null) { + return Optional.empty(); + } + return switch (source) { + case MAIN_TASK -> Optional.of(EvaluationMissingKeywordSource.MAIN_TASK); + case QUALIFICATION -> Optional.of(EvaluationMissingKeywordSource.QUALIFICATION); + case PREFERENCE -> Optional.of(EvaluationMissingKeywordSource.PREFERENCE); + }; + } + + public static MissingKeywordSource toAnalysisSource(EvaluationMissingKeywordSource source) { + if (source == null) { + return null; + } + return switch (source) { + case MAIN_TASK -> MissingKeywordSource.MAIN_TASK; + case QUALIFICATION -> MissingKeywordSource.QUALIFICATION; + case PREFERENCE -> MissingKeywordSource.PREFERENCE; + }; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java new file mode 100644 index 00000000..f1d37775 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java @@ -0,0 +1,22 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; + +public final class EvaluationQuestionAnalysisMapper { + private EvaluationQuestionAnalysisMapper() { + } + + public static EvaluationQuestionAnalysis from(AnalysisLlmResponse.QuestionAnalysisItem item) { + if (item == null) { + return null; + } + return new EvaluationQuestionAnalysis( + item.questionId(), + item.sentence(), + item.status(), + item.reason(), + item.improvement() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java new file mode 100644 index 00000000..cd03d0ae --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationCandidateSnapshot( + List missingKeywordCandidates +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java new file mode 100644 index 00000000..4e01a388 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationLlmSnapshot( + List keyStrengthQuotes, + List missingKeywords, + List questionAnalyses +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java new file mode 100644 index 00000000..3fdc7d1c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeyword.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeyword( + String keyword, + EvaluationMissingKeywordSource source +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java new file mode 100644 index 00000000..ff274889 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordCandidate.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeywordCandidate( + String keyword, + EvaluationMissingKeywordSource source, + String relatedRequirement +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java new file mode 100644 index 00000000..2d34514c --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSource.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.Arrays; +import java.util.Optional; + +public enum EvaluationMissingKeywordSource { + MAIN_TASK("mainTask", "MAIN_TASK", "MAIN_TASKS"), + QUALIFICATION("qualification", "QUALIFICATION", "QUALIFICATIONS"), + PREFERENCE("preference", "PREFERENCE", "PREFERENCES"); + + private final String value; + private final String[] aliases; + + EvaluationMissingKeywordSource(String value, String... aliases) { + this.value = value; + this.aliases = aliases; + } + + public String value() { + return value; + } + + public static Optional from(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.trim(); + return Arrays.stream(values()) + .filter(source -> source.value.equalsIgnoreCase(normalized) + || Arrays.stream(source.aliases).anyMatch(alias -> alias.equalsIgnoreCase(normalized))) + .findFirst(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java new file mode 100644 index 00000000..acfa661d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationQuestionAnalysis.java @@ -0,0 +1,10 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationQuestionAnalysis( + Long questionId, + String sentence, + String status, + String reason, + String improvement +) { +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java new file mode 100644 index 00000000..42c0f621 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java @@ -0,0 +1,32 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationCandidateSnapshotMapperTest { + + @Test + @DisplayName("candidate response를 evaluation snapshot으로 변환한다") + void mapsCandidateResponseToEvaluationSnapshot() { + AnalysisCandidateResponse response = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of( + new AnalysisCandidateResponse.MissingKeywordCandidate("Spring Boot", "qualification", "Spring Boot 실무 경험"), + new AnalysisCandidateResponse.MissingKeywordCandidate("ignored", "unknown", "N/A") + ) + ); + + EvaluationCandidateSnapshot snapshot = EvaluationCandidateSnapshotMapper.from(response); + + assertThat(snapshot.missingKeywordCandidates()) + .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) + .containsExactly("Spring Boot:QUALIFICATION"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java new file mode 100644 index 00000000..f43dcc9b --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java @@ -0,0 +1,51 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationLlmSnapshotMapperTest { + + @Test + @DisplayName("runtime llm response를 evaluation snapshot으로 변환한다") + void mapsAnalysisLlmResponseToEvaluationSnapshot() { + AnalysisLlmResponse response = new AnalysisLlmResponse( + 4, + 4, + 4, + "feedback", + List.of( + new AnalysisLlmResponse.HighlightItem("강점", "문장 A") + ), + List.of(), + List.of( + new AnalysisLlmResponse.MissingKeywordItem("Spring Boot", "mainTask"), + new AnalysisLlmResponse.MissingKeywordItem("무시됨", "unknown") + ), + List.of( + new AnalysisLlmResponse.QuestionAnalysisItem( + 1L, + "문장", + "mentioned", + "근거", + "개선" + ) + ) + ); + + EvaluationLlmSnapshot snapshot = EvaluationLlmSnapshotMapper.from(response); + + assertThat(snapshot.keyStrengthQuotes()).containsExactly("문장 A"); + assertThat(snapshot.missingKeywords()) + .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) + .containsExactly("Spring Boot:MAIN_TASK"); + assertThat(snapshot.questionAnalyses()) + .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) + .containsExactly("문장:mentioned"); + } +} From 76ea303d9759fd71df0b52d2d2f6ee8969c56a5c Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 13:06:33 +0900 Subject: [PATCH 47/55] =?UTF-8?q?[Refactor]=20NLG=20evaluation=EC=9D=84=20?= =?UTF-8?q?evaluation=20snapshot=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/NlgEvaluationBatchService.java | 79 ++++--------- .../mapper/EvaluationLlmSnapshotParser.java | 104 ++++++++++++++++++ .../EvaluationLlmSnapshotParserTest.java | 59 ++++++++++ 3 files changed, 187 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index 9210d17f..efc5814b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -5,8 +5,10 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -64,6 +66,11 @@ class NlgEvaluationBatchService { private final NlgEvaluationAiClient nlgEvaluationAiClient; private final ObjectMapper objectMapper; + private final EvaluationLlmSnapshotParser evaluationLlmSnapshotParser; + + NlgEvaluationBatchService(NlgEvaluationAiClient nlgEvaluationAiClient, ObjectMapper objectMapper) { + this(nlgEvaluationAiClient, objectMapper, new EvaluationLlmSnapshotParser(objectMapper)); + } NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { validateDifferentFiles(inputPath, outputPath); @@ -140,7 +147,9 @@ private NlgEvaluationAiClient.NlgJudgeInput buildJudgeInput( requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "question", caseId), requiredResolvedValue(row, sourceCaseRows, resolvedHeaders, "answer", caseId), questionAnalysesJson, - readKeyStrengthsJson(resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson")), + writeJson(evaluationLlmSnapshotParser.parseRawLlmResponse( + resolvedValue(row, sourceCaseRows, resolvedHeaders, "rawLlmResponseJson") + ).keyStrengthQuotes()), missingKeywordsJson, value(row, "rawCandidateResponseJson"), value(row, "sanitizedCandidateResponseJson"), @@ -389,8 +398,8 @@ private List validMissingKey if (evaluations == null || evaluations.isEmpty()) { return List.of(); } - List actualMissingKeywords = - readActualMissingKeywords(input.missingKeywordsJson(), input.caseId()); + List actualMissingKeywords = + evaluationLlmSnapshotParser.parseMissingKeywords(input.missingKeywordsJson(), input.caseId()); List valid = new ArrayList<>(); for (NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation : evaluations) { Optional invalidReason = @@ -413,7 +422,7 @@ private List validMissingKey private Optional missingKeywordMissInvalidReason( NlgEvaluationAiClient.NlgJudgeInput input, - List actualMissingKeywords, + List actualMissingKeywords, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation ) { if (evaluation == null) { @@ -428,15 +437,15 @@ private Optional missingKeywordMissInvalidReaso if (!StringUtils.hasText(evaluation.reason())) { return Optional.of(MissingKeywordMissInvalidReason.BLANK_REASON); } - Optional source = parseJudgeMissingKeywordSource(evaluation.source()); - if (source.isEmpty() || source.get() == MissingKeywordSource.PREFERENCE) { + Optional source = parseJudgeMissingKeywordSource(evaluation.source()); + if (source.isEmpty() || source.get() == EvaluationMissingKeywordSource.PREFERENCE) { return Optional.of(MissingKeywordMissInvalidReason.INVALID_SOURCE); } if (AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.keyword()) || AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { return Optional.of(MissingKeywordMissInvalidReason.STRUCTURED_QUALIFICATION); } - String sourceText = source.get() == MissingKeywordSource.MAIN_TASK + String sourceText = source.get() == EvaluationMissingKeywordSource.MAIN_TASK ? input.mainTasks() : input.qualifications(); if (!containsNormalized(sourceText, evaluation.relatedRequirement())) { @@ -444,7 +453,7 @@ private Optional missingKeywordMissInvalidReaso } if (!AnalysisSanitizationRules.isValidMissingKeyword( evaluation.keyword(), - source.get(), + EvaluationMissingKeywordSourceMapper.toAnalysisSource(source.get()), input.mainTasks(), input.qualifications() )) { @@ -456,30 +465,18 @@ private Optional missingKeywordMissInvalidReaso return Optional.empty(); } - private Optional parseJudgeMissingKeywordSource(String source) { - if (!StringUtils.hasText(source)) { - return Optional.empty(); - } - String normalized = source.trim(); - if ("MAIN_TASK".equalsIgnoreCase(normalized) || "MAIN_TASKS".equalsIgnoreCase(normalized)) { - return Optional.of(MissingKeywordSource.MAIN_TASK); - } - if ("QUALIFICATION".equalsIgnoreCase(normalized) || "QUALIFICATIONS".equalsIgnoreCase(normalized)) { - return Optional.of(MissingKeywordSource.QUALIFICATION); - } - return MissingKeywordSource.from(normalized); + private Optional parseJudgeMissingKeywordSource(String source) { + return EvaluationMissingKeywordSource.from(source); } private boolean sameMissingKeyword( - AnalysisLlmResponse.MissingKeywordItem actual, + EvaluationMissingKeyword actual, NlgEvaluationResponse.MissingKeywordMissEvaluation evaluation, - MissingKeywordSource source + EvaluationMissingKeywordSource source ) { return actual != null && normalize(actual.keyword()).equals(normalize(evaluation.keyword())) - && parseJudgeMissingKeywordSource(actual.source()) - .map(actualSource -> actualSource == source) - .orElse(false); + && actual.source() == source; } private List sanitizeErrorCodes( @@ -601,22 +598,6 @@ private int readJsonArraySize(String json, String fieldName, String caseId) { } } - private List readActualMissingKeywords(String json, String caseId) { - if (!StringUtils.hasText(json)) { - return List.of(); - } - try { - List values = objectMapper.readValue( - json, - new TypeReference<>() { - } - ); - return values == null ? List.of() : values; - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); - } - } - private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJson) { if (!StringUtils.hasText(sanitizedCandidateResponseJson)) { return 0; @@ -634,18 +615,6 @@ private int readMissingKeywordCandidateCount(String sanitizedCandidateResponseJs } } - private String readKeyStrengthsJson(String rawLlmResponseJson) { - if (!StringUtils.hasText(rawLlmResponseJson)) { - return "[]"; - } - try { - AnalysisLlmResponse response = objectMapper.readValue(rawLlmResponseJson, AnalysisLlmResponse.class); - return writeJson(response == null || response.keyStrengths() == null ? List.of() : response.keyStrengths()); - } catch (JsonProcessingException e) { - return "[]"; - } - } - private SourceCaseRows loadSourceCaseRowsIfNeeded(Path inputPath, List inputHeaders) throws IOException { boolean hasAllContextHeaders = List.of("mainTasks", "qualifications", "question", "answer").stream() .allMatch(field -> directHeader(inputHeaders, field).isPresent()); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java new file mode 100644 index 00000000..3850d80a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java @@ -0,0 +1,104 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationLlmSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationLlmSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationLlmSnapshot parseRawLlmResponse(String rawLlmResponseJson) { + if (!StringUtils.hasText(rawLlmResponseJson)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(rawLlmResponseJson); + return new EvaluationLlmSnapshot( + readKeyStrengthQuotes(root.path("keyStrengths")), + readMissingKeywords(root.path("missingKeywords")), + readQuestionAnalyses(root.path("questionAnalyses")) + ); + } catch (JsonProcessingException e) { + return emptySnapshot(); + } + } + + public List parseMissingKeywords(String json, String caseId) { + if (!StringUtils.hasText(json)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(json); + if (!root.isArray()) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId); + } + return readMissingKeywords(root); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("aiMissingKeywordsJson must be a JSON array. caseId=" + caseId, e); + } + } + + private EvaluationLlmSnapshot emptySnapshot() { + return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); + } + + private List readKeyStrengthQuotes(JsonNode keyStrengthsNode) { + if (!keyStrengthsNode.isArray()) { + return List.of(); + } + List quotes = new ArrayList<>(); + for (JsonNode item : keyStrengthsNode) { + String quote = item.path("quote").asText(null); + if (StringUtils.hasText(quote)) { + quotes.add(quote); + } + } + return quotes; + } + + private List readMissingKeywords(JsonNode missingKeywordsNode) { + if (!missingKeywordsNode.isArray()) { + return List.of(); + } + List missingKeywords = new ArrayList<>(); + for (JsonNode item : missingKeywordsNode) { + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + if (!StringUtils.hasText(keyword)) { + continue; + } + EvaluationMissingKeywordSource.from(source) + .ifPresent(value -> missingKeywords.add(new EvaluationMissingKeyword(keyword, value))); + } + return missingKeywords; + } + + private List readQuestionAnalyses(JsonNode questionAnalysesNode) { + if (!questionAnalysesNode.isArray()) { + return List.of(); + } + List questionAnalyses = new ArrayList<>(); + for (JsonNode item : questionAnalysesNode) { + questionAnalyses.add(new EvaluationQuestionAnalysis( + item.path("questionId").isNumber() ? item.path("questionId").longValue() : null, + item.path("sentence").asText(null), + item.path("status").asText(null), + item.path("reason").asText(null), + item.path("improvement").asText(null) + )); + } + return questionAnalyses; + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java new file mode 100644 index 00000000..a9a7eec1 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java @@ -0,0 +1,59 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvaluationLlmSnapshotParserTest { + + private final EvaluationLlmSnapshotParser parser = new EvaluationLlmSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("raw llm response json을 evaluation snapshot으로 파싱한다") + void parsesRawLlmResponseJson() { + String rawLlmResponseJson = """ + { + "keyStrengths": [{"quote": "강점 문장"}], + "missingKeywords": [{"keyword": "Spring Boot", "source": "MAIN_TASK"}], + "questionAnalyses": [{"questionId": 1, "sentence": "문장", "status": "mentioned", "reason": "근거", "improvement": "개선"}] + } + """; + + EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(rawLlmResponseJson); + + assertThat(snapshot.keyStrengthQuotes()).containsExactly("강점 문장"); + assertThat(snapshot.missingKeywords()) + .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) + .containsExactly("Spring Boot:MAIN_TASK"); + assertThat(snapshot.questionAnalyses()) + .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) + .containsExactly("문장:mentioned"); + } + + @Test + @DisplayName("missing keyword json을 evaluation 모델로 파싱한다") + void parsesMissingKeywordJson() { + List missingKeywords = parser.parseMissingKeywords( + "[{\"keyword\":\"Spring Boot\",\"source\":\"QUALIFICATIONS\"}]", + "EV-01" + ); + + assertThat(missingKeywords) + .extracting(keyword -> keyword.toString()) + .containsExactly("EvaluationMissingKeyword[keyword=Spring Boot, source=QUALIFICATION]"); + } + + @Test + @DisplayName("missing keyword json이 배열이 아니면 예외를 던진다") + void rejectsNonArrayMissingKeywordJson() { + assertThatThrownBy(() -> parser.parseMissingKeywords("{\"keyword\":\"Spring Boot\"}", "EV-01")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("aiMissingKeywordsJson must be a JSON array"); + } +} From 510719ad0815bf2935311b504059ba8815a6cd15 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 13:18:41 +0900 Subject: [PATCH 48/55] =?UTF-8?q?[Fix]=20evaluation=20bean=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=EC=99=80=20analysis=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B5=EA=B5=AC=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/NlgEvaluationBatchService.java | 13 +++++++++---- .../analysis/service/ai/AnalysisAiClientTest.java | 11 +++-------- .../analysis/service/core/AnalysisServiceTest.java | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index efc5814b..5183acfb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -10,8 +10,8 @@ import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -30,7 +30,6 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j class NlgEvaluationBatchService { private static final int MIN_SCORE = 1; @@ -68,8 +67,14 @@ class NlgEvaluationBatchService { private final ObjectMapper objectMapper; private final EvaluationLlmSnapshotParser evaluationLlmSnapshotParser; - NlgEvaluationBatchService(NlgEvaluationAiClient nlgEvaluationAiClient, ObjectMapper objectMapper) { - this(nlgEvaluationAiClient, objectMapper, new EvaluationLlmSnapshotParser(objectMapper)); + @Autowired + NlgEvaluationBatchService( + NlgEvaluationAiClient nlgEvaluationAiClient, + ObjectMapper objectMapper + ) { + this.nlgEvaluationAiClient = nlgEvaluationAiClient; + this.objectMapper = objectMapper; + this.evaluationLlmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); } NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java index 540b1a19..c0f4e780 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClientTest.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.analysis.service.ai; +import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; +import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.jobdri.jobdri_api.domain.analysis.dto.internal.criteria.JobCategoryEvaluationCriteria; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; @@ -20,8 +22,6 @@ import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService; import com.jobdri.jobdri_api.domain.corpus.service.CorpusRetrievalService.RetrievalContext; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; -import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; -import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.openai.client.OpenAIClient; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -50,12 +50,7 @@ class AnalysisAiClientTest { fewShotProperties, objectMapper ); - private final OpenAiAnalysisAdapter openAiAnalysisAdapter = new OpenAiAnalysisAdapter( - mock(OpenAIClient.class), - mock(LlmConcurrencyLimiter.class), - mock(AsyncMetricsRecorder.class), - analysisResponseParser - ); + private final OpenAiAnalysisAdapter openAiAnalysisAdapter = mock(OpenAiAnalysisAdapter.class); private final AnalysisAiClient analysisAiClient = new AnalysisAiClient( mock(OpenAIClient.class), mock(CorpusRetrievalService.class), diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java index d6f9d4f5..ed112019 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/core/AnalysisServiceTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.analysis.service.core; +import com.jobdri.jobdri_api.domain.analysis.application.model.AnalysisExecutionPayload; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisResponse; import com.jobdri.jobdri_api.domain.analysis.dto.internal.worker.SimilarJobPostingContext; From af05019d99fad8d21d86c1972e86a410b332e838 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 13:34:29 +0900 Subject: [PATCH 49/55] =?UTF-8?q?[Refactor]=20evaluation=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20sanitization=20facade=20=EB=8F=84=EC=9E=85=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EvaluationAnalysisBatchService.java | 41 ++++++++-- .../MissingKeywordSanitizerReplayService.java | 20 ++++- .../analysis/NlgEvaluationBatchService.java | 19 +++-- .../EvaluationSanitizationService.java | 78 +++++++++++++++++++ 4 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index 0808cc72..bece40fd 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -12,9 +12,10 @@ import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -31,7 +32,6 @@ import java.util.Set; @Service -@RequiredArgsConstructor @Slf4j public class EvaluationAnalysisBatchService { private static final Long EVALUATION_QUESTION_ID = 1L; @@ -49,6 +49,33 @@ public class EvaluationAnalysisBatchService { private final AnalysisAiClient analysisAiClient; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final ObjectMapper objectMapper; + private final EvaluationSanitizationService evaluationSanitizationService; + + @Autowired + public EvaluationAnalysisBatchService( + AnalysisAiClient analysisAiClient, + JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService + ) { + this.analysisAiClient = analysisAiClient; + this.jobCategoryEvaluationCriteriaProvider = jobCategoryEvaluationCriteriaProvider; + this.objectMapper = objectMapper; + this.evaluationSanitizationService = evaluationSanitizationService; + } + + EvaluationAnalysisBatchService( + AnalysisAiClient analysisAiClient, + JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + ObjectMapper objectMapper + ) { + this( + analysisAiClient, + jobCategoryEvaluationCriteriaProvider, + objectMapper, + new EvaluationSanitizationService() + ); + } public EvaluationBatchSummary run(Path inputPath, Path outputPath) throws IOException { List cases = readCases(inputPath); @@ -214,7 +241,7 @@ private List buildMissingKeywords( if (source.isEmpty()) { continue; } - if (!AnalysisSanitizationRules.isValidMissingKeyword( + if (!evaluationSanitizationService.isValidMissingKeyword( keyword, source.get(), evaluationCase.mainTasks(), @@ -222,7 +249,7 @@ private List buildMissingKeywords( )) { continue; } - if (AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers( + if (evaluationSanitizationService.isMissingKeywordMentionedInAnswers( keyword, evaluationCase.answer() )) { @@ -275,11 +302,11 @@ private List buildQuestionAnalyses( continue; } if (status == QuestionAnalysisStatus.PROVEN - && !AnalysisSanitizationRules.hasValidProvenReason(item.reason())) { + && !evaluationSanitizationService.hasValidProvenReason(item.reason())) { continue; } if (status == QuestionAnalysisStatus.FABRICATED - && !AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence( + && !evaluationSanitizationService.hasFabricatedDirectConflictEvidence( item.sentence(), item.reason() )) { @@ -435,7 +462,7 @@ private String normalizeImprovement( String improvement, QuestionAnalysisStatus status ) { - return AnalysisSanitizationRules.normalizeImprovement( + return evaluationSanitizationService.normalizeImprovement( sentence, answer, improvement, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java index 751aab77..06d89c84 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java @@ -5,9 +5,10 @@ import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationDecision; import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -23,7 +24,6 @@ import java.util.stream.Collectors; @Service -@RequiredArgsConstructor @Slf4j class MissingKeywordSanitizerReplayService { private static final List REQUIRED_HEADERS = List.of( @@ -76,6 +76,20 @@ class MissingKeywordSanitizerReplayService { ); private final ObjectMapper objectMapper; + private final EvaluationSanitizationService evaluationSanitizationService; + + @Autowired + MissingKeywordSanitizerReplayService( + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService + ) { + this.objectMapper = objectMapper; + this.evaluationSanitizationService = evaluationSanitizationService; + } + + MissingKeywordSanitizerReplayService(ObjectMapper objectMapper) { + this(objectMapper, new EvaluationSanitizationService()); + } ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOException { List headers = EvaluationCsvSupport.readHeaders(input); @@ -112,7 +126,7 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept ); List rawCandidates = safeMissingKeywordCandidates(rawResponse); - MissingKeywordSanitizationResult replayResult = MissingKeywordSanitizer.sanitize( + MissingKeywordSanitizationResult replayResult = evaluationSanitizationService.sanitizeMissingKeywordCandidates( value(row, "mainTasks"), value(row, "qualifications"), value(row, "answer"), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index 5183acfb..d2863984 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -9,7 +9,7 @@ import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; @@ -66,15 +66,22 @@ class NlgEvaluationBatchService { private final NlgEvaluationAiClient nlgEvaluationAiClient; private final ObjectMapper objectMapper; private final EvaluationLlmSnapshotParser evaluationLlmSnapshotParser; + private final EvaluationSanitizationService evaluationSanitizationService; @Autowired NlgEvaluationBatchService( NlgEvaluationAiClient nlgEvaluationAiClient, - ObjectMapper objectMapper + ObjectMapper objectMapper, + EvaluationSanitizationService evaluationSanitizationService ) { this.nlgEvaluationAiClient = nlgEvaluationAiClient; this.objectMapper = objectMapper; this.evaluationLlmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + this.evaluationSanitizationService = evaluationSanitizationService; + } + + NlgEvaluationBatchService(NlgEvaluationAiClient nlgEvaluationAiClient, ObjectMapper objectMapper) { + this(nlgEvaluationAiClient, objectMapper, new EvaluationSanitizationService()); } NlgEvaluationSummary run(Path inputPath, Path outputPath) throws IOException { @@ -446,8 +453,8 @@ private Optional missingKeywordMissInvalidReaso if (source.isEmpty() || source.get() == EvaluationMissingKeywordSource.PREFERENCE) { return Optional.of(MissingKeywordMissInvalidReason.INVALID_SOURCE); } - if (AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.keyword()) - || AnalysisSanitizationRules.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { + if (evaluationSanitizationService.isStructuredQualificationKeyword(evaluation.keyword()) + || evaluationSanitizationService.isStructuredQualificationKeyword(evaluation.relatedRequirement())) { return Optional.of(MissingKeywordMissInvalidReason.STRUCTURED_QUALIFICATION); } String sourceText = source.get() == EvaluationMissingKeywordSource.MAIN_TASK @@ -456,7 +463,7 @@ private Optional missingKeywordMissInvalidReaso if (!containsNormalized(sourceText, evaluation.relatedRequirement())) { return Optional.of(MissingKeywordMissInvalidReason.RELATED_REQUIREMENT_NOT_IN_JD); } - if (!AnalysisSanitizationRules.isValidMissingKeyword( + if (!evaluationSanitizationService.isValidMissingKeyword( evaluation.keyword(), EvaluationMissingKeywordSourceMapper.toAnalysisSource(source.get()), input.mainTasks(), @@ -862,7 +869,7 @@ private boolean containsNormalized(String sourceText, String fragment) { } private String normalize(String value) { - return AnalysisSanitizationRules.normalizeText(value); + return evaluationSanitizationService.normalizeText(value); } private String truncateShortRationale(String rationale) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java new file mode 100644 index 00000000..19b940f9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java @@ -0,0 +1,78 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; +import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class EvaluationSanitizationService { + + public boolean isValidMissingKeyword( + String keyword, + EvaluationMissingKeywordSource source, + String mainTasks, + String qualifications + ) { + MissingKeywordSource analysisSource = EvaluationMissingKeywordSourceMapper.toAnalysisSource(source); + return analysisSource != null && AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + analysisSource, + mainTasks, + qualifications + ); + } + + public boolean isValidMissingKeyword( + String keyword, + MissingKeywordSource source, + String mainTasks, + String qualifications + ) { + return source != null && AnalysisSanitizationRules.isValidMissingKeyword( + keyword, + source, + mainTasks, + qualifications + ); + } + + public boolean isMissingKeywordMentionedInAnswers(String keyword, String answer) { + return AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, answer); + } + + public boolean hasValidProvenReason(String reason) { + return AnalysisSanitizationRules.hasValidProvenReason(reason); + } + + public boolean hasFabricatedDirectConflictEvidence(String sentence, String reason) { + return AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence(sentence, reason); + } + + public String normalizeImprovement(String sentence, String answer, String improvement, boolean proven) { + return AnalysisSanitizationRules.normalizeImprovement(sentence, answer, improvement, proven); + } + + public boolean isStructuredQualificationKeyword(String value) { + return AnalysisSanitizationRules.isStructuredQualificationKeyword(value); + } + + public String normalizeText(String value) { + return AnalysisSanitizationRules.normalizeText(value); + } + + public MissingKeywordSanitizationResult sanitizeMissingKeywordCandidates( + String mainTasks, + String qualifications, + String answer, + List candidates + ) { + return MissingKeywordSanitizer.sanitize(mainTasks, qualifications, answer, candidates); + } +} From beb77c39baa61c3761ab3363ff951c2db9d1d358 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 13:42:01 +0900 Subject: [PATCH 50/55] =?UTF-8?q?[Refactor]=20evaluation=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20AI=20port=20=EB=8F=84=EC=9E=85=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EvaluationAnalysisBatchService.java | 79 ++++++-------- ...AnalysisAiEvaluationAnalysisGenerator.java | 54 ++++++++++ .../model/EvaluationAnalysisCommand.java | 13 +++ .../model/EvaluationGeneratedResult.java | 15 +++ .../port/EvaluationAnalysisGenerator.java | 8 ++ .../EvaluationAnalysisBatchServiceTest.java | 102 ++++++------------ 6 files changed, 154 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index bece40fd..f66bbb16 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -7,13 +7,11 @@ import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -46,32 +44,27 @@ public class EvaluationAnalysisBatchService { "answer" ); - private final AnalysisAiClient analysisAiClient; - private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final EvaluationAnalysisGenerator evaluationAnalysisGenerator; private final ObjectMapper objectMapper; private final EvaluationSanitizationService evaluationSanitizationService; @Autowired public EvaluationAnalysisBatchService( - AnalysisAiClient analysisAiClient, - JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + EvaluationAnalysisGenerator evaluationAnalysisGenerator, ObjectMapper objectMapper, EvaluationSanitizationService evaluationSanitizationService ) { - this.analysisAiClient = analysisAiClient; - this.jobCategoryEvaluationCriteriaProvider = jobCategoryEvaluationCriteriaProvider; + this.evaluationAnalysisGenerator = evaluationAnalysisGenerator; this.objectMapper = objectMapper; this.evaluationSanitizationService = evaluationSanitizationService; } EvaluationAnalysisBatchService( - AnalysisAiClient analysisAiClient, - JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + EvaluationAnalysisGenerator evaluationAnalysisGenerator, ObjectMapper objectMapper ) { this( - analysisAiClient, - jobCategoryEvaluationCriteriaProvider, + evaluationAnalysisGenerator, objectMapper, new EvaluationSanitizationService() ); @@ -133,27 +126,17 @@ private void validateHeaders(List headers) { } private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCase) { - AnalysisPromptInput promptInput = new AnalysisPromptInput( + EvaluationGeneratedResult generatedResult = evaluationAnalysisGenerator.generate(new EvaluationAnalysisCommand( evaluationCase.caseId(), - "평가용 회사", + evaluationCase.jobCategoryMiddle(), evaluationCase.jobCategorySmall(), evaluationCase.mainTasks(), evaluationCase.qualifications(), evaluationCase.preferences(), - List.of(new AnalysisPromptInput.QuestionAnswer( - EVALUATION_QUESTION_ID, - evaluationCase.question(), - evaluationCase.answer() - )) - ); - - AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( - promptInput, - jobCategoryEvaluationCriteriaProvider - .findByMiddleName(evaluationCase.jobCategoryMiddle()) - .orElse(null) - ); - AnalysisLlmResponse llmResponse = aiCallResult.response(); + evaluationCase.question(), + evaluationCase.answer() + )); + AnalysisLlmResponse llmResponse = generatedResult.response(); int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit()); int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact()); @@ -163,7 +146,7 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa log.debug( "Evaluation serialized missing keyword flow. caseId={}, candidateMissingKeywordCount={}, finalMissingKeywordCount={}, evaluationSerializedMissingKeywordCount={}", evaluationCase.caseId(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), size(llmResponse == null ? null : llmResponse.missingKeywords()), missingKeywords.size() ); @@ -185,25 +168,25 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa writeJson(missingKeywords), writeJson(questionAnalyses), writeJson(llmResponse), - writeJson(aiCallResult.rawCandidateResponse()), - writeJson(aiCallResult.sanitizedCandidateResponse()), - writeJson(aiCallResult.candidateReviewResponse()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().analysisCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().analysisCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().strengthCandidates()), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), - acceptedDecisionCount(aiCallResult.candidateReviewResponse()), - rejectedDecisionCount(aiCallResult.candidateReviewResponse()), - rejectionCodeCounts(aiCallResult.candidateReviewResponse()), + writeJson(generatedResult.rawCandidateResponse()), + writeJson(generatedResult.sanitizedCandidateResponse()), + writeJson(generatedResult.candidateReviewResponse()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().analysisCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().analysisCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().strengthCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), + acceptedDecisionCount(generatedResult.candidateReviewResponse()), + rejectedDecisionCount(generatedResult.candidateReviewResponse()), + rejectionCodeCounts(generatedResult.candidateReviewResponse()), questionAnalyses.size(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().strengthCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().strengthCandidates()), llmResponse.keyStrengths() == null ? 0 : llmResponse.keyStrengths().size(), - size(aiCallResult.sanitizedCandidateResponse() == null ? null : aiCallResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), missingKeywords.size(), - aiCallResult.candidateCallLatencyMs(), - aiCallResult.finalCallLatencyMs(), - aiCallResult.candidateCallLatencyMs(), - aiCallResult.finalCallLatencyMs(), + generatedResult.candidateCallLatencyMs(), + generatedResult.finalCallLatencyMs(), + generatedResult.candidateCallLatencyMs(), + generatedResult.finalCallLatencyMs(), null, null, null, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java new file mode 100644 index 00000000..6aa9bb79 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java @@ -0,0 +1,54 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.adapter; + +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; +import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; +import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AnalysisAiEvaluationAnalysisGenerator implements EvaluationAnalysisGenerator { + private static final Long EVALUATION_QUESTION_ID = 1L; + + private final AnalysisAiClient analysisAiClient; + private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + + @Override + public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { + AnalysisPromptInput promptInput = new AnalysisPromptInput( + command.caseId(), + "평가용 회사", + command.jobCategorySmall(), + command.mainTasks(), + command.qualifications(), + command.preferences(), + List.of(new AnalysisPromptInput.QuestionAnswer( + EVALUATION_QUESTION_ID, + command.question(), + command.answer() + )) + ); + + AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( + promptInput, + jobCategoryEvaluationCriteriaProvider + .findByMiddleName(command.jobCategoryMiddle()) + .orElse(null) + ); + return new EvaluationGeneratedResult( + aiCallResult.response(), + aiCallResult.rawCandidateResponse(), + aiCallResult.sanitizedCandidateResponse(), + aiCallResult.candidateReviewResponse(), + aiCallResult.candidateCallLatencyMs(), + aiCallResult.finalCallLatencyMs() + ); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java new file mode 100644 index 00000000..847f40d3 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationAnalysisCommand.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationAnalysisCommand( + String caseId, + String jobCategoryMiddle, + String jobCategorySmall, + String mainTasks, + String qualifications, + String preferences, + String question, + String answer +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java new file mode 100644 index 00000000..314183f1 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; +import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; + +public record EvaluationGeneratedResult( + AnalysisLlmResponse response, + AnalysisCandidateResponse rawCandidateResponse, + AnalysisCandidateResponse sanitizedCandidateResponse, + CandidateReviewResponse candidateReviewResponse, + long candidateCallLatencyMs, + long finalCallLatencyMs +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java new file mode 100644 index 00000000..482a6f32 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/port/EvaluationAnalysisGenerator.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.port; + +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; + +public interface EvaluationAnalysisGenerator { + EvaluationGeneratedResult generate(EvaluationAnalysisCommand command); +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java index 62c11de6..c99d86f4 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java @@ -4,10 +4,8 @@ import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; -import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; -import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -21,7 +19,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -35,14 +32,10 @@ class EvaluationAnalysisBatchServiceTest { @Test @DisplayName("LLM 응답을 검증해 평가 결과 CSV로 저장한다") void runWritesSanitizedEvaluationResults() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -103,20 +96,16 @@ void runWritesSanitizedEvaluationResults() throws Exception { assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("잘못된 출처"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("답변에 없는 문장"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("missing은 저장하지 않습니다."); - verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), any()); + verify(generator).generate(any()); } @Test @DisplayName("평가 결과도 운영과 동일하게 missingKeywords와 improvement를 후처리한다") void runAppliesProductionSanitizationRules() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -177,14 +166,10 @@ void runAppliesProductionSanitizationRules() throws Exception { @Test @DisplayName("평가 결과도 운영과 동일하게 유효한 PROVEN/FABRICATED를 보존하고 raw/final 비교 정보를 남긴다") void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -253,14 +238,10 @@ void runKeepsRawAndAppliesFinalStatusFilter() throws Exception { @Test @DisplayName("평가 저장 경로도 reason이 null 또는 빈 PROVEN을 제외한다") void runSkipsProvenWithMissingReason() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse( 80, 70, @@ -301,16 +282,12 @@ void runSkipsProvenWithMissingReason() throws Exception { } @Test - @DisplayName("없는 직무 중분류는 보조 기준 없이 분석한다") - void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + @DisplayName("중분류 기준 조회 여부와 무관하게 evaluation generator를 통해 분석한다") + void runDelegatesAnalysisThroughEvaluationGenerator() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenReturn(result(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of()))); Path input = tempDir.resolve("evaluation_cases.csv"); @@ -324,19 +301,15 @@ void runOmitsCriteriaWhenMiddleNameNotFound() throws Exception { service.run(input, output); - verify(analysisAiClient).analyzeForEvaluationResult(any(AnalysisPromptInput.class), isNull()); + verify(generator).generate(any()); } @Test @DisplayName("필수 CSV header가 없으면 AI 호출 전에 실패한다") void runFailsFastWhenRequiredHeaderIsMissing() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); Path input = tempDir.resolve("evaluation_cases.csv"); Path output = tempDir.resolve("evaluation_ai_results.csv"); Files.writeString( @@ -350,20 +323,16 @@ void runFailsFastWhenRequiredHeaderIsMissing() throws Exception { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("missing required headers") .hasMessageContaining("answer"); - verifyNoInteractions(analysisAiClient); + verifyNoInteractions(generator); } @Test @DisplayName("LLM 호출 실패 케이스도 실패 row로 CSV에 기록한다") void runWritesFailureRowWhenAnalyzeFails() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) .thenThrow(new RuntimeException("rate limit exceeded")); Path input = tempDir.resolve("evaluation_cases.csv"); @@ -390,15 +359,11 @@ void runWritesFailureRowWhenAnalyzeFails() throws Exception { @Test @DisplayName("평가 CSV 후보/decision 통계는 검증 후 결과 기준으로 기록한다") void runWritesValidatedCandidateDecisionCounts() throws Exception { - AnalysisAiClient analysisAiClient = mock(AnalysisAiClient.class); + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); ObjectMapper objectMapper = new ObjectMapper(); - EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService( - analysisAiClient, - new JobCategoryEvaluationCriteriaProvider(objectMapper), - objectMapper - ); - when(analysisAiClient.analyzeForEvaluationResult(any(AnalysisPromptInput.class), any())) - .thenReturn(new AnalysisAiCallResult( + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())) + .thenReturn(new EvaluationGeneratedResult( new AnalysisLlmResponse( 80, 70, @@ -482,7 +447,6 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { 60, "피드백" ), - true, 10, 20 )); @@ -512,7 +476,7 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { assertThat(row.get("rejectionCodeCounts")).doesNotContain("NONE"); } - private AnalysisAiCallResult result(AnalysisLlmResponse response) { - return new AnalysisAiCallResult(response, null, null, null, false, 0, 1); + private EvaluationGeneratedResult result(AnalysisLlmResponse response) { + return new EvaluationGeneratedResult(response, null, null, null, 0, 1); } } From 3fc81acb101381ff4afcb8a3cd2a76115768b485 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 14:23:13 +0900 Subject: [PATCH 51/55] =?UTF-8?q?[Fix]=20evaluation=20=EC=82=AC=EB=A1=80?= =?UTF-8?q?=20deadline=EA=B3=BC=20command=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/OpenAiAnalysisAdapter.java | 19 +++- .../analysis/service/ai/AnalysisAiClient.java | 88 ++++++++++++++---- ...AnalysisAiEvaluationAnalysisGenerator.java | 25 +++++- .../EvaluationAnalysisBatchServiceTest.java | 89 ++++++++++++++++++- 4 files changed, 199 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java index 08daf24b..49ba7b6f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/infrastructure/ai/OpenAiAnalysisAdapter.java @@ -4,12 +4,15 @@ import com.jobdri.jobdri_api.global.config.LlmConcurrencyLimiter; import com.jobdri.jobdri_api.global.metrics.AsyncMetricsRecorder; import com.openai.client.OpenAIClient; +import com.openai.core.RequestOptions; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.StructuredResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.time.Duration; + @Component @RequiredArgsConstructor public class OpenAiAnalysisAdapter { @@ -22,18 +25,32 @@ public class OpenAiAnalysisAdapter { private String analysisModel; public T createStructuredResponse(String operationName, String prompt, Class responseType) { + return createStructuredResponse(operationName, prompt, responseType, null); + } + + public T createStructuredResponse( + String operationName, + String prompt, + Class responseType, + Duration timeout + ) { var params = ResponseCreateParams.builder() .model(analysisModel) .input(prompt) .temperature(0.2) .text(responseType) .build(); + RequestOptions requestOptions = timeout == null + ? null + : RequestOptions.builder().timeout(timeout).build(); long startedAt = System.nanoTime(); boolean success = false; try { StructuredResponse response = llmConcurrencyLimiter.execute( operationName, - () -> openAIClient.responses().create(params) + () -> requestOptions == null + ? openAIClient.responses().create(params) + : openAIClient.responses().create(params, requestOptions) ); T structuredContent = analysisResponseParser.extractStructuredContent(response); success = true; diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java index 88d93be7..56e72fbb 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/AnalysisAiClient.java @@ -26,6 +26,8 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.HashMap; @@ -100,19 +102,22 @@ public AnalysisLlmResponse analyze( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); case HYBRID_EXACT -> analyzeHybridExact( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); case SINGLE_PASS -> analyzeSinglePass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - "cover-letter-analysis" + "cover-letter-analysis", + null ).response(); }; } catch (GeneralException e) { @@ -155,6 +160,14 @@ public AnalysisLlmResponse analyzeForEvaluation( public AnalysisAiCallResult analyzeForEvaluationResult( AnalysisPromptInput promptInput, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria + ) { + return analyzeForEvaluationResult(promptInput, jobCategoryEvaluationCriteria, null); + } + + public AnalysisAiCallResult analyzeForEvaluationResult( + AnalysisPromptInput promptInput, + JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, + Instant deadline ) { try { return switch (resolveAnalysisMode()) { @@ -162,19 +175,22 @@ public AnalysisAiCallResult analyzeForEvaluationResult( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); case HYBRID_EXACT -> analyzeHybridExact( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); case SINGLE_PASS -> analyzeSinglePass( promptInput, emptyContext(), jobCategoryEvaluationCriteria, - "cover-letter-analysis-evaluation" + "cover-letter-analysis-evaluation", + deadline ); }; } catch (GeneralException e) { @@ -192,13 +208,15 @@ private AnalysisAiCallResult analyzeSinglePass( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { long startedAt = System.nanoTime(); AnalysisLlmResponse response = createStructuredResponse( operationName, buildPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), - AnalysisLlmResponse.class + AnalysisLlmResponse.class, + deadline ); response = analysisResponseParser.sanitizeSinglePassSubheadings(promptInput, response); return AnalysisAiCallResult.singlePass(response, elapsedMillis(startedAt)); @@ -208,13 +226,15 @@ private AnalysisAiCallResult analyzeTwoPass( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { long candidateStartedAt = System.nanoTime(); AnalysisCandidateResponse rawCandidates = createStructuredResponse( operationName + "-candidates", buildCandidatePrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria), - AnalysisCandidateResponse.class + AnalysisCandidateResponse.class, + deadline ); long candidateLatencyMs = elapsedMillis(candidateStartedAt); AnalysisCandidateResponse sanitizedCandidates = sanitizeCandidates(promptInput, rawCandidates); @@ -235,7 +255,8 @@ private AnalysisAiCallResult analyzeTwoPass( CandidateReviewResponse reviewResponse = createStructuredResponse( operationName + "-final", buildFinalPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates), - CandidateReviewResponse.class + CandidateReviewResponse.class, + deadline ); CandidateReviewResponse validatedReviewResponse = validateCandidateReview( promptInput, @@ -257,7 +278,8 @@ private AnalysisAiCallResult analyzeTwoPass( jobCategoryEvaluationCriteria, sanitizedCandidates, validatedReviewResponse, - operationName + operationName, + deadline ); AnalysisLlmResponse response = buildFinalResponse(promptInput, sanitizedCandidates, recheckedReviewResponse); long finalLatencyMs = elapsedMillis(finalStartedAt); @@ -294,19 +316,22 @@ private AnalysisAiCallResult analyzeHybridExact( AnalysisPromptInput promptInput, RetrievalContext referenceContext, JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, - String operationName + String operationName, + Instant deadline ) { AnalysisAiCallResult singlePassResult = analyzeSinglePass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - operationName + "-single-pass" + operationName + "-single-pass", + deadline ); AnalysisAiCallResult twoPassResult = analyzeTwoPass( promptInput, referenceContext, jobCategoryEvaluationCriteria, - operationName + "-two-pass" + operationName + "-two-pass", + deadline ); AnalysisLlmResponse merged = mergeHybridExact( singlePassResult.response(), @@ -335,6 +360,29 @@ private T createStructuredResponse(String operationName, String prompt, Clas return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); } + private T createStructuredResponse( + String operationName, + String prompt, + Class responseType, + Instant deadline + ) { + if (deadline == null) { + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + } + Duration remaining = Duration.between(Instant.now(), deadline); + if (remaining.isZero() || remaining.isNegative()) { + throw new GeneralException( + GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, + "평가 사례 처리 시간이 제한을 초과했습니다." + ); + } + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType, remaining); + } + + private T createStructuredResponse(String operationName, String prompt, Class responseType, Duration timeout) { + return openAiAnalysisAdapter.createStructuredResponse(operationName, prompt, responseType); + } + String buildPrompt( JobPosting jobPosting, List questions, @@ -553,7 +601,8 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( JobCategoryEvaluationCriteria jobCategoryEvaluationCriteria, AnalysisCandidateResponse sanitizedCandidates, CandidateReviewResponse reviewResponse, - String operationName + String operationName, + Instant deadline ) { int firstPassCandidates = sanitizedCandidates == null || sanitizedCandidates.analysisCandidates() == null ? 0 @@ -573,7 +622,8 @@ CandidateReviewResponse recheckWhenAllCandidatesRejected( CandidateRecheckResponse recheckResponse = createStructuredResponse( operationName + "-recheck", buildRecheckPrompt(promptInput, referenceContext, jobCategoryEvaluationCriteria, sanitizedCandidates, reviewResponse), - CandidateRecheckResponse.class + CandidateRecheckResponse.class, + deadline ); CandidateReviewResponse rechecked = applyRecheckResponse(promptInput, sanitizedCandidates, reviewResponse, recheckResponse); int recoveredMentionedCount = recoveredDecisionCount(rechecked, QuestionAnalysisStatus.MENTIONED); @@ -1635,7 +1685,7 @@ private RetrievalContext emptyContext() { return new RetrievalContext(List.of(), List.of()); } - AnalysisMode resolveAnalysisMode() { + public AnalysisMode resolveAnalysisMode() { if (StringUtils.hasText(analysisMode)) { String normalized = analysisMode.trim().replace('-', '_').toUpperCase(java.util.Locale.ROOT); try { @@ -1647,7 +1697,7 @@ AnalysisMode resolveAnalysisMode() { return twoPassEnabled ? AnalysisMode.TWO_PASS : AnalysisMode.SINGLE_PASS; } - enum AnalysisMode { + public enum AnalysisMode { SINGLE_PASS, TWO_PASS, HYBRID_EXACT diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java index 6aa9bb79..9ebbc379 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java @@ -8,8 +8,11 @@ import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; import java.util.List; @Component @@ -20,6 +23,15 @@ public class AnalysisAiEvaluationAnalysisGenerator implements EvaluationAnalysis private final AnalysisAiClient analysisAiClient; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + @Value("${evaluation.analysis.case-timeout.single-pass-seconds:70}") + private long singlePassCaseTimeoutSeconds; + + @Value("${evaluation.analysis.case-timeout.two-pass-seconds:130}") + private long twoPassCaseTimeoutSeconds; + + @Value("${evaluation.analysis.case-timeout.hybrid-exact-seconds:190}") + private long hybridExactCaseTimeoutSeconds; + @Override public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { AnalysisPromptInput promptInput = new AnalysisPromptInput( @@ -35,12 +47,14 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { command.answer() )) ); + Instant deadline = Instant.now().plus(resolveCaseBudget()); AnalysisAiCallResult aiCallResult = analysisAiClient.analyzeForEvaluationResult( promptInput, jobCategoryEvaluationCriteriaProvider .findByMiddleName(command.jobCategoryMiddle()) - .orElse(null) + .orElse(null), + deadline ); return new EvaluationGeneratedResult( aiCallResult.response(), @@ -51,4 +65,13 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { aiCallResult.finalCallLatencyMs() ); } + + private Duration resolveCaseBudget() { + long seconds = switch (analysisAiClient.resolveAnalysisMode()) { + case SINGLE_PASS -> singlePassCaseTimeoutSeconds; + case TWO_PASS -> twoPassCaseTimeoutSeconds; + case HYBRID_EXACT -> hybridExactCaseTimeoutSeconds; + }; + return Duration.ofSeconds(Math.max(1L, seconds)); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java index c99d86f4..0c3e030e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java @@ -4,11 +4,15 @@ import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -19,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -96,7 +101,49 @@ void runWritesSanitizedEvaluationResults() throws Exception { assertThat(row.get("aiMissingKeywordsJson")).doesNotContain("잘못된 출처"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("답변에 없는 문장"); assertThat(row.get("aiQuestionAnalysesJson")).doesNotContain("missing은 저장하지 않습니다."); - verify(generator).generate(any()); + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(EvaluationAnalysisCommand.class); + verify(generator).generate(commandCaptor.capture()); + assertThat(commandCaptor.getValue().caseId()).isEqualTo("EV-01"); + assertThat(commandCaptor.getValue().jobCategoryMiddle()).isEqualTo("AI·개발·데이터"); + assertThat(commandCaptor.getValue().jobCategorySmall()).isEqualTo("백엔드"); + assertThat(commandCaptor.getValue().question()).isEqualTo("경험을 쓰세요"); + assertThat(commandCaptor.getValue().answer()).isEqualTo("데이터 처리 경험이 있습니다."); + } + + @Test + @DisplayName("CSV 입력 순서대로 evaluation command를 generator에 전달한다") + void runPassesEvaluationCommandsInCsvOrder() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + when(generator.generate(any())).thenReturn(result(new AnalysisLlmResponse(70, 70, 70, "피드백", List.of(), List.of()))); + + Path input = tempDir.resolve("evaluation_cases_order.csv"); + Path output = tempDir.resolve("evaluation_cases_order_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-01,AI·개발·데이터,백엔드,API 개발,Spring,,첫 질문,첫 답변\n" + + "EV-02,디자인,프로덕트 디자이너,UX 설계,Figma,커뮤니케이션,둘째 질문,둘째 답변\n", + StandardCharsets.UTF_8 + ); + + service.run(input, output); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(EvaluationAnalysisCommand.class); + verify(generator, org.mockito.Mockito.times(2)).generate(commandCaptor.capture()); + assertThat(commandCaptor.getAllValues()) + .extracting( + EvaluationAnalysisCommand::caseId, + EvaluationAnalysisCommand::jobCategoryMiddle, + EvaluationAnalysisCommand::jobCategorySmall, + EvaluationAnalysisCommand::question, + EvaluationAnalysisCommand::answer + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("EV-01", "AI·개발·데이터", "백엔드", "첫 질문", "첫 답변"), + org.assertj.core.groups.Tuple.tuple("EV-02", "디자인", "프로덕트 디자이너", "둘째 질문", "둘째 답변") + ); } @Test @@ -356,6 +403,46 @@ void runWritesFailureRowWhenAnalyzeFails() throws Exception { assertThat(row.get("errorMessage")).contains("rate limit exceeded"); } + @Test + @DisplayName("한 사례가 timeout 나도 다음 사례와 CSV 출력은 계속된다") + void runContinuesAfterCaseDeadlineTimeout() throws Exception { + EvaluationAnalysisGenerator generator = mock(EvaluationAnalysisGenerator.class); + ObjectMapper objectMapper = new ObjectMapper(); + EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); + doAnswer(invocation -> { + EvaluationAnalysisCommand command = invocation.getArgument(0); + if ("EV-TIMEOUT".equals(command.caseId())) { + throw new GeneralException(GeneralErrorCode.EXTERNAL_SERVICE_TIMEOUT, "평가 사례 처리 시간이 제한을 초과했습니다."); + } + return result(new AnalysisLlmResponse(90, 80, 70, "정상", List.of(), List.of())); + }).when(generator).generate(any()); + + Path input = tempDir.resolve("evaluation_timeout_cases.csv"); + Path output = tempDir.resolve("evaluation_timeout_results.csv"); + Files.writeString( + input, + "caseId,jobCategoryMiddle,jobCategorySmall,mainTasks,qualifications,preferences,question,answer\n" + + "EV-TIMEOUT,AI·개발·데이터,백엔드,API 개발,Spring,,첫 질문,첫 답변\n" + + "EV-OK,AI·개발·데이터,백엔드,API 개발,Spring,,둘째 질문,둘째 답변\n", + StandardCharsets.UTF_8 + ); + + EvaluationAnalysisBatchService.EvaluationBatchSummary summary = service.run(input, output); + + assertThat(summary.totalCount()).isEqualTo(2); + assertThat(summary.successCount()).isEqualTo(1); + assertThat(summary.failureCount()).isEqualTo(1); + List> rows = EvaluationCsvSupport.read(output); + assertThat(rows).hasSize(2); + assertThat(rows.get(0).get("caseId")).isEqualTo("EV-TIMEOUT"); + assertThat(rows.get(0).get("errorMessage")).contains("제한을 초과"); + assertThat(rows.get(1).get("caseId")).isEqualTo("EV-OK"); + assertThat(rows.get(1).get("errorMessage")).isEmpty(); + assertThat(rows.get(1).get("aiJobFit")).isEqualTo("90"); + assertThat(rows.get(1).get("aiImpact")).isEqualTo("80"); + assertThat(rows.get(1).get("aiCompleteness")).isEqualTo("70"); + } + @Test @DisplayName("평가 CSV 후보/decision 통계는 검증 후 결과 기준으로 기록한다") void runWritesValidatedCandidateDecisionCounts() throws Exception { From 79cc4c15bdee0ed9d20f39682716e32cc1e221f3 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 14:39:22 +0900 Subject: [PATCH 52/55] =?UTF-8?q?[Refactor]=20evaluation=20analysis=20poli?= =?UTF-8?q?cy=20=EC=83=81=EC=88=98=20=EB=B6=84=EB=A6=AC=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EvaluationAnalysisBatchService.java | 104 +++++++++--------- ...aluationCandidateReviewSnapshotParser.java | 49 +++++++++ .../EvaluationCandidateSnapshotParser.java | 71 ++++++++++++ .../mapper/EvaluationLlmSnapshotParser.java | 6 +- .../EvaluationCandidateReviewDecision.java | 7 ++ .../EvaluationCandidateReviewSnapshot.java | 8 ++ .../model/EvaluationCandidateSnapshot.java | 2 + .../model/EvaluationGeneratedResult.java | 15 ++- .../analysis/model/EvaluationLlmSnapshot.java | 4 + .../EvaluationAnalysisPolicyConstants.java | 16 +++ ...AnalysisAiEvaluationAnalysisGenerator.java | 38 ++++++- .../EvaluationAnalysisBatchServiceTest.java | 46 +++++++- ...tionCandidateReviewSnapshotParserTest.java | 33 ++++++ ...EvaluationCandidateSnapshotParserTest.java | 46 ++++++++ .../EvaluationLlmSnapshotParserTest.java | 2 + 15 files changed, 376 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java rename src/main/java/com/jobdri/jobdri_api/domain/evaluation/{analysis/adapter => infrastructure/analysis}/AnalysisAiEvaluationAnalysisGenerator.java (59%) create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java index f66bbb16..5c77371b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchService.java @@ -2,14 +2,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; import com.jobdri.jobdri_api.domain.analysis.type.QuestionAnalysisStatus; -import com.jobdri.jobdri_api.domain.analysis.service.core.AnalysisResultConstants; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; +import com.jobdri.jobdri_api.domain.evaluation.analysis.policy.EvaluationAnalysisPolicyConstants; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.extern.slf4j.Slf4j; @@ -26,7 +27,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; @Service @@ -136,17 +136,17 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa evaluationCase.question(), evaluationCase.answer() )); - AnalysisLlmResponse llmResponse = generatedResult.response(); + EvaluationLlmSnapshot llmResponse = generatedResult.responseSnapshot(); int jobFit = validateScore("jobFit", llmResponse == null ? null : llmResponse.jobFit()); int impact = validateScore("impact", llmResponse == null ? null : llmResponse.impact()); int completeness = validateScore("completeness", llmResponse == null ? null : llmResponse.completeness()); - List missingKeywords = buildMissingKeywords(evaluationCase, llmResponse); + List missingKeywords = buildMissingKeywords(evaluationCase, llmResponse); List questionAnalyses = buildQuestionAnalyses(evaluationCase, llmResponse); log.debug( "Evaluation serialized missing keyword flow. caseId={}, candidateMissingKeywordCount={}, finalMissingKeywordCount={}, evaluationSerializedMissingKeywordCount={}", evaluationCase.caseId(), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), size(llmResponse == null ? null : llmResponse.missingKeywords()), missingKeywords.size() ); @@ -167,21 +167,21 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa normalizeFeedback(llmResponse.feedback()), writeJson(missingKeywords), writeJson(questionAnalyses), - writeJson(llmResponse), - writeJson(generatedResult.rawCandidateResponse()), - writeJson(generatedResult.sanitizedCandidateResponse()), - writeJson(generatedResult.candidateReviewResponse()), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().analysisCandidates()), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().analysisCandidates()), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().strengthCandidates()), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), - acceptedDecisionCount(generatedResult.candidateReviewResponse()), - rejectedDecisionCount(generatedResult.candidateReviewResponse()), - rejectionCodeCounts(generatedResult.candidateReviewResponse()), + generatedResult.rawLlmResponseJson(), + generatedResult.rawCandidateResponseJson(), + generatedResult.sanitizedCandidateResponseJson(), + generatedResult.candidateReviewResponseJson(), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().analysisCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().analysisCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().strengthCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), + acceptedDecisionCount(generatedResult.candidateReviewSnapshot()), + rejectedDecisionCount(generatedResult.candidateReviewSnapshot()), + rejectionCodeCounts(generatedResult.candidateReviewSnapshot()), questionAnalyses.size(), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().strengthCandidates()), - llmResponse.keyStrengths() == null ? 0 : llmResponse.keyStrengths().size(), - size(generatedResult.sanitizedCandidateResponse() == null ? null : generatedResult.sanitizedCandidateResponse().missingKeywordCandidates()), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().strengthCandidates()), + llmResponse.keyStrengthQuotes() == null ? 0 : llmResponse.keyStrengthQuotes().size(), + size(generatedResult.sanitizedCandidateSnapshot() == null ? null : generatedResult.sanitizedCandidateSnapshot().missingKeywordCandidates()), missingKeywords.size(), generatedResult.candidateCallLatencyMs(), generatedResult.finalCallLatencyMs(), @@ -199,34 +199,30 @@ private EvaluationAnalysisResult analyzeCase(EvaluationAnalysisCase evaluationCa ); } - private List buildMissingKeywords( + private List buildMissingKeywords( EvaluationAnalysisCase evaluationCase, - AnalysisLlmResponse llmResponse + EvaluationLlmSnapshot llmResponse ) { if (llmResponse == null || llmResponse.missingKeywords() == null) { return List.of(); } - List result = new ArrayList<>(); + List result = new ArrayList<>(); Set seenKeywords = new HashSet<>(); - for (AnalysisLlmResponse.MissingKeywordItem item : llmResponse.missingKeywords()) { + for (EvaluationMissingKeyword item : llmResponse.missingKeywords()) { if (item == null || !StringUtils.hasText(item.keyword())) { continue; } String keyword = item.keyword().trim(); - if (keyword.length() > AnalysisResultConstants.MAX_MISSING_KEYWORD_LENGTH) { + if (keyword.length() > EvaluationAnalysisPolicyConstants.MAX_MISSING_KEYWORD_LENGTH) { continue; } - Optional source = MissingKeywordSource.from(item.source()); - if (source.isEmpty()) { - continue; - } if (!evaluationSanitizationService.isValidMissingKeyword( keyword, - source.get(), + item.source(), evaluationCase.mainTasks(), evaluationCase.qualifications() )) { @@ -244,8 +240,8 @@ private List buildMissingKeywords( continue; } - result.add(new MissingKeywordResponse(keyword, source.get())); - if (result.size() >= AnalysisResultConstants.MAX_MISSING_KEYWORDS) { + result.add(new EvaluationMissingKeyword(keyword, item.source())); + if (result.size() >= EvaluationAnalysisPolicyConstants.MAX_MISSING_KEYWORDS) { break; } } @@ -254,7 +250,7 @@ private List buildMissingKeywords( private List buildQuestionAnalyses( EvaluationAnalysisCase evaluationCase, - AnalysisLlmResponse llmResponse + EvaluationLlmSnapshot llmResponse ) { if (llmResponse == null || llmResponse.questionAnalyses() == null) { return List.of(); @@ -272,7 +268,7 @@ private List buildQuestionAnalyses( Set fabricatedQuestionIds = new HashSet<>(); Set keyStrengthQuotes = normalizedKeyStrengthQuotes(llmResponse); - for (AnalysisLlmResponse.QuestionAnalysisItem item : llmResponse.questionAnalyses()) { + for (EvaluationQuestionAnalysis item : llmResponse.questionAnalyses()) { if (item == null || item.questionId() == null || !StringUtils.hasText(item.sentence())) { continue; } @@ -297,7 +293,7 @@ private List buildQuestionAnalyses( } int currentCount = analysisCountByQuestionId.getOrDefault(item.questionId(), 0); - if (currentCount >= AnalysisResultConstants.MAX_ANALYSES_PER_QUESTION) { + if (currentCount >= EvaluationAnalysisPolicyConstants.MAX_ANALYSES_PER_QUESTION) { continue; } @@ -339,20 +335,20 @@ private List buildQuestionAnalyses( return result; } - private Set normalizedKeyStrengthQuotes(AnalysisLlmResponse llmResponse) { - if (llmResponse == null || llmResponse.keyStrengths() == null) { + private Set normalizedKeyStrengthQuotes(EvaluationLlmSnapshot llmResponse) { + if (llmResponse == null || llmResponse.keyStrengthQuotes() == null) { return Set.of(); } - return llmResponse.keyStrengths().stream() - .filter(item -> item != null && StringUtils.hasText(item.quote())) - .map(item -> normalizeKeyword(item.quote())) + return llmResponse.keyStrengthQuotes().stream() + .filter(StringUtils::hasText) + .map(this::normalizeKeyword) .collect(java.util.stream.Collectors.toSet()); } private int validateScore(String fieldName, Integer score) { if (score == null - || score < AnalysisResultConstants.MIN_SCORE - || score > AnalysisResultConstants.MAX_SCORE) { + || score < EvaluationAnalysisPolicyConstants.MIN_SCORE + || score > EvaluationAnalysisPolicyConstants.MAX_SCORE) { throw new IllegalArgumentException("자소서 분석 AI 응답의 " + fieldName + " 점수 범위가 올바르지 않습니다."); } return score; @@ -360,9 +356,9 @@ private int validateScore(String fieldName, Integer score) { private int calculateScore(int jobFit, int impact, int completeness) { return (int) Math.round( - jobFit * AnalysisResultConstants.JOB_FIT_WEIGHT - + impact * AnalysisResultConstants.IMPACT_WEIGHT - + completeness * AnalysisResultConstants.COMPLETENESS_WEIGHT + jobFit * EvaluationAnalysisPolicyConstants.JOB_FIT_WEIGHT + + impact * EvaluationAnalysisPolicyConstants.IMPACT_WEIGHT + + completeness * EvaluationAnalysisPolicyConstants.COMPLETENESS_WEIGHT ); } @@ -399,7 +395,7 @@ private Integer size(List values) { return values == null ? null : values.size(); } - private Integer acceptedDecisionCount(CandidateReviewResponse reviewResponse) { + private Integer acceptedDecisionCount(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return null; } @@ -408,7 +404,7 @@ private Integer acceptedDecisionCount(CandidateReviewResponse reviewResponse) { .count(); } - private Integer rejectedDecisionCount(CandidateReviewResponse reviewResponse) { + private Integer rejectedDecisionCount(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return null; } @@ -417,16 +413,16 @@ private Integer rejectedDecisionCount(CandidateReviewResponse reviewResponse) { .count(); } - private String rejectionCodeCounts(CandidateReviewResponse reviewResponse) { + private String rejectionCodeCounts(EvaluationCandidateReviewSnapshot reviewResponse) { if (reviewResponse == null || reviewResponse.decisions() == null) { return ""; } Map counts = reviewResponse.decisions().stream() .filter(decision -> decision != null - && decision.rejectionCode() != null - && decision.rejectionCode() != CandidateReviewResponse.RejectionCode.NONE) + && StringUtils.hasText(decision.rejectionCode()) + && !"NONE".equals(decision.rejectionCode())) .collect(java.util.stream.Collectors.groupingBy( - decision -> decision.rejectionCode().name(), + EvaluationCandidateReviewDecision::rejectionCode, java.util.stream.Collectors.counting() )); return writeJson(counts); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java new file mode 100644 index 00000000..ba937714 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParser.java @@ -0,0 +1,49 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationCandidateReviewSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationCandidateReviewSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationCandidateReviewSnapshot parse(String json) { + if (!StringUtils.hasText(json)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(json); + return new EvaluationCandidateReviewSnapshot(readDecisions(root.path("decisions"))); + } catch (JsonProcessingException e) { + return emptySnapshot(); + } + } + + private EvaluationCandidateReviewSnapshot emptySnapshot() { + return new EvaluationCandidateReviewSnapshot(List.of()); + } + + private List readDecisions(JsonNode decisionsNode) { + if (!decisionsNode.isArray()) { + return List.of(); + } + List decisions = new ArrayList<>(); + for (JsonNode item : decisionsNode) { + decisions.add(new EvaluationCandidateReviewDecision( + item.path("accepted").isBoolean() ? item.path("accepted").booleanValue() : null, + item.path("rejectionCode").asText(null) + )); + } + return decisions; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java new file mode 100644 index 00000000..fc5309f7 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java @@ -0,0 +1,71 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class EvaluationCandidateSnapshotParser { + private final ObjectMapper objectMapper; + + public EvaluationCandidateSnapshotParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public EvaluationCandidateSnapshot parse(String json, String fieldName, String caseId) { + if (!StringUtils.hasText(json)) { + return emptySnapshot(); + } + try { + JsonNode root = objectMapper.readTree(json); + return new EvaluationCandidateSnapshot( + readOpaqueItems(root.path("strengthCandidates")), + readOpaqueItems(root.path("analysisCandidates")), + readMissingKeywordCandidates(root.path("missingKeywordCandidates")) + ); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); + } + } + + private EvaluationCandidateSnapshot emptySnapshot() { + return new EvaluationCandidateSnapshot(List.of(), List.of(), List.of()); + } + + private List readOpaqueItems(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List items = new ArrayList<>(); + node.forEach(item -> items.add(item)); + return items; + } + + private List readMissingKeywordCandidates(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List candidates = new ArrayList<>(); + for (JsonNode item : node) { + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + String relatedRequirement = item.path("relatedRequirement").asText(null); + if (!StringUtils.hasText(keyword)) { + continue; + } + EvaluationMissingKeywordSource.from(source) + .ifPresent(value -> candidates.add(new EvaluationMissingKeywordCandidate( + keyword, + value, + relatedRequirement + ))); + } + return candidates; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java index 3850d80a..76694c36 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParser.java @@ -26,6 +26,10 @@ public EvaluationLlmSnapshot parseRawLlmResponse(String rawLlmResponseJson) { try { JsonNode root = objectMapper.readTree(rawLlmResponseJson); return new EvaluationLlmSnapshot( + root.path("jobFit").isNumber() ? root.path("jobFit").intValue() : null, + root.path("impact").isNumber() ? root.path("impact").intValue() : null, + root.path("completeness").isNumber() ? root.path("completeness").intValue() : null, + root.path("feedback").asText(""), readKeyStrengthQuotes(root.path("keyStrengths")), readMissingKeywords(root.path("missingKeywords")), readQuestionAnalyses(root.path("questionAnalyses")) @@ -51,7 +55,7 @@ public List parseMissingKeywords(String json, String c } private EvaluationLlmSnapshot emptySnapshot() { - return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); + return new EvaluationLlmSnapshot(null, null, null, "", List.of(), List.of(), List.of()); } private List readKeyStrengthQuotes(JsonNode keyStrengthsNode) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java new file mode 100644 index 00000000..ba6b6572 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewDecision.java @@ -0,0 +1,7 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationCandidateReviewDecision( + Boolean accepted, + String rejectionCode +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java new file mode 100644 index 00000000..359e8851 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateReviewSnapshot.java @@ -0,0 +1,8 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationCandidateReviewSnapshot( + List decisions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java index cd03d0ae..727dd5d5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java @@ -3,6 +3,8 @@ import java.util.List; public record EvaluationCandidateSnapshot( + List strengthCandidates, + List analysisCandidates, List missingKeywordCandidates ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java index 314183f1..4e761130 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationGeneratedResult.java @@ -1,14 +1,13 @@ package com.jobdri.jobdri_api.domain.evaluation.analysis.model; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; - public record EvaluationGeneratedResult( - AnalysisLlmResponse response, - AnalysisCandidateResponse rawCandidateResponse, - AnalysisCandidateResponse sanitizedCandidateResponse, - CandidateReviewResponse candidateReviewResponse, + EvaluationLlmSnapshot responseSnapshot, + String rawLlmResponseJson, + String rawCandidateResponseJson, + String sanitizedCandidateResponseJson, + EvaluationCandidateSnapshot sanitizedCandidateSnapshot, + String candidateReviewResponseJson, + EvaluationCandidateReviewSnapshot candidateReviewSnapshot, long candidateCallLatencyMs, long finalCallLatencyMs ) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java index 4e01a388..e082b2ae 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationLlmSnapshot.java @@ -3,6 +3,10 @@ import java.util.List; public record EvaluationLlmSnapshot( + Integer jobFit, + Integer impact, + Integer completeness, + String feedback, List keyStrengthQuotes, List missingKeywords, List questionAnalyses diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java new file mode 100644 index 00000000..b61041d8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/policy/EvaluationAnalysisPolicyConstants.java @@ -0,0 +1,16 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.policy; + +public final class EvaluationAnalysisPolicyConstants { + + public static final int MIN_SCORE = 0; + public static final int MAX_SCORE = 100; + public static final int MAX_ANALYSES_PER_QUESTION = 3; + public static final int MAX_MISSING_KEYWORDS = 3; + public static final int MAX_MISSING_KEYWORD_LENGTH = 60; + public static final double JOB_FIT_WEIGHT = 0.50; + public static final double IMPACT_WEIGHT = 0.30; + public static final double COMPLETENESS_WEIGHT = 0.20; + + private EvaluationAnalysisPolicyConstants() { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java similarity index 59% rename from src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java rename to src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java index 9ebbc379..364a99c2 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/adapter/AnalysisAiEvaluationAnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java @@ -1,9 +1,14 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.adapter; +package com.jobdri.jobdri_api.domain.evaluation.infrastructure.analysis; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisAiClient.AnalysisAiCallResult; import com.jobdri.jobdri_api.domain.analysis.service.ai.AnalysisPromptInput; import com.jobdri.jobdri_api.domain.analysis.service.ai.JobCategoryEvaluationCriteriaProvider; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateReviewSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; @@ -22,6 +27,7 @@ public class AnalysisAiEvaluationAnalysisGenerator implements EvaluationAnalysis private final AnalysisAiClient analysisAiClient; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; + private final ObjectMapper objectMapper; @Value("${evaluation.analysis.case-timeout.single-pass-seconds:70}") private long singlePassCaseTimeoutSeconds; @@ -56,11 +62,25 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { .orElse(null), deadline ); + String rawLlmResponseJson = writeJson(aiCallResult.response()); + String rawCandidateResponseJson = writeJson(aiCallResult.rawCandidateResponse()); + String sanitizedCandidateResponseJson = writeJson(aiCallResult.sanitizedCandidateResponse()); + String candidateReviewResponseJson = writeJson(aiCallResult.candidateReviewResponse()); + EvaluationLlmSnapshotParser llmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + EvaluationCandidateSnapshotParser candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); + EvaluationCandidateReviewSnapshotParser reviewSnapshotParser = new EvaluationCandidateReviewSnapshotParser(objectMapper); return new EvaluationGeneratedResult( - aiCallResult.response(), - aiCallResult.rawCandidateResponse(), - aiCallResult.sanitizedCandidateResponse(), - aiCallResult.candidateReviewResponse(), + llmSnapshotParser.parseRawLlmResponse(rawLlmResponseJson), + rawLlmResponseJson, + rawCandidateResponseJson, + sanitizedCandidateResponseJson, + candidateSnapshotParser.parse( + sanitizedCandidateResponseJson, + "sanitizedCandidateResponseJson", + command.caseId() + ), + candidateReviewResponseJson, + reviewSnapshotParser.parse(candidateReviewResponseJson), aiCallResult.candidateCallLatencyMs(), aiCallResult.finalCallLatencyMs() ); @@ -74,4 +94,12 @@ private Duration resolveCaseBudget() { }; return Duration.ofSeconds(Math.max(1L, seconds)); } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value == null ? List.of() : value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize evaluation analysis result.", e); + } + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java index 0c3e030e..a7e98601 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisBatchServiceTest.java @@ -4,6 +4,9 @@ import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.CandidateReviewResponse; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateReviewSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; @@ -34,6 +37,8 @@ class EvaluationAnalysisBatchServiceTest { @TempDir Path tempDir; + private final ObjectMapper objectMapper = new ObjectMapper(); + @Test @DisplayName("LLM 응답을 검증해 평가 결과 CSV로 저장한다") void runWritesSanitizedEvaluationResults() throws Exception { @@ -450,7 +455,7 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { ObjectMapper objectMapper = new ObjectMapper(); EvaluationAnalysisBatchService service = new EvaluationAnalysisBatchService(generator, objectMapper); when(generator.generate(any())) - .thenReturn(new EvaluationGeneratedResult( + .thenReturn(result( new AnalysisLlmResponse( 80, 70, @@ -464,7 +469,6 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { ), List.of() ), - null, new AnalysisCandidateResponse( List.of(), List.of( @@ -564,6 +568,42 @@ void runWritesValidatedCandidateDecisionCounts() throws Exception { } private EvaluationGeneratedResult result(AnalysisLlmResponse response) { - return new EvaluationGeneratedResult(response, null, null, null, 0, 1); + try { + return result(response, null, null, 0, 1); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private EvaluationGeneratedResult result( + AnalysisLlmResponse response, + AnalysisCandidateResponse sanitizedCandidateResponse, + CandidateReviewResponse candidateReviewResponse, + long candidateCallLatencyMs, + long finalCallLatencyMs + ) throws Exception { + String rawLlmResponseJson = objectMapper.writeValueAsString(response == null ? List.of() : response); + String rawCandidateResponseJson = objectMapper.writeValueAsString(List.of()); + String sanitizedCandidateResponseJson = objectMapper.writeValueAsString( + sanitizedCandidateResponse == null ? List.of() : sanitizedCandidateResponse + ); + String candidateReviewResponseJson = objectMapper.writeValueAsString( + candidateReviewResponse == null ? List.of() : candidateReviewResponse + ); + return new EvaluationGeneratedResult( + new EvaluationLlmSnapshotParser(objectMapper).parseRawLlmResponse(rawLlmResponseJson), + rawLlmResponseJson, + rawCandidateResponseJson, + sanitizedCandidateResponseJson, + new EvaluationCandidateSnapshotParser(objectMapper).parse( + sanitizedCandidateResponseJson, + "sanitizedCandidateResponseJson", + "TEST" + ), + candidateReviewResponseJson, + new EvaluationCandidateReviewSnapshotParser(objectMapper).parse(candidateReviewResponseJson), + candidateCallLatencyMs, + finalCallLatencyMs + ); } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java new file mode 100644 index 00000000..b160274c --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java @@ -0,0 +1,33 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateReviewSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationCandidateReviewSnapshotParserTest { + + private final EvaluationCandidateReviewSnapshotParser parser = + new EvaluationCandidateReviewSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("candidate review json을 evaluation snapshot으로 파싱한다") + void parsesReviewJson() { + String json = """ + { + "decisions": [ + {"accepted": true, "rejectionCode": "NONE"}, + {"accepted": false, "rejectionCode": "NOT_ACTIONABLE"} + ] + } + """; + + EvaluationCandidateReviewSnapshot snapshot = parser.parse(json); + + assertThat(snapshot.decisions()) + .extracting(decision -> decision.accepted() + ":" + decision.rejectionCode()) + .containsExactly("true:NONE", "false:NOT_ACTIONABLE"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java new file mode 100644 index 00000000..31467a34 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java @@ -0,0 +1,46 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvaluationCandidateSnapshotParserTest { + + private final EvaluationCandidateSnapshotParser parser = new EvaluationCandidateSnapshotParser(new ObjectMapper()); + + @Test + @DisplayName("candidate response json을 evaluation snapshot으로 파싱한다") + void parsesCandidateResponseJson() { + String json = """ + { + "strengthCandidates": [{"quote": "강점"}], + "analysisCandidates": [{"candidateId": "candidate-1"}], + "missingKeywordCandidates": [ + {"keyword": "Spring Boot", "source": "qualification", "relatedRequirement": "Spring Boot 실무 경험"}, + {"keyword": "ignored", "source": "unknown", "relatedRequirement": "N/A"} + ] + } + """; + + EvaluationCandidateSnapshot snapshot = parser.parse(json, "rawCandidateResponseJson", "EV-01"); + + assertThat(snapshot.strengthCandidates()).hasSize(1); + assertThat(snapshot.analysisCandidates()).hasSize(1); + assertThat(snapshot.missingKeywordCandidates()) + .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) + .containsExactly("Spring Boot:QUALIFICATION"); + } + + @Test + @DisplayName("candidate response json이 malformed면 caseId를 포함해 fail-fast 한다") + void rejectsMalformedCandidateResponseJson() { + assertThatThrownBy(() -> parser.parse("{bad-json", "rawCandidateResponseJson", "EV-01")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("EV-01"); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java index a9a7eec1..9fc8141c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java @@ -27,6 +27,8 @@ void parsesRawLlmResponseJson() { EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(rawLlmResponseJson); + assertThat(snapshot.jobFit()).isNull(); + assertThat(snapshot.feedback()).isEmpty(); assertThat(snapshot.keyStrengthQuotes()).containsExactly("강점 문장"); assertThat(snapshot.missingKeywords()) .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) From bcee2416561e3a0184f22d127c737e5ed6e6f5c7 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 14:39:47 +0900 Subject: [PATCH 53/55] =?UTF-8?q?[Refactor]=20missing=20keyword=20replay?= =?UTF-8?q?=20=EB=A1=9C=EC=BB=AC=20=EC=8A=A4=EB=83=85=EC=83=B7=ED=99=94=20?= =?UTF-8?q?(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MissingKeywordSanitizerReplayService.java | 52 +- ...aluationMissingKeywordRejectionReason.java | 15 + ...ionMissingKeywordSanitizationDecision.java | 14 + ...ationMissingKeywordSanitizationResult.java | 9 + .../EvaluationSanitizationService.java | 471 ++++++++++++++++-- 5 files changed, 495 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java index 06d89c84..d65fe32d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java @@ -2,9 +2,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationDecision; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -77,6 +79,7 @@ class MissingKeywordSanitizerReplayService { private final ObjectMapper objectMapper; private final EvaluationSanitizationService evaluationSanitizationService; + private final EvaluationCandidateSnapshotParser candidateSnapshotParser; @Autowired MissingKeywordSanitizerReplayService( @@ -85,6 +88,7 @@ class MissingKeywordSanitizerReplayService { ) { this.objectMapper = objectMapper; this.evaluationSanitizationService = evaluationSanitizationService; + this.candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); } MissingKeywordSanitizerReplayService(ObjectMapper objectMapper) { @@ -114,19 +118,18 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept if (!caseIds.add(caseId)) { throw new IllegalArgumentException("Missing keyword replay input CSV has duplicate caseId: " + caseId); } - AnalysisCandidateResponse rawResponse = readCandidateResponse( + EvaluationCandidateSnapshot rawResponse = readCandidateResponse( value(row, "rawCandidateResponseJson"), "rawCandidateResponseJson", caseId ); - AnalysisCandidateResponse existingSanitized = readCandidateResponse( + EvaluationCandidateSnapshot existingSanitized = readCandidateResponse( value(row, "sanitizedCandidateResponseJson"), "sanitizedCandidateResponseJson", caseId ); - List rawCandidates = - safeMissingKeywordCandidates(rawResponse); - MissingKeywordSanitizationResult replayResult = evaluationSanitizationService.sanitizeMissingKeywordCandidates( + List rawCandidates = safeMissingKeywordCandidates(rawResponse); + EvaluationMissingKeywordSanitizationResult replayResult = evaluationSanitizationService.sanitizeMissingKeywordCandidates( value(row, "mainTasks"), value(row, "qualifications"), value(row, "answer"), @@ -144,7 +147,7 @@ ReplaySummary replay(Path input, Path output, Path reviewOutput) throws IOExcept acceptedTotal += acceptedCount; rejectedTotal += rejectedCount; - for (MissingKeywordSanitizationDecision decision : replayResult.decisions()) { + for (EvaluationMissingKeywordSanitizationDecision decision : replayResult.decisions()) { reasonCounts.merge(decision.rejectionReason().name(), 1L, Long::sum); replayRows.add(toReplayRow(caseId, rawCandidateCount, acceptedCount, rejectedCount, decision)); if (rawCandidateCount > 0) { @@ -192,23 +195,12 @@ private void validateHeaders(List headers) { } } - private AnalysisCandidateResponse readCandidateResponse(String json, String fieldName, String caseId) { - if (!StringUtils.hasText(json)) { - return new AnalysisCandidateResponse(List.of(), List.of(), List.of()); - } - try { - AnalysisCandidateResponse response = objectMapper.readValue(json, AnalysisCandidateResponse.class); - if (response == null) { - return new AnalysisCandidateResponse(List.of(), List.of(), List.of()); - } - return response; - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); - } + private EvaluationCandidateSnapshot readCandidateResponse(String json, String fieldName, String caseId) { + return candidateSnapshotParser.parse(json, fieldName, caseId); } - private List safeMissingKeywordCandidates( - AnalysisCandidateResponse response + private List safeMissingKeywordCandidates( + EvaluationCandidateSnapshot response ) { if (response == null || response.missingKeywordCandidates() == null) { return List.of(); @@ -218,8 +210,8 @@ private List safeMissingKeywo private void validateExistingSanitized( String caseId, - List replayAccepted, - List existingAccepted + List replayAccepted, + List existingAccepted ) { if (!Objects.equals(replayAccepted, existingAccepted)) { throw new IllegalStateException( @@ -233,9 +225,9 @@ private Map toReplayRow( int rawCandidateCount, int acceptedCandidateCount, int rejectedCandidateCount, - MissingKeywordSanitizationDecision decision + EvaluationMissingKeywordSanitizationDecision decision ) { - AnalysisCandidateResponse.MissingKeywordCandidate candidate = decision.originalCandidate(); + EvaluationMissingKeywordCandidate candidate = decision.originalCandidate(); Map row = new LinkedHashMap<>(); row.put("caseId", caseId); row.put("candidateIndex", String.valueOf(decision.candidateIndex())); @@ -259,9 +251,9 @@ private Map toReplayRow( private Map toReviewRow( String caseId, Map inputRow, - MissingKeywordSanitizationDecision decision + EvaluationMissingKeywordSanitizationDecision decision ) { - AnalysisCandidateResponse.MissingKeywordCandidate candidate = decision.originalCandidate(); + EvaluationMissingKeywordCandidate candidate = decision.originalCandidate(); Map row = new LinkedHashMap<>(); row.put("caseId", caseId); row.put("candidateIndex", String.valueOf(decision.candidateIndex())); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java new file mode 100644 index 00000000..e60c485b --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordRejectionReason.java @@ -0,0 +1,15 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public enum EvaluationMissingKeywordRejectionReason { + ACCEPTED, + NULL_CANDIDATE, + BLANK_KEYWORD, + MAX_ACCEPTED_LIMIT, + INVALID_FORMAT, + UNSUPPORTED_KEYWORD, + CERTIFICATE_OR_QUANTITATIVE_NOISE, + TOO_GENERIC, + NOT_RELATED_TO_JD, + DUPLICATE_KEYWORD, + NORMALIZATION_COLLISION +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java new file mode 100644 index 00000000..1034d6a0 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationDecision.java @@ -0,0 +1,14 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +public record EvaluationMissingKeywordSanitizationDecision( + int candidateIndex, + EvaluationMissingKeywordCandidate originalCandidate, + String normalizedKeyword, + boolean accepted, + EvaluationMissingKeywordRejectionReason rejectionReason, + boolean answerExactMatch, + boolean answerNormalizedMatch, + boolean jdRequirementMatched, + Integer duplicateOfCandidateIndex +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java new file mode 100644 index 00000000..9a659aa9 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationMissingKeywordSanitizationResult.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis.model; + +import java.util.List; + +public record EvaluationMissingKeywordSanitizationResult( + List acceptedCandidates, + List decisions +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java index 19b940f9..aef26057 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java @@ -1,18 +1,79 @@ package com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization; -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.AnalysisSanitizationRules; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizationResult; -import com.jobdri.jobdri_api.domain.analysis.service.sanitization.MissingKeywordSanitizer; -import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordRejectionReason; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationDecision; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class EvaluationSanitizationService { + private static final int MAX_ACCEPTED_COUNT = 3; + private static final double MIN_KEYWORD_TOKEN_MATCH_RATIO = 0.5; + private static final Pattern TOKEN_PATTERN = Pattern.compile("[a-zA-Z0-9+#.]+|[가-힣]+"); + private static final Pattern CAREER_YEAR_PATTERN = Pattern.compile("경력\\s*\\d+\\s*년|\\d+\\s*년\\s*(이상|이하|미만|초과)"); + private static final String[] STRUCTURED_QUALIFICATION_TERMS = { + "자격증", "면허", "면허증", "공인성적", "어학성적", "토익", "toeic", "토플", "toefl", + "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령", + "사회복지사", "청소년지도사", "청소년상담사", "직업상담사", "임상심리사", + "대졸", "초대졸", "전문대졸", "고졸" + }; + private static final String[] META_IMPROVEMENT_TERMS = { + "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", + "더 설득력 있게 작성했습니다", "경험을 구체적으로 작성했습니다", "구체적으로 설명했습니다", + "구체적으로 서술했습니다", "명확히 서술했습니다", "구체적으로 설명합니다", + "구체적으로 서술합니다", "명확히 설명합니다", "명확히 서술합니다", + "추가해 보", "추가하면 좋", "설명해 보", "구체적으로 작성", "구체적으로 설명", + "강조하겠", "추가하겠", "명확히 작성", "작성할 수 있", "설명할 수 있", + "보완하겠", "드러내겠", "제시하겠", "추가할 수 있", "수정할 수 있", + "수정하는 방향", "강조하는 방향" + }; + private static final String[] CONTRADICTORY_PROVEN_REASON_TERMS = { + "근거가 부족", "성과가 부족", "수치가 부족", "구체성이 부족", "보완이 필요", + "드러나지 않음", "확인하기 어려움" + }; + private static final String[] FABRICATED_DIRECT_CONFLICT_TERMS = { + "직접 충돌", "명시적 사실과 충돌", "사실과 충돌", "조건과 충돌", "요건과 충돌", + "서로 충돌", "상충", "하지 않았다고", "수행하지 않았다고", "경험이 없다고", + "없다고 밝혔", "실제로 하지 않았", "모순", "불일치", "일치하지 않", "앞뒤가 맞지", + "다르게 서술" + }; + private static final Set STOP_WORDS = Set.of( + "경험", "역량", "업무", "관련", "가능", "보유", "필수", "우대", "자격", "요건", + "사항", "직무", "수행", "활용", "사용", "기반", "중심", "대한", "통한", "등", + "및", "또는", "위한", "있는", "없는" + ); + private static final List BANNED_IMPROVEMENT_PHRASES = List.of( + "추가하세요", + "보완하세요", + "수정해주세요", + "수정하세요", + "작성해주세요", + "작성하세요", + "필요합니다", + "해야 합니다", + "해주세요", + "명확히 해야", + "명확히 하세요" + ); + private static final List IMPERATIVE_ENDINGS = List.of( + "하세요", + "하십시오", + "해주십시오", + "해 주십시오" + ); public boolean isValidMissingKeyword( String keyword, @@ -20,59 +81,397 @@ public boolean isValidMissingKeyword( String mainTasks, String qualifications ) { - MissingKeywordSource analysisSource = EvaluationMissingKeywordSourceMapper.toAnalysisSource(source); - return analysisSource != null && AnalysisSanitizationRules.isValidMissingKeyword( - keyword, - analysisSource, - mainTasks, - qualifications - ); - } - - public boolean isValidMissingKeyword( - String keyword, - MissingKeywordSource source, - String mainTasks, - String qualifications - ) { - return source != null && AnalysisSanitizationRules.isValidMissingKeyword( - keyword, - source, - mainTasks, - qualifications - ); + if (!StringUtils.hasText(keyword) || source == null) { + return false; + } + if (source == EvaluationMissingKeywordSource.PREFERENCE) { + return false; + } + if (isStructuredQualificationKeyword(keyword)) { + return false; + } + if (source == EvaluationMissingKeywordSource.MAIN_TASK) { + return isGroundedInSource(keyword, mainTasks); + } + if (source == EvaluationMissingKeywordSource.QUALIFICATION) { + return isGroundedInSource(keyword, qualifications); + } + return false; } public boolean isMissingKeywordMentionedInAnswers(String keyword, String answer) { - return AnalysisSanitizationRules.isMissingKeywordMentionedInAnswers(keyword, answer); + if (!StringUtils.hasText(keyword) || !StringUtils.hasText(answer)) { + return false; + } + if (containsNormalized(answer, keyword)) { + return true; + } + + Set keywordTokens = coreTokens(keyword); + Set answerTokens = coreTokens(answer); + if (keywordTokens.isEmpty() || answerTokens.isEmpty()) { + return false; + } + + long matchCount = keywordTokens.stream() + .filter(answerTokens::contains) + .count(); + if (keywordTokens.size() == 1) { + return matchCount == 1; + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; } public boolean hasValidProvenReason(String reason) { - return AnalysisSanitizationRules.hasValidProvenReason(reason); + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : CONTRADICTORY_PROVEN_REASON_TERMS) { + if (normalized.contains(normalize(term))) { + return false; + } + } + return true; } public boolean hasFabricatedDirectConflictEvidence(String sentence, String reason) { - return AnalysisSanitizationRules.hasFabricatedDirectConflictEvidence(sentence, reason); + if (!StringUtils.hasText(reason)) { + return false; + } + if (hasFabricatedDirectConflictReason(reason)) { + return true; + } + return StringUtils.hasText(sentence) + && hasFabricatedDirectConflictReason(sentence + " " + reason); } public String normalizeImprovement(String sentence, String answer, String improvement, boolean proven) { - return AnalysisSanitizationRules.normalizeImprovement(sentence, answer, improvement, proven); + if (proven || !StringUtils.hasText(improvement) || isNullLikeImprovement(improvement)) { + return ""; + } + + String normalized = improvement.trim(); + if (isInstructionLike(normalized) + || equalsNormalized(sentence, normalized) + || isCopiedFromAnotherAnswerSentence(sentence, answer, normalized) + || isMetaImprovement(normalized) + || changesSentenceTense(sentence, normalized)) { + return ""; + } + return normalized; } public boolean isStructuredQualificationKeyword(String value) { - return AnalysisSanitizationRules.isStructuredQualificationKeyword(value); + String normalized = normalize(value); + if (CAREER_YEAR_PATTERN.matcher(value).find()) { + return true; + } + for (String term : STRUCTURED_QUALIFICATION_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; } public String normalizeText(String value) { - return AnalysisSanitizationRules.normalizeText(value); + return normalize(value); + } + + public EvaluationMissingKeywordSanitizationResult sanitizeMissingKeywordCandidates( + String mainTasks, + String qualifications, + String answer, + List candidates + ) { + if (candidates == null) { + return new EvaluationMissingKeywordSanitizationResult(List.of(), List.of()); + } + + List acceptedCandidates = new ArrayList<>(); + List decisions = new ArrayList<>(); + Map seen = new HashMap<>(); + + for (int i = 0; i < candidates.size(); i++) { + EvaluationMissingKeywordCandidate candidate = candidates.get(i); + EvaluationMissingKeywordSanitizationDecision decision = decide( + i, + candidate, + mainTasks, + qualifications, + answer, + seen, + acceptedCandidates.size() + ); + decisions.add(decision); + if (decision.accepted()) { + acceptedCandidates.add(candidate); + seen.put(decision.normalizedKeyword(), new IndexedKeyword(i, candidate.keyword())); + } + } + + return new EvaluationMissingKeywordSanitizationResult(List.copyOf(acceptedCandidates), List.copyOf(decisions)); } - public MissingKeywordSanitizationResult sanitizeMissingKeywordCandidates( + private EvaluationMissingKeywordSanitizationDecision decide( + int index, + EvaluationMissingKeywordCandidate candidate, String mainTasks, String qualifications, String answer, - List candidates + Map seen, + int acceptedCount + ) { + if (candidate == null) { + return rejected(index, null, "", answer, false, null, EvaluationMissingKeywordRejectionReason.NULL_CANDIDATE); + } + String keyword = candidate.keyword(); + String normalizedKeyword = normalize(keyword); + if (!StringUtils.hasText(keyword)) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.BLANK_KEYWORD); + } + if (acceptedCount >= MAX_ACCEPTED_COUNT) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.MAX_ACCEPTED_LIMIT); + } + if (candidate.source() == null) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.INVALID_FORMAT); + } + if (candidate.source() == EvaluationMissingKeywordSource.PREFERENCE) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.UNSUPPORTED_KEYWORD); + } + if (isStructuredQualificationKeyword(keyword)) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.CERTIFICATE_OR_QUANTITATIVE_NOISE); + } + if (coreTokens(keyword).isEmpty()) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.TOO_GENERIC); + } + + boolean grounded = isValidMissingKeyword(keyword, candidate.source(), mainTasks, qualifications); + if (!grounded) { + return rejected(index, candidate, normalizedKeyword, answer, false, null, EvaluationMissingKeywordRejectionReason.NOT_RELATED_TO_JD); + } + + IndexedKeyword duplicate = seen.get(normalizedKeyword); + if (duplicate != null) { + EvaluationMissingKeywordRejectionReason reason = duplicate.keyword().trim().equals(keyword.trim()) + ? EvaluationMissingKeywordRejectionReason.DUPLICATE_KEYWORD + : EvaluationMissingKeywordRejectionReason.NORMALIZATION_COLLISION; + return rejected(index, candidate, normalizedKeyword, answer, grounded, duplicate.index(), reason); + } + + return new EvaluationMissingKeywordSanitizationDecision( + index, + candidate, + normalizedKeyword, + true, + EvaluationMissingKeywordRejectionReason.ACCEPTED, + containsExact(answer, keyword), + containsNormalized(answer, keyword), + true, + null + ); + } + + private EvaluationMissingKeywordSanitizationDecision rejected( + int index, + EvaluationMissingKeywordCandidate candidate, + String normalizedKeyword, + String answer, + boolean jdRequirementMatched, + Integer duplicateOfCandidateIndex, + EvaluationMissingKeywordRejectionReason reason + ) { + String keyword = candidate == null ? "" : candidate.keyword(); + return new EvaluationMissingKeywordSanitizationDecision( + index, + candidate, + normalizedKeyword, + false, + reason, + containsExact(answer, keyword), + containsNormalized(answer, keyword), + jdRequirementMatched, + duplicateOfCandidateIndex + ); + } + + private boolean hasFabricatedDirectConflictReason(String reason) { + if (!StringUtils.hasText(reason)) { + return false; + } + String normalized = normalize(reason); + for (String term : FABRICATED_DIRECT_CONFLICT_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + boolean teamClaim = normalized.contains(normalize("팀 프로젝트")) + || normalized.contains(normalize("팀으로 진행")); + boolean individualClaim = normalized.contains(normalize("개인 프로젝트")) + || normalized.contains(normalize("혼자 수행")) + || normalized.contains(normalize("혼자 진행")); + return teamClaim && individualClaim; + } + + private boolean isInstructionLike(String improvement) { + if (improvement == null) { + return false; + } + String compact = improvement.replaceAll("\\s+", ""); + return BANNED_IMPROVEMENT_PHRASES.stream() + .map(phrase -> phrase.replaceAll("\\s+", "")) + .anyMatch(compact::contains) + || IMPERATIVE_ENDINGS.stream() + .map(ending -> ending.replaceAll("\\s+", "")) + .anyMatch(ending -> compact.endsWith(ending) || compact.endsWith(ending + ".")); + } + + private boolean isGroundedInSource(String keyword, String sourceText) { + Set keywordTokens = coreTokens(keyword); + if (keywordTokens.isEmpty() || !StringUtils.hasText(sourceText)) { + return false; + } + + Set sourceTokens = coreTokens(sourceText); + long matchCount = keywordTokens.stream() + .filter(sourceTokens::contains) + .count(); + if (matchCount < 1) { + return false; + } + if (keywordTokens.size() == 1) { + String token = keywordTokens.iterator().next(); + return token.length() >= 3 && sourceTokens.contains(token); + } + return (double) matchCount / keywordTokens.size() >= MIN_KEYWORD_TOKEN_MATCH_RATIO; + } + + private Set coreTokens(String value) { + Set tokens = new LinkedHashSet<>(); + if (!StringUtils.hasText(value)) { + return tokens; + } + + Matcher matcher = TOKEN_PATTERN.matcher(value.toLowerCase(Locale.ROOT)); + while (matcher.find()) { + String token = stripKoreanSuffix(matcher.group()); + if (isCoreToken(token)) { + tokens.add(token); + } + } + return tokens; + } + + private boolean isCoreToken(String token) { + return StringUtils.hasText(token) + && token.length() >= 2 + && !STOP_WORDS.contains(token); + } + + private String stripKoreanSuffix(String token) { + if (token == null || !token.matches("[가-힣]+")) { + return token == null ? "" : token; + } + String result = token; + String[] suffixes = {"했습니다", "았습니다", "었습니다", "으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", + "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한"}; + boolean changed; + do { + changed = false; + for (String suffix : suffixes) { + if (result.length() > suffix.length() + 1 && result.endsWith(suffix)) { + result = result.substring(0, result.length() - suffix.length()); + changed = true; + break; + } + } + } while (changed); + return result; + } + + private boolean containsExact(String sourceText, String value) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(value) + && sourceText.contains(value); + } + + private boolean containsNormalized(String sourceText, String keyword) { + return StringUtils.hasText(sourceText) + && StringUtils.hasText(keyword) + && normalize(sourceText).contains(normalize(keyword)); + } + + private boolean equalsNormalized(String left, String right) { + return StringUtils.hasText(left) + && StringUtils.hasText(right) + && normalize(left).equals(normalize(right)); + } + + private boolean isCopiedFromAnotherAnswerSentence(String sentence, String answer, String improvement) { + if (!StringUtils.hasText(answer) || !StringUtils.hasText(improvement)) { + return false; + } + return containsNormalized(answer, improvement) && !equalsNormalized(sentence, improvement); + } + + private boolean isMetaImprovement(String improvement) { + String normalized = normalize(improvement); + for (String term : META_IMPROVEMENT_TERMS) { + if (normalized.contains(normalize(term))) { + return true; + } + } + return false; + } + + private boolean isNullLikeImprovement(String improvement) { + String normalized = normalize(improvement); + return "null".equals(normalized) + || "n/a".equals(normalized) + || "na".equals(normalized) + || "없음".equals(normalized); + } + + private boolean changesSentenceTense(String sentence, String improvement) { + return isPastSentence(sentence) && isFutureSentence(improvement) + || isFutureSentence(sentence) && isPastSentence(improvement); + } + + private boolean isPastSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("했습니다") + || normalized.contains("였습니다") + || normalized.contains("수행했습니다") + || normalized.contains("개선했습니다") + || normalized.contains("달성했습니다") + || normalized.contains("근무했습니다") + || normalized.contains("담당했습니다"); + } + + private boolean isFutureSentence(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + String normalized = normalize(value); + return normalized.contains("하겠습니다") + || normalized.contains("되겠습니다") + || normalized.contains("기여하겠습니다") + || normalized.contains("노력하겠습니다") + || normalized.contains("성장하겠습니다") + || normalized.contains("싶습니다"); + } + + private String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + } + + private record IndexedKeyword( + int index, + String keyword ) { - return MissingKeywordSanitizer.sanitize(mainTasks, qualifications, answer, candidates); } } From 335b9143f07ac7b20ee977bc527e5237eba11177 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 14:40:14 +0900 Subject: [PATCH 54/55] =?UTF-8?q?[Refactor]=20evaluation=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=98=EC=A1=B4=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysis/NlgEvaluationBatchService.java | 3 +- .../EvaluationCandidateSnapshotMapper.java | 29 ----------- .../mapper/EvaluationLlmSnapshotMapper.java | 38 -------------- .../EvaluationMissingKeywordMapper.java | 19 ------- .../EvaluationMissingKeywordSourceMapper.java | 37 -------------- .../EvaluationQuestionAnalysisMapper.java | 22 -------- ...aluationAnalysisPackageDependencyTest.java | 50 ++++++++++++++++++ ...EvaluationCandidateSnapshotMapperTest.java | 32 ------------ .../EvaluationLlmSnapshotMapperTest.java | 51 ------------------- 9 files changed, 51 insertions(+), 230 deletions(-) delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java delete mode 100644 src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java create mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java delete mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java delete mode 100644 src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java index d2863984..60205300 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/NlgEvaluationBatchService.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationLlmSnapshotParser; -import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationMissingKeywordSourceMapper; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; @@ -465,7 +464,7 @@ private Optional missingKeywordMissInvalidReaso } if (!evaluationSanitizationService.isValidMissingKeyword( evaluation.keyword(), - EvaluationMissingKeywordSourceMapper.toAnalysisSource(source.get()), + source.get(), input.mainTasks(), input.qualifications() )) { diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java deleted file mode 100644 index d8b7d0df..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapper.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; - -import java.util.List; - -public final class EvaluationCandidateSnapshotMapper { - private EvaluationCandidateSnapshotMapper() { - } - - public static EvaluationCandidateSnapshot from(AnalysisCandidateResponse response) { - if (response == null || response.missingKeywordCandidates() == null) { - return new EvaluationCandidateSnapshot(List.of()); - } - List missingKeywordCandidates = response.missingKeywordCandidates().stream() - .map(candidate -> EvaluationMissingKeywordSourceMapper.fromAnalysisSource(candidate.source()) - .map(source -> new EvaluationMissingKeywordCandidate( - candidate.keyword(), - source, - candidate.relatedRequirement() - )) - .orElse(null)) - .filter(candidate -> candidate != null) - .toList(); - return new EvaluationCandidateSnapshot(missingKeywordCandidates); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java deleted file mode 100644 index 4c111263..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapper.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; - -import java.util.List; -import java.util.Optional; - -public final class EvaluationLlmSnapshotMapper { - private EvaluationLlmSnapshotMapper() { - } - - public static EvaluationLlmSnapshot from(AnalysisLlmResponse response) { - if (response == null) { - return new EvaluationLlmSnapshot(List.of(), List.of(), List.of()); - } - List keyStrengthQuotes = response.keyStrengths() == null - ? List.of() - : response.keyStrengths().stream() - .map(AnalysisLlmResponse.HighlightItem::quote) - .toList(); - List missingKeywords = response.missingKeywords() == null - ? List.of() - : response.missingKeywords().stream() - .map(EvaluationMissingKeywordMapper::from) - .flatMap(Optional::stream) - .toList(); - List questionAnalyses = response.questionAnalyses() == null - ? List.of() - : response.questionAnalyses().stream() - .map(EvaluationQuestionAnalysisMapper::from) - .filter(questionAnalysis -> questionAnalysis != null) - .toList(); - return new EvaluationLlmSnapshot(keyStrengthQuotes, missingKeywords, questionAnalyses); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java deleted file mode 100644 index 2ce25340..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeyword; - -import java.util.Optional; - -public final class EvaluationMissingKeywordMapper { - private EvaluationMissingKeywordMapper() { - } - - public static Optional from(AnalysisLlmResponse.MissingKeywordItem item) { - if (item == null) { - return Optional.empty(); - } - return EvaluationMissingKeywordSourceMapper.fromAnalysisSource(item.source()) - .map(source -> new EvaluationMissingKeyword(item.keyword(), source)); - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java deleted file mode 100644 index 383c5b99..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationMissingKeywordSourceMapper.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.response.MissingKeywordSource; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; - -import java.util.Optional; - -public final class EvaluationMissingKeywordSourceMapper { - private EvaluationMissingKeywordSourceMapper() { - } - - public static Optional fromAnalysisSource(String value) { - return EvaluationMissingKeywordSource.from(value); - } - - public static Optional fromAnalysisSource(MissingKeywordSource source) { - if (source == null) { - return Optional.empty(); - } - return switch (source) { - case MAIN_TASK -> Optional.of(EvaluationMissingKeywordSource.MAIN_TASK); - case QUALIFICATION -> Optional.of(EvaluationMissingKeywordSource.QUALIFICATION); - case PREFERENCE -> Optional.of(EvaluationMissingKeywordSource.PREFERENCE); - }; - } - - public static MissingKeywordSource toAnalysisSource(EvaluationMissingKeywordSource source) { - if (source == null) { - return null; - } - return switch (source) { - case MAIN_TASK -> MissingKeywordSource.MAIN_TASK; - case QUALIFICATION -> MissingKeywordSource.QUALIFICATION; - case PREFERENCE -> MissingKeywordSource.PREFERENCE; - }; - } -} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java deleted file mode 100644 index f1d37775..00000000 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationQuestionAnalysisMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationQuestionAnalysis; - -public final class EvaluationQuestionAnalysisMapper { - private EvaluationQuestionAnalysisMapper() { - } - - public static EvaluationQuestionAnalysis from(AnalysisLlmResponse.QuestionAnalysisItem item) { - if (item == null) { - return null; - } - return new EvaluationQuestionAnalysis( - item.questionId(), - item.sentence(), - item.status(), - item.reason(), - item.improvement() - ); - } -} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java new file mode 100644 index 00000000..85070968 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java @@ -0,0 +1,50 @@ +package com.jobdri.jobdri_api.domain.evaluation.analysis; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvaluationAnalysisPackageDependencyTest { + + private static final Path EVALUATION_ANALYSIS_ROOT = Path.of( + "src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis" + ); + private static final List FORBIDDEN_IMPORT_PREFIXES = List.of( + "import com.jobdri.jobdri_api.domain.analysis.dto.", + "import com.jobdri.jobdri_api.domain.analysis.service." + ); + + @Test + @DisplayName("evaluation analysis 패키지는 runtime analysis dto/service에 직접 의존하지 않는다") + void evaluationAnalysisPackageDoesNotDependOnRuntimeAnalysisDtoOrService() throws IOException { + List violations = new ArrayList<>(); + + try (var paths = Files.walk(EVALUATION_ANALYSIS_ROOT)) { + paths.filter(path -> path.toString().endsWith(".java")) + .forEach(path -> collectViolations(path, violations)); + } + + assertThat(violations).isEmpty(); + } + + private void collectViolations(Path file, List violations) { + try { + List lines = Files.readAllLines(file); + for (String forbiddenPrefix : FORBIDDEN_IMPORT_PREFIXES) { + boolean matched = lines.stream().anyMatch(line -> line.startsWith(forbiddenPrefix)); + if (matched) { + violations.add(file + " -> " + forbiddenPrefix); + } + } + } catch (IOException e) { + throw new IllegalStateException("Failed to inspect file: " + file, e); + } + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java deleted file mode 100644 index 42c0f621..00000000 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotMapperTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisCandidateResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class EvaluationCandidateSnapshotMapperTest { - - @Test - @DisplayName("candidate response를 evaluation snapshot으로 변환한다") - void mapsCandidateResponseToEvaluationSnapshot() { - AnalysisCandidateResponse response = new AnalysisCandidateResponse( - List.of(), - List.of(), - List.of( - new AnalysisCandidateResponse.MissingKeywordCandidate("Spring Boot", "qualification", "Spring Boot 실무 경험"), - new AnalysisCandidateResponse.MissingKeywordCandidate("ignored", "unknown", "N/A") - ) - ); - - EvaluationCandidateSnapshot snapshot = EvaluationCandidateSnapshotMapper.from(response); - - assertThat(snapshot.missingKeywordCandidates()) - .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) - .containsExactly("Spring Boot:QUALIFICATION"); - } -} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java deleted file mode 100644 index f43dcc9b..00000000 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotMapperTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; - -import com.jobdri.jobdri_api.domain.analysis.dto.external.llm.AnalysisLlmResponse; -import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationLlmSnapshot; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class EvaluationLlmSnapshotMapperTest { - - @Test - @DisplayName("runtime llm response를 evaluation snapshot으로 변환한다") - void mapsAnalysisLlmResponseToEvaluationSnapshot() { - AnalysisLlmResponse response = new AnalysisLlmResponse( - 4, - 4, - 4, - "feedback", - List.of( - new AnalysisLlmResponse.HighlightItem("강점", "문장 A") - ), - List.of(), - List.of( - new AnalysisLlmResponse.MissingKeywordItem("Spring Boot", "mainTask"), - new AnalysisLlmResponse.MissingKeywordItem("무시됨", "unknown") - ), - List.of( - new AnalysisLlmResponse.QuestionAnalysisItem( - 1L, - "문장", - "mentioned", - "근거", - "개선" - ) - ) - ); - - EvaluationLlmSnapshot snapshot = EvaluationLlmSnapshotMapper.from(response); - - assertThat(snapshot.keyStrengthQuotes()).containsExactly("문장 A"); - assertThat(snapshot.missingKeywords()) - .extracting(keyword -> keyword.keyword() + ":" + keyword.source().name()) - .containsExactly("Spring Boot:MAIN_TASK"); - assertThat(snapshot.questionAnalyses()) - .extracting(questionAnalysis -> questionAnalysis.sentence() + ":" + questionAnalysis.status()) - .containsExactly("문장:mentioned"); - } -} From 882bdeae48d9504f3ef6a1b4177cf784248aaab0 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Wed, 12 Aug 2026 15:13:01 +0900 Subject: [PATCH 55/55] =?UTF-8?q?[Fix]=20evaluation=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20replay=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B0=95=ED=99=94=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MissingKeywordSanitizerReplayService.java | 75 ++++++++++++++++- .../EvaluationCandidateSnapshotParser.java | 4 +- .../model/EvaluationCandidateSnapshot.java | 6 +- .../EvaluationSanitizationService.java | 82 +++++++++++++------ ...AnalysisAiEvaluationAnalysisGenerator.java | 23 ++++-- ...aluationAnalysisPackageDependencyTest.java | 15 ++-- ...singKeywordSanitizerReplayServiceTest.java | 78 ++++++++++++++++++ ...tionCandidateReviewSnapshotParserTest.java | 31 +++++++ ...EvaluationCandidateSnapshotParserTest.java | 27 ++++++ .../EvaluationLlmSnapshotParserTest.java | 18 ++++ 10 files changed, 315 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java index d65fe32d..a2e9a917 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayService.java @@ -1,12 +1,14 @@ package com.jobdri.jobdri_api.domain.evaluation.analysis; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.evaluation.analysis.mapper.EvaluationCandidateSnapshotParser; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordCandidate; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationDecision; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSanitizationResult; +import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationMissingKeywordSource; import com.jobdri.jobdri_api.domain.evaluation.analysis.sanitization.EvaluationSanitizationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -196,7 +198,9 @@ private void validateHeaders(List headers) { } private EvaluationCandidateSnapshot readCandidateResponse(String json, String fieldName, String caseId) { - return candidateSnapshotParser.parse(json, fieldName, caseId); + EvaluationCandidateSnapshot snapshot = candidateSnapshotParser.parse(json, fieldName, caseId); + validateMissingKeywordCandidates(json, fieldName, caseId); + return snapshot; } private List safeMissingKeywordCandidates( @@ -213,13 +217,80 @@ private void validateExistingSanitized( List replayAccepted, List existingAccepted ) { - if (!Objects.equals(replayAccepted, existingAccepted)) { + if (!acceptedCandidatesMatch(replayAccepted, existingAccepted)) { throw new IllegalStateException( "Missing keyword replay accepted candidates mismatch. caseId=" + caseId ); } } + private void validateMissingKeywordCandidates(String json, String fieldName, String caseId) { + if (!StringUtils.hasText(json)) { + return; + } + try { + JsonNode missingKeywordCandidates = objectMapper.readTree(json).path("missingKeywordCandidates"); + if (!missingKeywordCandidates.isArray()) { + return; + } + for (int i = 0; i < missingKeywordCandidates.size(); i++) { + JsonNode item = missingKeywordCandidates.get(i); + String keyword = item.path("keyword").asText(null); + String source = item.path("source").asText(null); + if (!StringUtils.hasText(keyword)) { + throw new IllegalArgumentException( + fieldName + " has blank missingKeywordCandidates[" + i + "].keyword. caseId=" + caseId + ); + } + if (EvaluationMissingKeywordSource.from(source).isEmpty()) { + throw new IllegalArgumentException( + fieldName + " has invalid missingKeywordCandidates[" + i + "].source. caseId=" + caseId + ); + } + } + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(fieldName + " is not valid candidate JSON. caseId=" + caseId, e); + } + } + + private boolean acceptedCandidatesMatch( + List replayAccepted, + List existingAccepted + ) { + if (replayAccepted == null || existingAccepted == null) { + return Objects.equals(replayAccepted, existingAccepted); + } + if (replayAccepted.size() != existingAccepted.size()) { + return false; + } + for (int i = 0; i < replayAccepted.size(); i++) { + if (!acceptedCandidateMatches(replayAccepted.get(i), existingAccepted.get(i))) { + return false; + } + } + return true; + } + + private boolean acceptedCandidateMatches( + EvaluationMissingKeywordCandidate replayCandidate, + EvaluationMissingKeywordCandidate existingCandidate + ) { + if (replayCandidate == null || existingCandidate == null) { + return Objects.equals(replayCandidate, existingCandidate); + } + return Objects.equals(replayCandidate.keyword(), existingCandidate.keyword()) + && replayCandidate.source() == existingCandidate.source() + && relatedRequirementMatches(replayCandidate.relatedRequirement(), existingCandidate.relatedRequirement()); + } + + private boolean relatedRequirementMatches(String replayRelatedRequirement, String existingRelatedRequirement) { + String normalizedReplay = value(replayRelatedRequirement); + String normalizedExisting = value(existingRelatedRequirement); + return Objects.equals(normalizedReplay, normalizedExisting) + || !StringUtils.hasText(normalizedReplay) + || !StringUtils.hasText(normalizedExisting); + } + private Map toReplayRow( String caseId, int rawCandidateCount, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java index fc5309f7..5d084d6d 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParser.java @@ -38,11 +38,11 @@ private EvaluationCandidateSnapshot emptySnapshot() { return new EvaluationCandidateSnapshot(List.of(), List.of(), List.of()); } - private List readOpaqueItems(JsonNode node) { + private List readOpaqueItems(JsonNode node) { if (!node.isArray()) { return List.of(); } - List items = new ArrayList<>(); + List items = new ArrayList<>(); node.forEach(item -> items.add(item)); return items; } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java index 727dd5d5..24c140c9 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/model/EvaluationCandidateSnapshot.java @@ -1,10 +1,12 @@ package com.jobdri.jobdri_api.domain.evaluation.analysis.model; +import com.fasterxml.jackson.databind.JsonNode; + import java.util.List; public record EvaluationCandidateSnapshot( - List strengthCandidates, - List analysisCandidates, + List strengthCandidates, + List analysisCandidates, List missingKeywordCandidates ) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java index aef26057..23078d5b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis/sanitization/EvaluationSanitizationService.java @@ -9,6 +9,7 @@ import org.springframework.util.StringUtils; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -22,15 +23,17 @@ public class EvaluationSanitizationService { private static final int MAX_ACCEPTED_COUNT = 3; private static final double MIN_KEYWORD_TOKEN_MATCH_RATIO = 0.5; + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); private static final Pattern TOKEN_PATTERN = Pattern.compile("[a-zA-Z0-9+#.]+|[가-힣]+"); + private static final Pattern KOREAN_ONLY_PATTERN = Pattern.compile("[가-힣]+"); private static final Pattern CAREER_YEAR_PATTERN = Pattern.compile("경력\\s*\\d+\\s*년|\\d+\\s*년\\s*(이상|이하|미만|초과)"); - private static final String[] STRUCTURED_QUALIFICATION_TERMS = { + private static final Set STRUCTURED_QUALIFICATION_TERMS = normalizedSet( "자격증", "면허", "면허증", "공인성적", "어학성적", "토익", "toeic", "토플", "toefl", "opic", "ielts", "학위", "전공", "졸업", "학력", "신입", "근무 가능", "국적", "나이", "연령", "사회복지사", "청소년지도사", "청소년상담사", "직업상담사", "임상심리사", "대졸", "초대졸", "전문대졸", "고졸" - }; - private static final String[] META_IMPROVEMENT_TERMS = { + ); + private static final Set META_IMPROVEMENT_TERMS = normalizedSet( "구체적으로 작성했습니다", "명확히 설명했습니다", "성과를 강조했습니다", "수치로 명시했습니다", "더 설득력 있게 작성했습니다", "경험을 구체적으로 작성했습니다", "구체적으로 설명했습니다", "구체적으로 서술했습니다", "명확히 서술했습니다", "구체적으로 설명합니다", @@ -39,23 +42,28 @@ public class EvaluationSanitizationService { "강조하겠", "추가하겠", "명확히 작성", "작성할 수 있", "설명할 수 있", "보완하겠", "드러내겠", "제시하겠", "추가할 수 있", "수정할 수 있", "수정하는 방향", "강조하는 방향" - }; + ); private static final String[] CONTRADICTORY_PROVEN_REASON_TERMS = { "근거가 부족", "성과가 부족", "수치가 부족", "구체성이 부족", "보완이 필요", "드러나지 않음", "확인하기 어려움" }; - private static final String[] FABRICATED_DIRECT_CONFLICT_TERMS = { + private static final Set FABRICATED_DIRECT_CONFLICT_TERMS = normalizedSet( "직접 충돌", "명시적 사실과 충돌", "사실과 충돌", "조건과 충돌", "요건과 충돌", "서로 충돌", "상충", "하지 않았다고", "수행하지 않았다고", "경험이 없다고", "없다고 밝혔", "실제로 하지 않았", "모순", "불일치", "일치하지 않", "앞뒤가 맞지", "다르게 서술" - }; + ); + private static final String TEAM_PROJECT_TERM = normalizeStatic("팀 프로젝트"); + private static final String TEAM_PROGRESS_TERM = normalizeStatic("팀으로 진행"); + private static final String INDIVIDUAL_PROJECT_TERM = normalizeStatic("개인 프로젝트"); + private static final String SOLO_EXECUTION_TERM = normalizeStatic("혼자 수행"); + private static final String SOLO_PROGRESS_TERM = normalizeStatic("혼자 진행"); private static final Set STOP_WORDS = Set.of( "경험", "역량", "업무", "관련", "가능", "보유", "필수", "우대", "자격", "요건", "사항", "직무", "수행", "활용", "사용", "기반", "중심", "대한", "통한", "등", "및", "또는", "위한", "있는", "없는" ); - private static final List BANNED_IMPROVEMENT_PHRASES = List.of( + private static final List BANNED_IMPROVEMENT_PHRASES = compactList( "추가하세요", "보완하세요", "수정해주세요", @@ -68,12 +76,16 @@ public class EvaluationSanitizationService { "명확히 해야", "명확히 하세요" ); - private static final List IMPERATIVE_ENDINGS = List.of( + private static final List IMPERATIVE_ENDINGS = compactList( "하세요", "하십시오", "해주십시오", "해 주십시오" ); + private static final List KOREAN_SUFFIXES = List.of( + "했습니다", "았습니다", "었습니다", "으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", + "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한" + ); public boolean isValidMissingKeyword( String keyword, @@ -163,12 +175,15 @@ public String normalizeImprovement(String sentence, String answer, String improv } public boolean isStructuredQualificationKeyword(String value) { + if (value == null) { + return false; + } String normalized = normalize(value); if (CAREER_YEAR_PATTERN.matcher(value).find()) { return true; } for (String term : STRUCTURED_QUALIFICATION_TERMS) { - if (normalized.contains(normalize(term))) { + if (normalized.contains(term)) { return true; } } @@ -302,15 +317,15 @@ private boolean hasFabricatedDirectConflictReason(String reason) { } String normalized = normalize(reason); for (String term : FABRICATED_DIRECT_CONFLICT_TERMS) { - if (normalized.contains(normalize(term))) { + if (normalized.contains(term)) { return true; } } - boolean teamClaim = normalized.contains(normalize("팀 프로젝트")) - || normalized.contains(normalize("팀으로 진행")); - boolean individualClaim = normalized.contains(normalize("개인 프로젝트")) - || normalized.contains(normalize("혼자 수행")) - || normalized.contains(normalize("혼자 진행")); + boolean teamClaim = normalized.contains(TEAM_PROJECT_TERM) + || normalized.contains(TEAM_PROGRESS_TERM); + boolean individualClaim = normalized.contains(INDIVIDUAL_PROJECT_TERM) + || normalized.contains(SOLO_EXECUTION_TERM) + || normalized.contains(SOLO_PROGRESS_TERM); return teamClaim && individualClaim; } @@ -318,12 +333,9 @@ private boolean isInstructionLike(String improvement) { if (improvement == null) { return false; } - String compact = improvement.replaceAll("\\s+", ""); - return BANNED_IMPROVEMENT_PHRASES.stream() - .map(phrase -> phrase.replaceAll("\\s+", "")) - .anyMatch(compact::contains) + String compact = compactWhitespace(improvement); + return BANNED_IMPROVEMENT_PHRASES.stream().anyMatch(compact::contains) || IMPERATIVE_ENDINGS.stream() - .map(ending -> ending.replaceAll("\\s+", "")) .anyMatch(ending -> compact.endsWith(ending) || compact.endsWith(ending + ".")); } @@ -370,16 +382,14 @@ private boolean isCoreToken(String token) { } private String stripKoreanSuffix(String token) { - if (token == null || !token.matches("[가-힣]+")) { + if (token == null || !KOREAN_ONLY_PATTERN.matcher(token).matches()) { return token == null ? "" : token; } String result = token; - String[] suffixes = {"했습니다", "았습니다", "었습니다", "으로", "에서", "하며", "하고", "하는", "까지", "부터", "에게", "보다", - "은", "는", "이", "가", "을", "를", "와", "과", "의", "에", "로", "한"}; boolean changed; do { changed = false; - for (String suffix : suffixes) { + for (String suffix : KOREAN_SUFFIXES) { if (result.length() > suffix.length() + 1 && result.endsWith(suffix)) { result = result.substring(0, result.length() - suffix.length()); changed = true; @@ -418,7 +428,7 @@ private boolean isCopiedFromAnotherAnswerSentence(String sentence, String answer private boolean isMetaImprovement(String improvement) { String normalized = normalize(improvement); for (String term : META_IMPROVEMENT_TERMS) { - if (normalized.contains(normalize(term))) { + if (normalized.contains(term)) { return true; } } @@ -466,7 +476,27 @@ private boolean isFutureSentence(String value) { } private String normalize(String value) { - return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + return normalizeStatic(value); + } + + private static Set normalizedSet(String... values) { + return Arrays.stream(values) + .map(EvaluationSanitizationService::normalizeStatic) + .collect(LinkedHashSet::new, Set::add, Set::addAll); + } + + private static List compactList(String... values) { + return Arrays.stream(values) + .map(EvaluationSanitizationService::compactWhitespace) + .toList(); + } + + private static String compactWhitespace(String value) { + return value == null ? "" : WHITESPACE_PATTERN.matcher(value).replaceAll(""); + } + + private static String normalizeStatic(String value) { + return compactWhitespace(value).toLowerCase(Locale.ROOT); } private record IndexedKeyword( diff --git a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java index 364a99c2..0bcf9edf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/evaluation/infrastructure/analysis/AnalysisAiEvaluationAnalysisGenerator.java @@ -12,7 +12,6 @@ import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationAnalysisCommand; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationGeneratedResult; import com.jobdri.jobdri_api.domain.evaluation.analysis.port.EvaluationAnalysisGenerator; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -21,13 +20,28 @@ import java.util.List; @Component -@RequiredArgsConstructor public class AnalysisAiEvaluationAnalysisGenerator implements EvaluationAnalysisGenerator { private static final Long EVALUATION_QUESTION_ID = 1L; private final AnalysisAiClient analysisAiClient; private final JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider; private final ObjectMapper objectMapper; + private final EvaluationLlmSnapshotParser llmSnapshotParser; + private final EvaluationCandidateSnapshotParser candidateSnapshotParser; + private final EvaluationCandidateReviewSnapshotParser reviewSnapshotParser; + + public AnalysisAiEvaluationAnalysisGenerator( + AnalysisAiClient analysisAiClient, + JobCategoryEvaluationCriteriaProvider jobCategoryEvaluationCriteriaProvider, + ObjectMapper objectMapper + ) { + this.analysisAiClient = analysisAiClient; + this.jobCategoryEvaluationCriteriaProvider = jobCategoryEvaluationCriteriaProvider; + this.objectMapper = objectMapper; + this.llmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); + this.candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); + this.reviewSnapshotParser = new EvaluationCandidateReviewSnapshotParser(objectMapper); + } @Value("${evaluation.analysis.case-timeout.single-pass-seconds:70}") private long singlePassCaseTimeoutSeconds; @@ -66,9 +80,6 @@ public EvaluationGeneratedResult generate(EvaluationAnalysisCommand command) { String rawCandidateResponseJson = writeJson(aiCallResult.rawCandidateResponse()); String sanitizedCandidateResponseJson = writeJson(aiCallResult.sanitizedCandidateResponse()); String candidateReviewResponseJson = writeJson(aiCallResult.candidateReviewResponse()); - EvaluationLlmSnapshotParser llmSnapshotParser = new EvaluationLlmSnapshotParser(objectMapper); - EvaluationCandidateSnapshotParser candidateSnapshotParser = new EvaluationCandidateSnapshotParser(objectMapper); - EvaluationCandidateReviewSnapshotParser reviewSnapshotParser = new EvaluationCandidateReviewSnapshotParser(objectMapper); return new EvaluationGeneratedResult( llmSnapshotParser.parseRawLlmResponse(rawLlmResponseJson), rawLlmResponseJson, @@ -97,7 +108,7 @@ private Duration resolveCaseBudget() { private String writeJson(Object value) { try { - return objectMapper.writeValueAsString(value == null ? List.of() : value); + return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("Failed to serialize evaluation analysis result.", e); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java index 85070968..af5bb3a7 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/EvaluationAnalysisPackageDependencyTest.java @@ -17,8 +17,8 @@ class EvaluationAnalysisPackageDependencyTest { "src/main/java/com/jobdri/jobdri_api/domain/evaluation/analysis" ); private static final List FORBIDDEN_IMPORT_PREFIXES = List.of( - "import com.jobdri.jobdri_api.domain.analysis.dto.", - "import com.jobdri.jobdri_api.domain.analysis.service." + "com.jobdri.jobdri_api.domain.analysis.dto.", + "com.jobdri.jobdri_api.domain.analysis.service." ); @Test @@ -37,10 +37,13 @@ void evaluationAnalysisPackageDoesNotDependOnRuntimeAnalysisDtoOrService() throw private void collectViolations(Path file, List violations) { try { List lines = Files.readAllLines(file); - for (String forbiddenPrefix : FORBIDDEN_IMPORT_PREFIXES) { - boolean matched = lines.stream().anyMatch(line -> line.startsWith(forbiddenPrefix)); - if (matched) { - violations.add(file + " -> " + forbiddenPrefix); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).stripLeading(); + for (String forbiddenPrefix : FORBIDDEN_IMPORT_PREFIXES) { + if (line.startsWith("import " + forbiddenPrefix) + || line.startsWith("import static " + forbiddenPrefix)) { + violations.add(file + ":" + (i + 1) + " -> " + forbiddenPrefix); + } } } } catch (IOException e) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java index b5862f7b..72fba441 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/MissingKeywordSanitizerReplayServiceTest.java @@ -85,6 +85,84 @@ void replayFailsWhenExistingSanitizedDoesNotMatch() throws Exception { .hasMessageContaining("EV-01"); } + @Test + @DisplayName("blank missing keyword 후보는 parser 단계에서 fail-fast 한다") + void blankMissingKeywordCandidateFailsFast() throws Exception { + Path input = tempDir.resolve("input.csv"); + Map row = row("EV-01", response(List.of()), response(List.of())); + row.put("rawCandidateResponseJson", """ + {"missingKeywordCandidates":[{"keyword":" ","source":"MAIN_TASK","relatedRequirement":"재고 관리"}]} + """); + EvaluationCsvSupport.writeRows(input, headers(), List.of(row)); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("missingKeywordCandidates[0].keyword") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("unknown source 후보는 parser 단계에서 fail-fast 한다") + void unknownSourceCandidateFailsFast() throws Exception { + Path input = tempDir.resolve("input.csv"); + Map row = row("EV-01", response(List.of()), response(List.of())); + row.put("rawCandidateResponseJson", """ + {"missingKeywordCandidates":[{"keyword":"재고 분석 경험","source":"UNKNOWN","relatedRequirement":"재고 분석 경험"}]} + """); + EvaluationCsvSupport.writeRows(input, headers(), List.of(row)); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawCandidateResponseJson") + .hasMessageContaining("missingKeywordCandidates[0].source") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("relatedRequirement가 다르면 기존 sanitized JSON과 mismatch로 fail-fast 한다") + void replayFailsWhenRelatedRequirementDoesNotMatch() throws Exception { + Path input = tempDir.resolve("input.csv"); + AnalysisCandidateResponse raw = response(List.of(candidate("재고 관리 및 분석 경험", "MAIN_TASK"))); + AnalysisCandidateResponse sanitized = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "재고 관리 및 분석 경험", + "MAIN_TASK", + "다른 요구사항" + )) + ); + writeInput(input, List.of(row("EV-01", raw, sanitized))); + + assertThatThrownBy(() -> service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("accepted candidates mismatch") + .hasMessageContaining("EV-01"); + } + + @Test + @DisplayName("legacy sanitized JSON에 relatedRequirement가 비어 있으면 mismatch로 보지 않는다") + void replayAllowsLegacyMissingRelatedRequirement() throws Exception { + Path input = tempDir.resolve("input.csv"); + AnalysisCandidateResponse raw = response(List.of(candidate("재고 관리 및 분석 경험", "MAIN_TASK"))); + AnalysisCandidateResponse sanitized = new AnalysisCandidateResponse( + List.of(), + List.of(), + List.of(new AnalysisCandidateResponse.MissingKeywordCandidate( + "재고 관리 및 분석 경험", + "MAIN_TASK", + null + )) + ); + writeInput(input, List.of(row("EV-01", raw, sanitized))); + + MissingKeywordSanitizerReplayService.ReplaySummary summary = + service.replay(input, tempDir.resolve("replay.csv"), tempDir.resolve("review.csv")); + + assertThat(summary.acceptedCandidateCount()).isEqualTo(1); + } + @Test @DisplayName("malformed rawCandidateResponseJson은 fail-fast 한다") void malformedRawCandidateResponseFailsFast() throws Exception { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java index b160274c..4ddc1c8e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateReviewSnapshotParserTest.java @@ -30,4 +30,35 @@ void parsesReviewJson() { .extracting(decision -> decision.accepted() + ":" + decision.rejectionCode()) .containsExactly("true:NONE", "false:NOT_ACTIONABLE"); } + + @Test + @DisplayName("candidate review json이 비어 있거나 malformed면 empty snapshot을 반환한다") + void returnsEmptySnapshotForEmptyOrMalformedJson() { + assertThat(parser.parse(null).decisions()).isEmpty(); + assertThat(parser.parse("").decisions()).isEmpty(); + assertThat(parser.parse("{bad-json").decisions()).isEmpty(); + } + + @Test + @DisplayName("decisions가 없거나 배열이 아니면 empty snapshot을 반환한다") + void returnsEmptySnapshotWhenDecisionsMissingOrNotArray() { + assertThat(parser.parse("{\"other\":[]}").decisions()).isEmpty(); + assertThat(parser.parse("{\"decisions\":{}}").decisions()).isEmpty(); + } + + @Test + @DisplayName("accepted가 boolean이 아니면 null로 파싱한다") + void parsesNonBooleanAcceptedAsNull() { + EvaluationCandidateReviewSnapshot snapshot = parser.parse(""" + { + "decisions": [ + {"accepted": "true", "rejectionCode": "NONE"} + ] + } + """); + + assertThat(snapshot.decisions()).singleElement() + .extracting(decision -> decision.accepted() + ":" + decision.rejectionCode()) + .isEqualTo("null:NONE"); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java index 31467a34..cf6cbdb4 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationCandidateSnapshotParserTest.java @@ -1,5 +1,6 @@ package com.jobdri.jobdri_api.domain.evaluation.analysis.mapper; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.jobdri.jobdri_api.domain.evaluation.analysis.model.EvaluationCandidateSnapshot; import org.junit.jupiter.api.DisplayName; @@ -29,7 +30,13 @@ void parsesCandidateResponseJson() { EvaluationCandidateSnapshot snapshot = parser.parse(json, "rawCandidateResponseJson", "EV-01"); assertThat(snapshot.strengthCandidates()).hasSize(1); + assertThat(snapshot.strengthCandidates()) + .extracting(node -> ((JsonNode) node).path("quote").asText()) + .containsExactly("강점"); assertThat(snapshot.analysisCandidates()).hasSize(1); + assertThat(snapshot.analysisCandidates()) + .extracting(node -> ((JsonNode) node).path("candidateId").asText()) + .containsExactly("candidate-1"); assertThat(snapshot.missingKeywordCandidates()) .extracting(candidate -> candidate.keyword() + ":" + candidate.source().name()) .containsExactly("Spring Boot:QUALIFICATION"); @@ -43,4 +50,24 @@ void rejectsMalformedCandidateResponseJson() { .hasMessageContaining("rawCandidateResponseJson") .hasMessageContaining("EV-01"); } + + @Test + @DisplayName("candidate 배열 필드가 없거나 배열이 아니면 빈 목록으로 파싱한다") + void returnsEmptyListsWhenCandidateArraysMissingOrNotArray() { + EvaluationCandidateSnapshot missingArrays = parser.parse("{}", "rawCandidateResponseJson", "EV-01"); + EvaluationCandidateSnapshot nonArrayFields = parser.parse(""" + { + "strengthCandidates": {}, + "analysisCandidates": "", + "missingKeywordCandidates": 1 + } + """, "rawCandidateResponseJson", "EV-01"); + + assertThat(missingArrays.strengthCandidates()).isEmpty(); + assertThat(missingArrays.analysisCandidates()).isEmpty(); + assertThat(missingArrays.missingKeywordCandidates()).isEmpty(); + assertThat(nonArrayFields.strengthCandidates()).isEmpty(); + assertThat(nonArrayFields.analysisCandidates()).isEmpty(); + assertThat(nonArrayFields.missingKeywordCandidates()).isEmpty(); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java index 9fc8141c..d0f12d4e 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/evaluation/analysis/mapper/EvaluationLlmSnapshotParserTest.java @@ -28,6 +28,8 @@ void parsesRawLlmResponseJson() { EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(rawLlmResponseJson); assertThat(snapshot.jobFit()).isNull(); + assertThat(snapshot.impact()).isNull(); + assertThat(snapshot.completeness()).isNull(); assertThat(snapshot.feedback()).isEmpty(); assertThat(snapshot.keyStrengthQuotes()).containsExactly("강점 문장"); assertThat(snapshot.missingKeywords()) @@ -38,6 +40,22 @@ void parsesRawLlmResponseJson() { .containsExactly("문장:mentioned"); } + @Test + @DisplayName("숫자 점수 필드를 모두 파싱한다") + void parsesNumericScores() { + EvaluationLlmSnapshot snapshot = parser.parseRawLlmResponse(""" + { + "jobFit": 80, + "impact": 70, + "completeness": 60 + } + """); + + assertThat(snapshot.jobFit()).isEqualTo(80); + assertThat(snapshot.impact()).isEqualTo(70); + assertThat(snapshot.completeness()).isEqualTo(60); + } + @Test @DisplayName("missing keyword json을 evaluation 모델로 파싱한다") void parsesMissingKeywordJson() {