KakaoPayService.java
package qwerty.chaekit.service.ebook.credit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import qwerty.chaekit.dto.ebook.credit.payment.CreditPaymentReadyRequest;
import qwerty.chaekit.dto.external.kakaopay.KakaoPayApproveResponse;
import qwerty.chaekit.dto.external.kakaopay.KakaoPayCancelResponse;
import qwerty.chaekit.dto.external.kakaopay.KakaoPayReadyResponse;
import qwerty.chaekit.global.constant.CreditProduct;
import qwerty.chaekit.global.enums.ErrorCode;
import qwerty.chaekit.global.exception.BadRequestException;
import qwerty.chaekit.global.properties.KakaoPayProperties;
import qwerty.chaekit.global.security.resolver.UserToken;
import qwerty.chaekit.service.ebook.credit.exception.PaymentCancelFailedException;
import java.time.Duration;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class KakaoPayService {
private static final String KAKAO_PAY_READY_URL = "https://open-api.kakaopay.com/online/v1/payment/ready";
private static final String KAKAO_PAY_CANCEL_URL = "https://open-api.kakaopay.com/online/v1/payment/cancel";
private static final String KAKAO_PAY_APPROVE_URL = "https://open-api.kakaopay.com/online/v1/payment/approve";
private static final String REDIS_TID_KEY_PREFIX = "kakao:tid:";
private static final String REDIS_ORDER_ID_KEY_PREFIX = "kakao:orderId:";
private static final Duration REDIS_KEY_EXPIRATION = Duration.ofMinutes(10);
private final RestTemplate restTemplate;
private final KakaoPayProperties kakaoPayProperties;
private final RedisTemplate<String, String> redisTemplate;
@Transactional
public String requestKakaoPay(UserToken userToken, CreditPaymentReadyRequest request) {
Long creditProductId = request.creditProductId();
CreditProduct product = findCreditProductById(creditProductId);
String orderId = UUID.randomUUID().toString();
HttpEntity<Map<String, String>> httpEntity = createKakaoPayRequest(userToken, product, orderId);
ResponseEntity<KakaoPayReadyResponse> response = restTemplate.postForEntity(
KAKAO_PAY_READY_URL, httpEntity, KakaoPayReadyResponse.class
);
return handleKakaoPayResponse(response, userToken.userId(), orderId);
}
private CreditProduct findCreditProductById(Long creditProductId) {
return Arrays.stream(CreditProduct.values())
.filter(p -> p.id == creditProductId)
.findFirst()
.orElseThrow(() -> new BadRequestException(ErrorCode.INVALID_CREDIT_PRODUCT_ID));
}
private HttpEntity<Map<String, String>> createKakaoPayRequest(UserToken userToken, CreditProduct product,
String orderId) {
HttpHeaders headers = createKakaoPayHeaders();
Map<String, String> body = createKakaoPayRequestBody(userToken, product, orderId);
log.info("[KakaoPay Create Request] Headers: {}", headers);
log.info("[KakaoPay Create Request] Body: {}", body);
return new HttpEntity<>(body, headers);
}
private Map<String, String> createKakaoPayRequestBody(UserToken userToken, CreditProduct product,
String orderId) {
Map<String, String> body = new HashMap<>();
body.put("cid", kakaoPayProperties.cid());
body.put("partner_order_id", orderId);
body.put("partner_user_id", String.valueOf(userToken.userId()));
body.put("item_name", product.getName());
body.put("item_code", String.valueOf(product.id));
body.put("quantity", "1");
body.put("total_amount", String.valueOf(product.price));
body.put("tax_free_amount", "0");
String redirectBaseUrl = kakaoPayProperties.redirectBaseUrl();
body.put("approval_url", redirectBaseUrl + "/credits/payment/success");
body.put("cancel_url", redirectBaseUrl + "/credits/payment/cancel");
body.put("fail_url", redirectBaseUrl + "/credits/payment/fail");
return body;
}
private String handleKakaoPayResponse(ResponseEntity<KakaoPayReadyResponse> response, Long userId,
String orderId) {
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
String tid = response.getBody().tid();
// save tid, orderId to Redis
String tidKey = REDIS_TID_KEY_PREFIX + userId;
redisTemplate.opsForValue().set(tidKey, tid, REDIS_KEY_EXPIRATION);
String orderIdKey = REDIS_ORDER_ID_KEY_PREFIX + tid;
redisTemplate.opsForValue().set(orderIdKey, orderId, REDIS_KEY_EXPIRATION);
return response.getBody().next_redirect_pc_url(); // pc only
}
throw new IllegalStateException("카카오페이 요청 실패");
}
@Transactional
public void cancelKakaoPayPayment(String tid, long amount) {
HttpEntity<Map<String, String>> httpEntity = createKakaoPayCancelRequest(tid, amount);
ResponseEntity<KakaoPayCancelResponse> response = restTemplate.postForEntity(
KAKAO_PAY_CANCEL_URL,
httpEntity,
KakaoPayCancelResponse.class
);
if (!response.getStatusCode().is2xxSuccessful()
|| response.getBody() == null
|| !response.getBody().status().equals("CANCEL_PAYMENT")) {
throw new PaymentCancelFailedException("카카오페이 환불 처리가 실패했습니다.");
}
}
private HttpEntity<Map<String, String>> createKakaoPayCancelRequest(String tid, long amount) {
HttpHeaders headers = createKakaoPayHeaders();
Map<String, String> body = createKakaoPayCancelRequestBody(tid, amount);
return new HttpEntity<>(body, headers);
}
private Map<String, String> createKakaoPayCancelRequestBody(String tid, long amount) {
Map<String, String> body = new HashMap<>();
body.put("cid", kakaoPayProperties.cid());
body.put("tid", tid);
body.put("cancel_amount", String.valueOf(amount));
body.put("cancel_tax_free_amount", "0");
return body;
}
@Transactional
public KakaoPayApproveResponse approveKakaoPayPayment(Long userId, String pgToken) {
String tid = loadTidFromRedis(userId);
String orderId = loadOrderIdFromRedis(tid);
HttpEntity<Map<String, String>> httpEntity = createKakaoPayApproveRequest(userId, tid, orderId, pgToken);
ResponseEntity<KakaoPayApproveResponse> response = restTemplate.postForEntity(
KAKAO_PAY_APPROVE_URL,
httpEntity,
KakaoPayApproveResponse.class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return response.getBody();
}
throw new IllegalStateException("카카오페이 결제 승인 실패");
}
private String loadTidFromRedis(Long userId) {
String redisKey = REDIS_TID_KEY_PREFIX + userId;
String tid = redisTemplate.opsForValue().get(redisKey);
if (tid == null) {
throw new BadRequestException(ErrorCode.INVALID_PAYMENT_SESSION);
}
redisTemplate.delete(redisKey);
return tid;
}
private String loadOrderIdFromRedis(String tid) {
String orderIdKey = REDIS_ORDER_ID_KEY_PREFIX + tid;
String orderId = redisTemplate.opsForValue().get(orderIdKey);
if (orderId == null) {
throw new BadRequestException(ErrorCode.INVALID_PAYMENT_SESSION);
}
redisTemplate.delete(orderId);
return orderId;
}
private HttpEntity<Map<String, String>> createKakaoPayApproveRequest(Long userId, String tid,
String orderId, String pgToken) {
HttpHeaders headers = createKakaoPayHeaders();
Map<String, String> body = new HashMap<>();
body.put("cid", kakaoPayProperties.cid());
body.put("tid", tid);
body.put("partner_order_id", orderId);
body.put("partner_user_id", String.valueOf(userId));
body.put("pg_token", pgToken);
return new HttpEntity<>(body, headers);
}
// ==== common methods ====
private HttpHeaders createKakaoPayHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "SECRET_KEY " + kakaoPayProperties.secretKey());
return headers;
}
}