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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

import java.util.Objects;

public record PaymentPrepareResponse(
Long paymentId,
String orderId,
Expand All @@ -12,6 +14,7 @@ public record PaymentPrepareResponse(
String customerEmail
) {
public static PaymentPrepareResponse of(Payment payment, String clientKey) {
Objects.requireNonNull(payment.getUser(), "Payment.user must not be null");
return new PaymentPrepareResponse(
payment.getId(),
payment.getOrderId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package com.jobdri.jobdri_api.domain.payment.dto.toss;

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

public record TossPaymentConfirmRequest(
@NotBlank
String paymentKey,
@NotBlank
String orderId,
@Positive
int amount
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public record TossPaymentConfirmResponse(
String orderId,
String orderName,
String status,
Integer totalAmount,
int totalAmount,
String method
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ public class Payment {
@Column(nullable = false)
private String content;

@Column(unique = true)
@Column(nullable = false, unique = true)
private String orderId;

@Column(unique = true)
private String paymentKey;

@Column
@Column(nullable = false)
private String planCode;

@Column(nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import com.jobdri.jobdri_api.domain.payment.entity.Payment;
import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus;
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.List;
import java.util.Optional;
Expand All @@ -11,4 +15,8 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
List<Payment> findAllByUserId(Long userId);
List<Payment> findAllByStatus(PaymentStatus status);
Optional<Payment> findByOrderId(String orderId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Payment p where p.orderId = :orderId")
Optional<Payment> findByOrderIdForUpdate(@Param("orderId") String orderId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
Expand All @@ -19,16 +18,18 @@ public class CreditService {
private final UserRepository userRepository;
private final CreditTransactionRepository creditTransactionRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public int charge(User user, int amount, String description, String referenceId) {
validatePositiveAmount(amount);
User managedUser = getManagedUser(user);
managedUser.increaseCredit(amount);
saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId);
return managedUser.getCredit();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public int use(User user, int amount, String description, String referenceId) {
validatePositiveAmount(amount);
User managedUser = getManagedUser(user);
try {
managedUser.decreaseCredit(amount);
Expand All @@ -39,14 +40,21 @@ public int use(User user, int amount, String description, String referenceId) {
return managedUser.getCredit();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public int refund(User user, int amount, String description, String referenceId) {
validatePositiveAmount(amount);
User managedUser = getManagedUser(user);
managedUser.increaseCredit(amount);
saveTransaction(managedUser, CreditTransactionType.REFUND, amount, description, referenceId);
return managedUser.getCredit();
}

private void validatePositiveAmount(int amount) {
if (amount <= 0) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "amount는 1 이상이어야 합니다.");
}
}

private void saveTransaction(
User user,
CreditTransactionType type,
Expand All @@ -68,7 +76,7 @@ private User getManagedUser(User user) {
if (user == null || user.getId() == null) {
throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO, "인증 정보가 누락되었습니다.");
}
return userRepository.findById(user.getId())
return userRepository.findByIdForUpdate(user.getId())
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.USER_NOT_FOUND,
"해당 유저를 찾을 수 없습니다. userId=" + user.getId()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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 jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
Expand All @@ -37,6 +38,13 @@ public class PaymentService {
@Value("${payment.toss.client-key:}")
private String tossClientKey;

@PostConstruct
void validateConfig() {
if (tossClientKey == null || tossClientKey.isBlank()) {
throw new IllegalStateException("payment.toss.client-key must be configured");
}
}

public List<CreditPlanResponse> getPlans() {
return Arrays.stream(CreditPlan.values())
.map(CreditPlanResponse::from)
Expand All @@ -63,7 +71,7 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request)
@Transactional
public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) {
User validatedUser = userService.validateUser(user);
Payment payment = paymentRepository.findByOrderId(request.orderId())
Payment payment = paymentRepository.findByOrderIdForUpdate(request.orderId())
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.PAYMENT_NOT_FOUND,
"결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
Expand Down Expand Up @@ -117,7 +125,6 @@ private void validateTossResponse(PaymentConfirmRequest request, TossPaymentConf
if (response == null
|| !request.orderId().equals(response.orderId())
|| !request.paymentKey().equals(response.paymentKey())
|| response.totalAmount() == null
|| response.totalAmount() != request.amount()) {
throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "결제 승인 응답 검증에 실패했습니다.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,57 @@
import com.jobdri.jobdri_api.domain.payment.dto.toss.TossPaymentConfirmResponse;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;

@Component
@RequiredArgsConstructor
@Slf4j
public class TossPaymentClient {

private static final int LOG_MESSAGE_MAX_LENGTH = 500;

private final RestClient.Builder restClientBuilder;
private RestClient restClient;

@Value("${payment.toss.secret-key:}")
@Value("${payment.toss.secret-key}")
private String secretKey;

@Value("${payment.toss.base-url:https://api.tosspayments.com}")
private String baseUrl;

@PostConstruct
void init() {
if (secretKey == null || secretKey.isBlank()) {
throw new IllegalStateException("payment.toss.secret-key must be configured");
}

SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(5));
requestFactory.setReadTimeout(Duration.ofSeconds(10));

this.restClient = restClientBuilder
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
}

public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int amount) {
try {
return restClientBuilder
.baseUrl(baseUrl)
.build()
return restClient
.post()
.uri("/v1/payments/confirm")
.header(HttpHeaders.AUTHORIZATION, authorizationHeader())
Expand All @@ -40,10 +63,23 @@ public TossPaymentConfirmResponse confirm(String paymentKey, String orderId, int
.body(new TossPaymentConfirmRequest(paymentKey, orderId, amount))
.retrieve()
.body(TossPaymentConfirmResponse.class);
} catch (HttpStatusCodeException e) {
log.warn(
"Toss payment confirm failed. status={}, response={}",
e.getStatusCode(),
truncate(e.getResponseBodyAsString())
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.warn("Toss payment confirm exception", e);
throw new GeneralException(
GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
"토스페이먼츠 결제 승인 실패"
);
} catch (RestClientException e) {
log.warn("Toss payment confirm request failed. message={}", truncate(e.getMessage()));
log.warn("Toss payment confirm request exception", e);
throw new GeneralException(
GeneralErrorCode.PAYMENT_CONFIRM_FAILED,
"토스페이먼츠 결제 승인에 실패했습니다."
"토스페이먼츠 결제 승인 중 오류 발생"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Expand All @@ -52,4 +88,11 @@ private String authorizationHeader() {
String credential = secretKey + ":";
return "Basic " + Base64.getEncoder().encodeToString(credential.getBytes(StandardCharsets.UTF_8));
}

private String truncate(String value) {
if (value == null || value.length() <= LOG_MESSAGE_MAX_LENGTH) {
return value;
}
return value.substring(0, LOG_MESSAGE_MAX_LENGTH) + "...";
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package com.jobdri.jobdri_api.domain.user.repository;

import com.jobdri.jobdri_api.domain.user.entity.User;
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.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);
}
Loading
Loading