Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=change-me
APP_OAUTH2_REDIRECT_URI=http://localhost:3000/oauth2/redirect
OPENAI_API_KEY=change-me

TOSS_CLIENT_KEY=change-me
TOSS_SECRET_KEY=change-me
TOSS_BASE_URL=https://api.tosspayments.com

JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
Expand Down
5 changes: 5 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ GOOGLE_CLIENT_ID=change-me.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=change-me
APP_OAUTH2_REDIRECT_URI=https://your-frontend-domain/oauth2/redirect
OPENAI_API_KEY=change-me

TOSS_CLIENT_KEY=change-me
TOSS_SECRET_KEY=change-me
TOSS_BASE_URL=https://api.tosspayments.com

JOB_POSTING_CLASSIFICATION_CONFIDENCE_THRESHOLD=0.65
JOB_POSTING_ASYNC_CORE_POOL_SIZE=2
JOB_POSTING_ASYNC_MAX_POOL_SIZE=4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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.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;
Expand All @@ -40,6 +41,7 @@ public class AnalysisService {
private final AnalysisRepository analysisRepository;
private final QuestionAnalysisRepository questionAnalysisRepository;
private final AnalysisAiClient analysisAiClient;
private final CreditService creditService;

@Transactional
public AnalysisResponse analyze(User user, Long mockApplyId) {
Expand All @@ -56,23 +58,31 @@ public AnalysisResponse analyze(User user, Long mockApplyId) {
);
}

AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions);
replaceExistingAnalysis(mockApply);
String referenceId = "mockApplyId=" + mockApply.getId();
creditService.use(user, 1, "자소서 분석 크레딧 차감", referenceId);

Analysis analysis = analysisRepository.save(Analysis.create(
mockApply,
clampScore(llmResponse.score()),
clampScore(llmResponse.jobFit()),
clampScore(llmResponse.impact()),
clampScore(llmResponse.completeness()),
normalizeFeedback(llmResponse.feedback())
));
try {
AnalysisLlmResponse llmResponse = analysisAiClient.analyze(mockApply.getJobPosting(), answeredQuestions);
Comment on lines +61 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Pre-committing the credit deduction here can permanently strand user credits.

creditService.use(...) is committed before the external LLM call, and the refund is only best-effort inside this thread's catch. If the process dies, the request times out, or the node is terminated between those two steps, the user keeps the deducted credit with no analysis result and no refund. For this flow, the charge needs to stay in the outer transaction or be modeled as a reservation/finalize flow with durable reconciliation.

Also applies to: 82-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/analysis/service/AnalysisService.java`
around lines 61 - 65, The code currently calls creditService.use(...) before the
external LLM call (analysisAiClient.analyze), which can permanently consume user
credits if the process dies; change this to a durable reserve/finalize flow:
call a reservation method (e.g., creditService.reserve(user, 1, referenceId) or
create a CreditReservation) before calling
AnalysisService.analyze/analysisAiClient.analyze, then after a successful
llmResponse call finalize/commit the reservation (e.g.,
creditService.finalizeReservation(reservation) or
creditService.useReserved(...)); on any failure or exception cancel the
reservation (e.g., creditService.cancelReservation(reservation)). Also apply the
same reservation/finalize change to the other similar deduction sites mentioned
(lines around the second occurrence of creditService.use). Ensure method names
(reserve, finalizeReservation/cancelReservation) exist or add them to
CreditService to implement durable reconciliation.

replaceExistingAnalysis(mockApply);

Analysis analysis = analysisRepository.save(Analysis.create(
mockApply,
clampScore(llmResponse.score()),
clampScore(llmResponse.jobFit()),
clampScore(llmResponse.impact()),
clampScore(llmResponse.completeness()),
normalizeFeedback(llmResponse.feedback())
));

List<QuestionAnalysis> questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse);
questionAnalysisRepository.saveAll(questionAnalyses);
mockApply.updateStatus(MockApplyStatus.COMPLETED);
List<QuestionAnalysis> questionAnalyses = buildQuestionAnalyses(analysis, answeredQuestions, llmResponse);
questionAnalysisRepository.saveAll(questionAnalyses);
mockApply.updateStatus(MockApplyStatus.COMPLETED);

return getAnalysis(user, mockApplyId);
return getAnalysis(user, mockApplyId);
} catch (RuntimeException e) {
creditService.refund(user, 1, "자소서 분석 실패 환불", referenceId);
throw e;
}
}

public AnalysisResponse getAnalysis(User user, Long mockApplyId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.jobdri.jobdri_api.domain.payment.controller;

import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest;
import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentPrepareRequest;
import com.jobdri.jobdri_api.domain.payment.dto.response.*;
import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType;
import com.jobdri.jobdri_api.domain.payment.service.PaymentService;
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.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/payments")
@Tag(name = "Payment", description = "크레딧 결제 및 거래 내역 API")
public class PaymentController {

private final PaymentService paymentService;

@Operation(summary = "크레딧 가격 플랜 조회", description = "구매 가능한 크레딧 플랜 목록을 조회합니다.")
@GetMapping("/plans")
public ApiResponse<List<CreditPlanResponse>> getPlans() {
return ApiResponse.onSuccess("크레딧 가격 플랜 조회에 성공했습니다.", paymentService.getPlans());
}

@Operation(summary = "토스 결제 준비", description = "선택한 크레딧 플랜 기준으로 결제 주문을 생성합니다.")
@PostMapping("/prepare")
public ApiResponse<PaymentPrepareResponse> prepare(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@Valid @RequestBody PaymentPrepareRequest request
) {
return ApiResponse.onSuccess(
"결제 준비가 완료되었습니다.",
paymentService.prepare(userDetails.getUser(), request)
);
}

@Operation(summary = "토스 결제 승인", description = "토스페이먼츠 결제 성공 후 paymentKey/orderId/amount를 검증하고 크레딧을 충전합니다.")
@PostMapping("/confirm")
public ApiResponse<PaymentConfirmResponse> confirm(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@Valid @RequestBody PaymentConfirmRequest request
) {
return ApiResponse.onSuccess(
"결제가 완료되었습니다.",
paymentService.confirm(userDetails.getUser(), request)
);
}

@Operation(summary = "내 크레딧 잔액 조회", description = "로그인 사용자의 현재 크레딧 잔액을 조회합니다.")
@GetMapping("/credits/me")
public ApiResponse<CreditBalanceResponse> getBalance(
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
return ApiResponse.onSuccess(
"크레딧 잔액 조회에 성공했습니다.",
paymentService.getBalance(userDetails.getUser())
);
}

@Operation(summary = "내 크레딧 거래 내역 조회", description = "충전/사용/환불/쿠폰 거래 내역을 조회합니다. type query parameter로 필터링할 수 있습니다.")
@GetMapping("/credits/me/transactions")
public ApiResponse<List<CreditTransactionResponse>> getTransactions(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam(required = false) CreditTransactionType type
) {
return ApiResponse.onSuccess(
"크레딧 거래 내역 조회에 성공했습니다.",
paymentService.getTransactions(userDetails.getUser(), type)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.jobdri.jobdri_api.domain.payment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record PaymentConfirmRequest(
@NotBlank(message = "paymentKey는 필수입니다.")
String paymentKey,

@NotBlank(message = "orderId는 필수입니다.")
String orderId,

@Positive(message = "결제 금액은 1원 이상이어야 합니다.")
int amount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.jobdri.jobdri_api.domain.payment.dto.request;

import jakarta.validation.constraints.NotBlank;

public record PaymentPrepareRequest(
@NotBlank(message = "플랜 코드는 필수입니다.")
String planCode
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.jobdri.jobdri_api.domain.payment.dto.response;

public record CreditBalanceResponse(
int creditBalance
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.jobdri.jobdri_api.domain.payment.dto.response;

import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan;

public record CreditPlanResponse(
String planCode,
String name,
int creditAmount,
int price,
boolean recommended
) {
public static CreditPlanResponse from(CreditPlan plan) {
return new CreditPlanResponse(
plan.getCode(),
plan.getName(),
plan.getCreditAmount(),
plan.getPrice(),
plan.isRecommended()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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 java.time.LocalDateTime;

public record CreditTransactionResponse(
Long transactionId,
CreditTransactionType type,
int amount,
int balanceAfter,
String description,
String referenceId,
LocalDateTime createdAt
) {
public static CreditTransactionResponse from(CreditTransaction transaction) {
return new CreditTransactionResponse(
transaction.getId(),
transaction.getType(),
transaction.getAmount(),
transaction.getBalanceAfter(),
transaction.getDescription(),
transaction.getReferenceId(),
transaction.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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;

public record PaymentConfirmResponse(
Long paymentId,
String orderId,
String paymentKey,
PaymentStatus status,
int creditAmount,
int amount,
int creditBalance
) {
public static PaymentConfirmResponse of(Payment payment, int creditBalance) {
return new PaymentConfirmResponse(
payment.getId(),
payment.getOrderId(),
payment.getPaymentKey(),
payment.getStatus(),
payment.getCreditAmount(),
payment.getPrice(),
creditBalance
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.jobdri.jobdri_api.domain.payment.dto.response;

import com.jobdri.jobdri_api.domain.payment.entity.Payment;

public record PaymentPrepareResponse(
Long paymentId,
String orderId,
String orderName,
int amount,
int creditAmount,
String clientKey,
String customerEmail
) {
public static PaymentPrepareResponse of(Payment payment, String clientKey) {
return new PaymentPrepareResponse(
payment.getId(),
payment.getOrderId(),
payment.getContent(),
payment.getPrice(),
payment.getCreditAmount(),
clientKey,
payment.getUser().getEmail()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Potential NPE on user access.

payment.getUser().getEmail() will throw a NullPointerException if the user association is null. Verify that the Payment entity guarantees a non-null user, or add defensive null handling.

🛡️ Proposed defensive fix
-                payment.getUser().getEmail()
+                payment.getUser() != null ? payment.getUser().getEmail() : null

Alternatively, if user is always required, ensure the Payment entity enforces this with @NotNull on the association and document this invariant.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
payment.getUser().getEmail()
payment.getUser() != null ? payment.getUser().getEmail() : null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/dto/response/PaymentPrepareResponse.java`
at line 22, The expression payment.getUser().getEmail() in
PaymentPrepareResponse may NPE if Payment.user is null; update the
constructor/factory in PaymentPrepareResponse (where payment is accessed) to
defensively handle a null user by either using a null-safe access (e.g.,
userEmail = payment.getUser() != null ? payment.getUser().getEmail() : null) or
by calling Objects.requireNonNull(payment.getUser(), "Payment.user must not be
null") if the domain invariant requires a non-null user; alternatively, enforce
the invariant at the entity level by adding `@NotNull` to the Payment.user
association and documenting that Payment always contains a user.

);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.jobdri.jobdri_api.domain.payment.dto.toss;

public record TossPaymentConfirmRequest(
String paymentKey,
String orderId,
int amount
) {
Comment on lines +3 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add validation annotations for external API request.

This DTO is sent to the TOSS Payments API but lacks validation. Consider adding Bean Validation annotations to prevent invalid data:

  • @NotBlank on paymentKey and orderId
  • @Positive on amount to reject negative values
🔒 Proposed validation
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Positive;
+
 public record TossPaymentConfirmRequest(
+        `@NotBlank`
         String paymentKey,
+        `@NotBlank`
         String orderId,
+        `@Positive`
         int amount
 ) {
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/payment/dto/toss/TossPaymentConfirmRequest.java`
around lines 3 - 7, TossPaymentConfirmRequest lacks Bean Validation on its
components; annotate the record components paymentKey and orderId with `@NotBlank`
and annotate amount with `@Positive` (importing the appropriate
javax/jakarta.validation annotations consistent with the project) so validation
occurs before sending to the external TOSS API; update TossPaymentConfirmRequest
to declare these annotations on the record components and ensure any DTO
mapping/validation pipeline picks them up.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.jobdri.jobdri_api.domain.payment.dto.toss;

public record TossPaymentConfirmResponse(
String paymentKey,
String orderId,
String orderName,
String status,
Integer totalAmount,
String method
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.jobdri.jobdri_api.domain.payment.entity;

import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.Getter;

import java.util.Arrays;

@Getter
public enum CreditPlan {
ONE_TIME("ONE_TIME", "1회권", 1, 2500, false),
FIVE_TIMES("FIVE_TIMES", "5회권", 5, 11500, true),
TEN_TIMES("TEN_TIMES", "10회권", 10, 19900, false);

private final String code;
private final String name;
private final int creditAmount;
private final int price;
private final boolean recommended;

CreditPlan(String code, String name, int creditAmount, int price, boolean recommended) {
this.code = code;
this.name = name;
this.creditAmount = creditAmount;
this.price = price;
this.recommended = recommended;
}

public static CreditPlan from(String code) {
return Arrays.stream(values())
.filter(plan -> plan.code.equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.INVALID_PARAMETER,
"지원하지 않는 크레딧 플랜입니다. planCode=" + code
));
}
}
Loading
Loading