-
Notifications
You must be signed in to change notification settings - Fork 1
Fix/kakao pay #153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix/kakao pay #153
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String> 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); | ||
| } | ||
| } | ||
|
Comment on lines
50
to
62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unauthenticated These GET endpoints accept a While the retrieved learning confirms these are browser redirect URLs (not server-to-server), consider adding a lightweight verification — e.g., a signed token or HMAC in the redirect URL generated during Also applies to: 65-77 🤖 Prompt for AI Agents
Comment on lines
+52
to
62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Replace
♻️ Proposed fixAdd } catch (Exception e) {
- e.printStackTrace();
+ log.error("Payment cancel failed for partnerOrderId={}", partnerOrderId, e);
response.sendRedirect(FRONT_DOMAIN);
} } catch (Exception e) {
- e.printStackTrace();
+ log.error("Payment fail handling failed for partnerOrderId={}", partnerOrderId, e);
response.sendRedirect(FRONT_DOMAIN);
}Also applies to: 67-77 🤖 Prompt for AI Agents |
||
|
|
||
| // 결제 실패 (시간 초과 15분) | ||
| @GetMapping("/fail") | ||
| @Operation(summary = "카카오페이 결제 실패 (자동 호출)", description = "15분간 결제 미완료 시 카카오 서버에서 fail_url로 자동 호출되는 API입니다. 직접 호출하지 마세요.") | ||
| public ApiResponse<String> 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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+190
to
192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If 🤖 Prompt for AI Agents |
||
|
|
||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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); | ||||||||
|
Comment on lines
+27
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Locate TempTicket entity
echo "=== Finding TempTicket entity ==="
fd -e java -path "*/entity/*" | xargs grep -l "class TempTicket" 2>/dev/null || rg -l "class TempTicket" --type=java
echo ""
echo "=== TempTicket entity - createdAt field definition ==="
rg "class TempTicket" -A 50 --type=java | head -100
echo ""
echo "=== Check for Hibernate/JDBC timezone configuration ==="
fd -e properties -o -e yml -o -e yaml | xargs grep -i "time.*zone\|jdbc.time_zone" 2>/dev/null || echo "No explicit timezone config found"
echo ""
echo "=== Check application.yml for timezone settings ==="
fd application.yml -o -path "*application*" -name "*.yml" -o -name "*.yaml" | head -5 | xargs cat 2>/dev/null | grep -i "timezone\|time-zone\|jackson\|jpa\|hibernate" || echo "Checking application.yml..."
echo ""
echo "=== Check for `@CreatedDate`, `@CreationTimestamp`, or `@Temporal` annotations ==="
rg "@CreatedDate|@CreationTimestamp|@Temporal|@Column.*columnDefinition" --type=java -B2 -A2
echo ""
echo "=== Check `@EnableScheduling` configuration ==="
rg "@EnableScheduling" --type=javaRepository: SeeATheater/CC_Backend Length of output: 14675 🏁 Script executed: cat -n src/main/java/cc/backend/ticket/entity/TempTicket.javaRepository: SeeATheater/CC_Backend Length of output: 2642 🏁 Script executed: cat -n src/main/java/cc/backend/domain/common/BaseEntity.javaRepository: SeeATheater/CC_Backend Length of output: 896 🏁 Script executed: find . -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" | head -5 | xargs cat 2>/dev/null | head -100Repository: SeeATheater/CC_Backend Length of output: 2030 🏁 Script executed: rg "LocalDateTime.now()" --type=java -C2 | head -30Repository: SeeATheater/CC_Backend Length of output: 3183 Critical timezone mismatch: scheduler uses explicit Asia/Seoul zone while database stores
When the scheduler compares a KST-based Use the same timezone as the persistence layer: either 🐛 Proposed fix (if JVM default is UTC)- LocalDateTime nowKst = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
- LocalDateTime expireTime = nowKst.minusMinutes(15);
+ LocalDateTime expireTime = LocalDateTime.now().minusMinutes(15);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
|
|
||||||||
| List<TempTicket> 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); | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<TempTicket> findAllByPerformanceDate(LocalDate date) { | |
| Optional<TempTicket> findWithTicketAndShowById(Long id); | ||
|
|
||
| // 스케줄러에서 사용 (15분 이상 PENDING 상태인 티켓을 EXPIRED로 변경) | ||
| List<TempTicket> findByReservationStatusAndCreatedAtBefore(ReservationStatus status, LocalDateTime expirationTime); | ||
| @Query(""" | ||
| SELECT t | ||
| FROM TempTicket t | ||
| WHERE t.reservationStatus = 'PENDING' | ||
| AND t.createdAt < :expireTime | ||
| """) | ||
| List<TempTicket> findExpiredPendingTickets(LocalDateTime expireTime); | ||
|
Comment on lines
+39
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find ReservationStatus enum definition
fd "ReservationStatus.java" --type fRepository: SeeATheater/CC_Backend Length of output: 133 🏁 Script executed: # Find TempTicket entity definition
fd "TempTicket.java" --type fRepository: SeeATheater/CC_Backend Length of output: 120 🏁 Script executed: # Search for `@Enumerated` annotations in the codebase to understand mapping patterns
rg "@Enumerated" --type java -B 2 -A 2Repository: SeeATheater/CC_Backend Length of output: 4719 🏁 Script executed: # Search for any usage of findExpiredPendingTickets method
rg "findExpiredPendingTickets" --type javaRepository: SeeATheater/CC_Backend Length of output: 350 🏁 Script executed: # Read ReservationStatus enum definition
cat "src/main/java/cc/backend/ticket/entity/enums/ReservationStatus.java"Repository: SeeATheater/CC_Backend Length of output: 189 🏁 Script executed: # Read TempTicket entity definition
cat "src/main/java/cc/backend/ticket/entity/TempTicket.java"Repository: SeeATheater/CC_Backend Length of output: 2075 🏁 Script executed: # Read TempTicketExpireScheduler to see usage context
cat "src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java"Repository: SeeATheater/CC_Backend Length of output: 1793 Use enum reference instead of string literal for type safety. The string literal Since the method name `@Query`("""
SELECT t
FROM TempTicket t
- WHERE t.reservationStatus = 'PENDING'
+ WHERE t.reservationStatus = cc.backend.ticket.entity.enums.ReservationStatus.PENDING
AND t.createdAt < :expireTime
""")
List<TempTicket> findExpiredPendingTickets(LocalDateTime expireTime);Alternatively, if the method should accept any status, use a parameter and update the signature: List<TempTicket> findExpiredPendingTickets(ReservationStatus status, LocalDateTime expireTime);Then pass 🤖 Prompt for AI Agents |
||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoded production URL replaces previously externalized configuration — this breaks environment portability.
Replacing the
@Value-injected URL with a hardcoded"https://seeatheater.site"means local development, staging, and other environments all redirect to production. The commented-out localhost line on line 24 confirms this is being toggled manually, which is error-prone and could easily leak into production with the wrong value.Re-externalize this to
application.yml(or environment variable), as it was before:🐛 Proposed fix
In
application.yml, add:In the controller:
🤖 Prompt for AI Agents