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 @@ -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.*;

Expand All @@ -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";
Comment on lines +23 to +25
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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:

front:
  domain: ${FRONT_DOMAIN}

In the controller:

-//    private static final String FRONT_DOMAIN = "http://localhost:5173";
-    private static final String FRONT_DOMAIN = "https://seeatheater.site";
+    `@Value`("${front.domain}")
+    private String frontDomain;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java` around
lines 23 - 25, Replace the hardcoded FRONT_DOMAIN constant with an externalized
property: remove the private static final String FRONT_DOMAIN =
"https://seeatheater.site" and restore an `@Value-injected` field in
KakaoPayController (e.g., add `@Value`("${front.domain}") private String
FRONT_DOMAIN) so the controller reads the URL from configuration/environment;
add the property mapping in application.yml (front.domain: ${FRONT_DOMAIN}) or
ensure FRONT_DOMAIN is provided as an env var, and update any references to use
the injected FRONT_DOMAIN field.


// 결제 준비 요청 (결제 페이지에 대한 url 발급 요청)
@PostMapping("/ready")
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unauthenticated cancel and fail endpoints allow any caller to expire arbitrary pending tickets.

These GET endpoints accept a partner_order_id without authentication and call stopPayment, which restores inventory and marks the ticket as EXPIRED. If an attacker guesses or enumerates ticket IDs, they can force-expire other users' in-flight payments.

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 preparePayment, validated here before calling stopPayment.

Also applies to: 65-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java` around
lines 50 - 62, The cancel and fail GET handlers (methods cancel and fail) call
kakaoPayBusinessService.stopPayment using an unauthenticated partner_order_id,
allowing attackers to expire tickets by guessing IDs; modify preparePayment to
emit a short-lived signed token/HMAC tied to partner_order_id (and optionally
timestamp/nonce) and include it in the redirect URL, then validate that token in
the cancel and fail endpoints before calling stopPayment (reject or ignore
requests with missing/invalid/expired tokens); ensure validation uses a
server-side secret and check signature + expiry to prevent replay.

Comment on lines +52 to 62
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace e.printStackTrace() with proper logging.

e.printStackTrace() writes to stderr and bypasses the application's logging framework (log level, format, aggregation). The class doesn't even have @Slf4j. Use a logger instead so these errors are captured in your structured logs.

♻️ Proposed fix

Add @Slf4j to the class, then:

         } 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
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/kakaoPay/controller/KakaoPayController.java` around
lines 52 - 62, Add structured logging to KakaoPayController by annotating the
class with a logging annotation (e.g., `@Slf4j`) and replace any
e.printStackTrace() calls in the cancel method (and the similar block at lines
67-77) with log.error statements that include a clear message and the exception
(e.g., log.error("Failed to cancel payment for partnerOrderId={}, redirecting to
front", partnerOrderId, e)); ensure you import/use the same logger across the
class so exceptions are captured by the app's logging framework.


// 결제 실패 (시간 초과 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
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Long.valueOf on an invalid partnerOrderId will throw an unchecked NumberFormatException.

If partnerOrderId is not a valid numeric string (e.g., corrupted or tampered input), Long.valueOf on line 192 throws NumberFormatException, which propagates unhandled. In the controller's cancel/fail endpoints this is caught by the generic catch (Exception e), but in the scheduler path it could log-and-continue. Still, a more explicit handling or logging of this parse step would make debugging easier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/kakaoPay/service/KakaoPayBusinessService.java`
around lines 190 - 192, The code currently calls Long.valueOf(partnerOrderId) in
stopPayment which will throw NumberFormatException for non-numeric input; wrap
the parse in a try-catch, catch NumberFormatException, log a clear message
including the offending partnerOrderId and the exception, and then return a safe
failure (e.g., throw an IllegalArgumentException or a domain-specific exception)
instead of allowing the unchecked exception to propagate silently; update
callers if needed so the scheduler and controller handle the returned/raised
error consistently.


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. 재고 복구 (증가)
Expand All @@ -207,5 +209,7 @@ public void stopPayment(String partnerOrderId) {

// 2. 티켓 상태 변경
tempTicket.updateReservationStatus(ReservationStatus.EXPIRED);

return playId;
}
}
53 changes: 53 additions & 0 deletions src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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=java

Repository: SeeATheater/CC_Backend

Length of output: 14675


🏁 Script executed:

cat -n src/main/java/cc/backend/ticket/entity/TempTicket.java

Repository: SeeATheater/CC_Backend

Length of output: 2642


🏁 Script executed:

cat -n src/main/java/cc/backend/domain/common/BaseEntity.java

Repository: 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 -100

Repository: SeeATheater/CC_Backend

Length of output: 2030


🏁 Script executed:

rg "LocalDateTime.now()" --type=java -C2 | head -30

Repository: SeeATheater/CC_Backend

Length of output: 3183


Critical timezone mismatch: scheduler uses explicit Asia/Seoul zone while database stores createdAt in JVM default timezone (typically UTC).

LocalDateTime.now(ZoneId.of("Asia/Seoul")) produces a KST-aware timestamp, but the @CreatedDate field in BaseEntity persists createdAt as timezone-unaware LocalDateTime, using the JVM's default timezone (UTC in most cloud deployments). The jackson.time-zone: Asia/Seoul setting in application.yml only affects JSON serialization, not JPA persistence.

When the scheduler compares a KST-based expireTime against UTC-stored createdAt values, timestamps appear ~9 hours older than they actually are, causing tickets to expire prematurely.

Use the same timezone as the persistence layer: either LocalDateTime.now() (matching JVM default) if the database stores in UTC, or explicitly configure hibernate.jdbc.time_zone to ensure consistency.

🐛 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

‼️ 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
LocalDateTime nowKst = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
LocalDateTime expireTime = nowKst.minusMinutes(15);
LocalDateTime expireTime = LocalDateTime.now().minusMinutes(15);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/scheduler/TempTicketExpireScheduler.java` around
lines 27 - 28, The scheduler in TempTicketExpireScheduler uses
LocalDateTime.now(ZoneId.of("Asia/Seoul")) to compute expireTime while persisted
createdAt in BaseEntity is timezone-unaware (JVM default/UTC), causing a 9-hour
mismatch; change the scheduler to use the same timezone as persistence (replace
LocalDateTime.now(ZoneId.of("Asia/Seoul")) and expireTime calculation with
LocalDateTime.now() if your JVM/database uses UTC) or alternatively configure
hibernate.jdbc.time_zone to a fixed zone and make both BaseEntity persistence
and TempTicketExpireScheduler use that same zone consistently; update any
related tests/comments to reflect the chosen approach.


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
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find ReservationStatus enum definition
fd "ReservationStatus.java" --type f

Repository: SeeATheater/CC_Backend

Length of output: 133


🏁 Script executed:

# Find TempTicket entity definition
fd "TempTicket.java" --type f

Repository: 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 2

Repository: SeeATheater/CC_Backend

Length of output: 4719


🏁 Script executed:

# Search for any usage of findExpiredPendingTickets method
rg "findExpiredPendingTickets" --type java

Repository: 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 'PENDING' in JPQL is fragile — if the enum constant name or @Enumerated mapping strategy changes, this query breaks silently at runtime. While the current @Enumerated(EnumType.STRING) mapping makes the comparison work, it lacks compile-time type safety.

Since the method name findExpiredPendingTickets and the scheduler usage indicate that PENDING is intentionally hardcoded (not parameterizable), replace the string literal with a fully qualified enum reference:

     `@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 ReservationStatus.PENDING from the scheduler. However, this changes the API contract—keep the current approach if hardcoding PENDING is the intended design.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/cc/backend/ticket/repository/TempTicketRepository.java` around
lines 39 - 45, The JPQL in TempTicketRepository.findExpiredPendingTickets uses
the string literal 'PENDING' which is brittle; update the query to reference the
enum constant directly (e.g., use the fully qualified enum reference like
YourReservationStatusEnumType.PENDING in the WHERE clause) so the JPQL compares
against the enum constant rather than a string, or if you prefer a reusable API,
change the method signature to accept a ReservationStatus parameter
(ReservationStatus status, LocalDateTime expireTime) and use that parameter in
the WHERE clause and pass ReservationStatus.PENDING from the scheduler; adjust
imports and tests accordingly.


}
4 changes: 1 addition & 3 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down