diff --git a/src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java b/src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java index 9d5d5de0..3a9f9d14 100644 --- a/src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java +++ b/src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java @@ -9,7 +9,6 @@ import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -21,9 +20,9 @@ public class KakaoPayController { private final KakaoPayBusinessService kakaoPayBusinessService; - - @Value("${front.kakaopay.complete-url}") - private String frontKakaoPayCompleteUrl; + +// private static final String FRONT_DOMAIN = "http://localhost:5173"; + private static final String FRONT_DOMAIN = "https://seeatheater.site"; // 결제 준비 요청 (결제 페이지에 대한 url 발급 요청) @PostMapping("/ready") @@ -43,31 +42,37 @@ public void approve(@Parameter(description = "ticketId 입니다") @RequestParam kakaoPayBusinessService.completePayment(partnerOrderId, pgToken); response.sendRedirect( - frontKakaoPayCompleteUrl + "?playId=" + result.getAmateurShowId() + FRONT_DOMAIN + "/ticketing/" + result.getAmateurShowId() + "?payment=success" ); } // 사용자가 X버튼으로 결제 도중 취소 (환불 아님) @GetMapping("/cancel") @Operation(summary = "카카오페이 결제 중단 취소 (자동 호출)", description = "결제 중단 시 카카오 서버에서 cancel_url로 자동 호출되는 API입니다. 직접 호출하지 마세요.") - public ApiResponse cancel(@RequestParam("partner_order_id") String partnerOrderId) { + public void cancel(@RequestParam("partner_order_id") String partnerOrderId, HttpServletResponse response) throws IOException{ try { - kakaoPayBusinessService.stopPayment(partnerOrderId); - return ApiResponse.onSuccess("결제가 취소되었습니다."); - } catch (NumberFormatException e) { - return ApiResponse.onFailure("INVALID_ORDER_ID", "유효하지 않은 주문번호입니다.", null); + Long playId = kakaoPayBusinessService.stopPayment(partnerOrderId); + response.sendRedirect( + FRONT_DOMAIN + "/ticketing/" + playId + "?payment=cancel" + ); + } catch (Exception e) { + e.printStackTrace(); + response.sendRedirect(FRONT_DOMAIN); } } // 결제 실패 (시간 초과 15분) @GetMapping("/fail") @Operation(summary = "카카오페이 결제 실패 (자동 호출)", description = "15분간 결제 미완료 시 카카오 서버에서 fail_url로 자동 호출되는 API입니다. 직접 호출하지 마세요.") - public ApiResponse fail(@RequestParam("partner_order_id") String partnerOrderId) { + public void fail(@RequestParam("partner_order_id") String partnerOrderId,HttpServletResponse response) throws IOException{ try { - kakaoPayBusinessService.stopPayment(partnerOrderId); - return ApiResponse.onSuccess("결제에 실패했습니다."); - } catch (NumberFormatException e) { - return ApiResponse.onFailure("INVALID_ORDER_ID", "유효하지 않은 주문번호입니다.", null); + Long playId = kakaoPayBusinessService.stopPayment(partnerOrderId); + response.sendRedirect( + FRONT_DOMAIN + "/ticketing/" + playId + "?payment=fail" + ); + } catch (Exception e) { + e.printStackTrace(); + response.sendRedirect(FRONT_DOMAIN); } } } diff --git a/src/main/java/cc/backend/kakaoPay/service/KakaoPayBusinessService.java b/src/main/java/cc/backend/kakaoPay/service/KakaoPayBusinessService.java index 1282dc9c..7f9468fd 100644 --- a/src/main/java/cc/backend/kakaoPay/service/KakaoPayBusinessService.java +++ b/src/main/java/cc/backend/kakaoPay/service/KakaoPayBusinessService.java @@ -187,16 +187,18 @@ public RealTicketResponseDTO cancelTicket(Long memberId, Long realTicketId) { } // 결제 중단(취소/실패) 시 재고 복구 로직 - public void stopPayment(String partnerOrderId) { + public Long stopPayment(String partnerOrderId) { Long ticketId = Long.valueOf(partnerOrderId); TempTicket tempTicket = tempTicketRepository.findById(ticketId) .orElseThrow(() -> new GeneralException(ErrorStatus.TEMP_TICKET_NOT_FOUND)); + Long playId=tempTicket.getAmateurRound().getAmateurShow().getId(); + // 이미 처리된 건이면 패스 if (tempTicket.getReservationStatus() != ReservationStatus.PENDING) { - return; + return playId; } // 1. 재고 복구 (증가) @@ -207,5 +209,7 @@ public void stopPayment(String partnerOrderId) { // 2. 티켓 상태 변경 tempTicket.updateReservationStatus(ReservationStatus.EXPIRED); + + return playId; } } diff --git a/src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java b/src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java new file mode 100644 index 00000000..14ab45ad --- /dev/null +++ b/src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java @@ -0,0 +1,53 @@ +package cc.backend.scheduler; + +import cc.backend.ticket.entity.TempTicket; +import cc.backend.ticket.repository.TempTicketRepository; +import cc.backend.ticket.entity.enums.ReservationStatus; +import cc.backend.kakaoPay.service.KakaoPayBusinessService; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TempTicketExpireScheduler { + + private final TempTicketRepository tempTicketRepository; + private final KakaoPayBusinessService kakaoPayBusinessService; + + // 1분마다 실행 + @Scheduled(fixedDelay = 60000) + public void expirePendingTickets() { + + LocalDateTime nowKst = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + LocalDateTime expireTime = nowKst.minusMinutes(15); + + List expiredTickets = + tempTicketRepository.findExpiredPendingTickets(expireTime); + + if (expiredTickets.isEmpty()) return; + + log.info("TTL 만료 대상 {}개", expiredTickets.size()); + + for (TempTicket tempTicket : expiredTickets) { + try { + if (tempTicket.getReservationStatus() != ReservationStatus.PENDING) + continue; + + kakaoPayBusinessService.stopPayment( + String.valueOf(tempTicket.getId()) + ); + + log.info("tempTicket {} TTL 만료 처리 완료", tempTicket.getId()); + + } catch (Exception e) { + log.error("TTL expire 실패 tempTicketId={}", tempTicket.getId(), e); + } + } + } +} diff --git a/src/main/java/cc/backend/ticket/repository/TempTicketRepository.java b/src/main/java/cc/backend/ticket/repository/TempTicketRepository.java index b4a75ba4..f8410a1f 100644 --- a/src/main/java/cc/backend/ticket/repository/TempTicketRepository.java +++ b/src/main/java/cc/backend/ticket/repository/TempTicketRepository.java @@ -4,6 +4,7 @@ import cc.backend.ticket.entity.enums.ReservationStatus; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -35,5 +36,12 @@ default List findAllByPerformanceDate(LocalDate date) { Optional findWithTicketAndShowById(Long id); // 스케줄러에서 사용 (15분 이상 PENDING 상태인 티켓을 EXPIRED로 변경) - List findByReservationStatusAndCreatedAtBefore(ReservationStatus status, LocalDateTime expirationTime); + @Query(""" + SELECT t + FROM TempTicket t + WHERE t.reservationStatus = 'PENDING' + AND t.createdAt < :expireTime + """) + List findExpiredPendingTickets(LocalDateTime expireTime); + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd52d182..bceaf718 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,9 +56,7 @@ kakaopay: approval: ${KAKAOPAY_APPROVE_URL} cancel: ${KAKAOPAY_CANCEL_URL} fail: ${KAKAOPAY_FAIL_URL} -front: - kakaopay: - complete-url: ${FRONT_KAKAOPAY_COMPLETE_URL} + management: endpoints: web: