diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java new file mode 100644 index 0000000..4aec3c4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -0,0 +1,81 @@ +package com.eatsfine.eatsfine.domain.payment.controller; + +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.bind.annotation.RequestHeader; +import jakarta.validation.Validator; +import jakarta.validation.ConstraintViolation; +import java.util.Set; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments/webhook") +@Tag(name = "Payment Webhook Controller", description = "Toss Payments 웹훅 수신 전용 컨트롤러") +public class PaymentWebhookController { + + private final PaymentService paymentService; + private final com.eatsfine.eatsfine.domain.payment.service.TossPaymentService tossPaymentService; + private final ObjectMapper objectMapper; + private final Validator validator; + + @Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.") + @PostMapping + public ResponseEntity handleWebhook( + @RequestBody String jsonBody, + @RequestHeader("tosspayments-webhook-signature") String signature, + @RequestHeader("tosspayments-webhook-transmission-time") String timestamp) throws JsonProcessingException { + + try { + tossPaymentService.verifyWebhookSignature(jsonBody, signature, timestamp); + } catch (Exception e) { + log.error("Webhook signature verification failed", e); + return ResponseEntity.status(401).body("Invalid Signature"); + } + + PaymentWebhookDTO dto = objectMapper.readValue(jsonBody, PaymentWebhookDTO.class); + + if (hasValidationErrors(dto)) { + return ResponseEntity.badRequest().body("Validation failed"); + } + + log.info("Webhook received: orderId={}, status={}", dto.data().orderId(), dto.data().status()); + + try { + paymentService.processWebhook(dto); + } catch (PaymentException e) { + log.error("Webhook processing failed (Business Logic): {}", e.getMessage()); + return ResponseEntity.ok("Ignored: " + e.getMessage()); + } catch (Exception e) { + log.error("Webhook processing failed (System Error)", e); + return ResponseEntity.internalServerError().body("Internal Server Error"); + } + + return ResponseEntity.ok("Received"); + } + + private boolean hasValidationErrors(PaymentWebhookDTO dto) { + Set> violations = validator.validate(dto); + if (!violations.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (ConstraintViolation violation : violations) { + sb.append(violation.getPropertyPath()).append(" ").append(violation.getMessage()).append("; "); + } + log.error("Webhook validation failed: {}", sb.toString()); + return true; + } + return false; + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentWebhookDTO.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentWebhookDTO.java new file mode 100644 index 0000000..b907736 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentWebhookDTO.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PaymentWebhookDTO( + @NotBlank String eventType, + @Valid @NotNull PaymentData data) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record PaymentData( + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotBlank String status, + BigDecimal totalAmount, + EasyPay easyPay) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EasyPay( + String provider) { + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 710c504..7e33feb 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -25,6 +25,10 @@ public class Payment extends BaseEntity { @Column(name = "payment_id") private Long id; + // 낙관적 락을 위한 버전 필드 + @Version + private Long version; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "booking_id", nullable = false) private Booking booking; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 6cec011..9ca43c5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.booking.entity.Booking; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; @@ -220,4 +221,80 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId null // 환불 상세 정보는 현재 null 처리 ); } + + @Transactional + public void processWebhook(PaymentWebhookDTO dto) { + // 이벤트 타입 검증 + if (!"PAYMENT_STATUS_CHANGED".equals(dto.eventType())) { + log.info("Webhook skipped: Unhandled event type {}", dto.eventType()); + return; + } + + PaymentWebhookDTO.PaymentData data = dto.data(); + + Payment payment = paymentRepository.findByOrderId(data.orderId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + PaymentStatus targetStatus = null; + if ("DONE".equals(data.status())) { + targetStatus = PaymentStatus.COMPLETED; + } else if ("CANCELED".equals(data.status())) { + targetStatus = PaymentStatus.REFUNDED; + } + + if (targetStatus == null) { + log.info("Webhook skipped: Unknown or unhandled status {}", data.status()); + return; + } + + if (payment.getPaymentStatus() == targetStatus) { + log.info("Webhook skipped: Payment {} already in status {}", data.orderId(), targetStatus); + return; + } + + // 상태 전환 유효성 검사 + // COMPLETED 완료 처리는 오직 PENDING 상태에서만 가능 + if (targetStatus == PaymentStatus.COMPLETED && payment.getPaymentStatus() != PaymentStatus.PENDING) { + log.warn("Webhook skipped: Invalid state transition from {} to {} for OrderID {}", + payment.getPaymentStatus(), targetStatus, data.orderId()); + return; + } + if (targetStatus == PaymentStatus.REFUNDED && payment.getPaymentStatus() != PaymentStatus.COMPLETED) { + log.warn("Webhook skipped: Invalid state transition from {} to {} for OrderID {}", + payment.getPaymentStatus(), targetStatus, data.orderId()); + return; + } + + if (targetStatus == PaymentStatus.COMPLETED) { + // 금액 검증 + if (data.totalAmount() == null || payment.getAmount().compareTo(data.totalAmount()) != 0) { + log.error("Webhook amount verification failed for OrderID: {}. Expected: {}, Received: {}", + data.orderId(), payment.getAmount(), data.totalAmount()); + payment.failPayment(); + return; + } + + // Provider 파싱 + PaymentProvider provider = null; + if (data.easyPay() != null) { + String providerCode = data.easyPay().provider(); + if ("토스페이".equals(providerCode)) { + provider = PaymentProvider.TOSS; + } else if ("카카오페이".equals(providerCode)) { + provider = PaymentProvider.KAKAOPAY; + } + } + + payment.completePayment( + LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + data.paymentKey(), + provider, + null); + log.info("Webhook processed: Payment {} status updated to COMPLETED", data.orderId()); + } else if (targetStatus == PaymentStatus.REFUNDED) { + payment.cancelPayment(); + log.info("Webhook processed: Payment {} status updated to REFUNDED", data.orderId()); + } + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java index 4a434f8..de69ded 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; +import org.springframework.beans.factory.annotation.Value; @Slf4j @Service @@ -16,6 +17,9 @@ public class TossPaymentService { private final RestClient tossPaymentClient; + @Value("${payment.toss.widget-secret-key}") + private String widgetSecretKey; + public TossPaymentService(@Qualifier("tossPaymentClient") RestClient tossPaymentClient) { this.tossPaymentClient = tossPaymentClient; } @@ -45,4 +49,20 @@ public TossPaymentResponse cancel(String paymentKey, PaymentRequestDTO.CancelPay throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); } } + + public void verifyWebhookSignature(String jsonBody, String signature, String timestamp) throws Exception { + String payload = timestamp + "." + jsonBody; + String calculatedSignature = hmacSha256(payload, widgetSecretKey); + + if (!signature.contains("v1:" + calculatedSignature)) { + throw new SecurityException("Signature verification failed"); + } + } + + private String hmacSha256(String data, String key) throws Exception { + javax.crypto.Mac sha256_HMAC = javax.crypto.Mac.getInstance("HmacSHA256"); + javax.crypto.spec.SecretKeySpec secret_key = new javax.crypto.spec.SecretKeySpec(key.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"); + sha256_HMAC.init(secret_key); + return java.util.Base64.getEncoder().encodeToString(sha256_HMAC.doFinal(data.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } }