From 30183221e3dd016ad4b2456c6acc6702500e8731 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:16:43 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[FEAT]=20Toss=20Payments=20Webhook=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentWebhookController.java | 31 ++++++++++++++++ .../dto/request/PaymentWebhookDTO.java | 11 ++++++ .../payment/service/PaymentService.java | 37 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java create mode 100644 src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentWebhookDTO.java 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..feb825b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -0,0 +1,31 @@ +package com.eatsfine.eatsfine.domain.payment.controller; + +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; +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; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments/webhook") +@Tag(name = "Payment Webhook Controller", description = "Toss Payments 웹훅 수신 전용 컨트롤러") +public class PaymentWebhookController { + + private final PaymentService paymentService; + + @Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.") + @PostMapping + public ResponseEntity handleWebhook(@RequestBody PaymentWebhookDTO dto) { + log.info("Webhook received: orderId={}, status={}", dto.orderId(), dto.status()); + paymentService.processWebhook(dto); + return ResponseEntity.ok("Received"); + } +} 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..cad8f36 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/dto/request/PaymentWebhookDTO.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.payment.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PaymentWebhookDTO( + String paymentKey, + String orderId, + String status, + String eventType) { +} 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..dee3214 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,40 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId null // 환불 상세 정보는 현재 null 처리 ); } + + @Transactional + public void processWebhook(PaymentWebhookDTO dto) { + Payment payment = paymentRepository.findByOrderId(dto.orderId()) + .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + + PaymentStatus targetStatus = null; + if ("DONE".equals(dto.status())) { + targetStatus = PaymentStatus.COMPLETED; + } else if ("CANCELED".equals(dto.status())) { + targetStatus = PaymentStatus.REFUNDED; + } + + if (targetStatus == null) { + log.info("Webhook skipped: Unknown or unhandled status {}", dto.status()); + return; + } + + if (payment.getPaymentStatus() == targetStatus) { + log.info("Webhook skipped: Payment {} already in status {}", dto.orderId(), targetStatus); + return; + } + + if (targetStatus == PaymentStatus.COMPLETED) { + payment.completePayment( + LocalDateTime.now(), + PaymentMethod.SIMPLE_PAYMENT, + dto.paymentKey(), + null, + null); + log.info("Webhook processed: Payment {} status updated to COMPLETED", dto.orderId()); + } else if (targetStatus == PaymentStatus.REFUNDED) { + payment.cancelPayment(); + log.info("Webhook processed: Payment {} status updated to REFUNDED", dto.orderId()); + } + } } From 9afbcf50b89d9303bca33695b5242584e8062738 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:43:03 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[FEAT]=20Webhook=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentWebhookController.java | 3 ++- .../domain/payment/dto/request/PaymentWebhookDTO.java | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) 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 index feb825b..e2133ee 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -23,7 +24,7 @@ public class PaymentWebhookController { @Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.") @PostMapping - public ResponseEntity handleWebhook(@RequestBody PaymentWebhookDTO dto) { + public ResponseEntity handleWebhook(@RequestBody @Valid PaymentWebhookDTO dto) { log.info("Webhook received: orderId={}, status={}", dto.orderId(), dto.status()); paymentService.processWebhook(dto); return ResponseEntity.ok("Received"); 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 index cad8f36..7b3c984 100644 --- 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 @@ -1,11 +1,12 @@ package com.eatsfine.eatsfine.domain.payment.dto.request; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; @JsonIgnoreProperties(ignoreUnknown = true) public record PaymentWebhookDTO( - String paymentKey, - String orderId, - String status, - String eventType) { + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotBlank String status, + String eventType) { } From 9d56c4ca670e48c33b87c9e7c12ed1785c880186 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:44:40 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[FEAT]=20Toss=20Payments=20Webhook=20?= =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentWebhookController.java | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) 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 index e2133ee..72e1c3c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -2,7 +2,6 @@ import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; -import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -12,6 +11,17 @@ 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.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.RequestHeader; +import jakarta.validation.Validator; +import jakarta.validation.ConstraintViolation; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Set; @Slf4j @RestController @@ -21,12 +31,58 @@ public class PaymentWebhookController { private final PaymentService paymentService; + private final ObjectMapper objectMapper; + private final Validator validator; + + @Value("${payment.toss.widget-secret-key}") + private String widgetSecretKey; @Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.") @PostMapping - public ResponseEntity handleWebhook(@RequestBody @Valid PaymentWebhookDTO dto) { + public ResponseEntity handleWebhook( + @RequestBody String jsonBody, + @RequestHeader("tosspayments-webhook-signature") String signature, + @RequestHeader("tosspayments-webhook-transmission-time") String timestamp) throws JsonProcessingException { + + try { + verifySignature(jsonBody, signature, timestamp); + } catch (Exception e) { + // 서명이 다르면 401 Unauthorized 반환하여 즉시 차단 + log.error("Webhook signature verification failed", e); + return ResponseEntity.status(401).body("Invalid Signature"); + } + + PaymentWebhookDTO dto = objectMapper.readValue(jsonBody, PaymentWebhookDTO.class); + + 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 ResponseEntity.badRequest().body("Validation failed: " + sb.toString()); + } + log.info("Webhook received: orderId={}, status={}", dto.orderId(), dto.status()); paymentService.processWebhook(dto); return ResponseEntity.ok("Received"); } + + private void verifySignature(String jsonBody, String signature, String timestamp) throws Exception { + String payload = timestamp + "." + jsonBody; + String calculatedSignature = hmacSha256(payload, widgetSecretKey); + + // Toss가 보낸 서명에, 내가 만든 암호문이 포함되어 있는지 확인 + if (!signature.contains("v1:" + calculatedSignature)) { + throw new SecurityException("Signature verification failed"); + } + } + + private String hmacSha256(String data, String key) throws Exception { + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + sha256_HMAC.init(secret_key); + return Base64.getEncoder().encodeToString(sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } } From c87d4c3ec9fc41ac59f7a3f5723f9b3f344d1d2e Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:26:02 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[FEAT]=20Webhook=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EB=8B=A8(EasyPay)=20=ED=8C=8C=EC=8B=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentWebhookController.java | 2 +- .../dto/request/PaymentWebhookDTO.java | 20 +++++++++--- .../payment/service/PaymentService.java | 31 +++++++++++++------ 3 files changed, 39 insertions(+), 14 deletions(-) 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 index 72e1c3c..c8e32c8 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -64,7 +64,7 @@ public ResponseEntity handleWebhook( return ResponseEntity.badRequest().body("Validation failed: " + sb.toString()); } - log.info("Webhook received: orderId={}, status={}", dto.orderId(), dto.status()); + log.info("Webhook received: orderId={}, status={}", dto.data().orderId(), dto.data().status()); paymentService.processWebhook(dto); return ResponseEntity.ok("Received"); } 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 index 7b3c984..fa7de6e 100644 --- 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 @@ -1,12 +1,24 @@ 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; @JsonIgnoreProperties(ignoreUnknown = true) public record PaymentWebhookDTO( - @NotBlank String paymentKey, - @NotBlank String orderId, - @NotBlank String status, - String eventType) { + String eventType, + @Valid @NotNull PaymentData data) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record PaymentData( + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotBlank String status, + EasyPay easyPay) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EasyPay( + String provider) { + } } 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 dee3214..82a6e70 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 @@ -224,37 +224,50 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId @Transactional public void processWebhook(PaymentWebhookDTO dto) { - Payment payment = paymentRepository.findByOrderId(dto.orderId()) + PaymentWebhookDTO.PaymentData data = dto.data(); + + Payment payment = paymentRepository.findByOrderId(data.orderId()) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); PaymentStatus targetStatus = null; - if ("DONE".equals(dto.status())) { + if ("DONE".equals(data.status())) { targetStatus = PaymentStatus.COMPLETED; - } else if ("CANCELED".equals(dto.status())) { + } else if ("CANCELED".equals(data.status())) { targetStatus = PaymentStatus.REFUNDED; } if (targetStatus == null) { - log.info("Webhook skipped: Unknown or unhandled status {}", dto.status()); + log.info("Webhook skipped: Unknown or unhandled status {}", data.status()); return; } if (payment.getPaymentStatus() == targetStatus) { - log.info("Webhook skipped: Payment {} already in status {}", dto.orderId(), targetStatus); + log.info("Webhook skipped: Payment {} already in status {}", data.orderId(), targetStatus); return; } if (targetStatus == PaymentStatus.COMPLETED) { + // 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, - dto.paymentKey(), - null, + data.paymentKey(), + provider, null); - log.info("Webhook processed: Payment {} status updated to COMPLETED", dto.orderId()); + 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", dto.orderId()); + log.info("Webhook processed: Payment {} status updated to REFUNDED", data.orderId()); } } } From eb1b99d4735cb29452291a9fca057525231972be Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:29:33 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[FEAT]=20Webhook=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/dto/request/PaymentWebhookDTO.java | 2 ++ .../eatsfine/domain/payment/service/PaymentService.java | 8 ++++++++ 2 files changed, 10 insertions(+) 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 index fa7de6e..93e31b9 100644 --- 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 @@ -4,6 +4,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; @JsonIgnoreProperties(ignoreUnknown = true) public record PaymentWebhookDTO( @@ -14,6 +15,7 @@ public record PaymentData( @NotBlank String paymentKey, @NotBlank String orderId, @NotBlank String status, + BigDecimal totalAmount, EasyPay easyPay) { } 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 82a6e70..5262dee 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 @@ -247,6 +247,14 @@ public void processWebhook(PaymentWebhookDTO dto) { } 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) { From 8b3d10bdba39eabf8643ad62a08e9e05dcaec8b0 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:32:40 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[FEAT]=20Webhook=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=83=80=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/dto/request/PaymentWebhookDTO.java | 2 +- .../eatsfine/domain/payment/service/PaymentService.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 index 93e31b9..b907736 100644 --- 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 @@ -8,7 +8,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record PaymentWebhookDTO( - String eventType, + @NotBlank String eventType, @Valid @NotNull PaymentData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record PaymentData( 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 5262dee..f1ece98 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 @@ -224,6 +224,12 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId @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()) From 80b5f68906bf0d097bb0b4b25491aee673443564 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:41:44 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[FEAT]=20Webhook=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/service/PaymentService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 f1ece98..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 @@ -252,6 +252,19 @@ public void processWebhook(PaymentWebhookDTO dto) { 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) { From 592d38e37ae4cfdc0e39ffe11c1e1d7ed7bc3d11 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:47:12 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[FEAT]=20Webhook=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20Retry=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentWebhookController.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index c8e32c8..13adaa5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -1,6 +1,7 @@ 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; @@ -65,7 +66,19 @@ public ResponseEntity handleWebhook( } log.info("Webhook received: orderId={}, status={}", dto.data().orderId(), dto.data().status()); - paymentService.processWebhook(dto); + + try { + paymentService.processWebhook(dto); + } catch (PaymentException e) { + // 비즈니스 로직 오류(결제 없음 등)는 재시도해도 해결되지 않으므로 200 OK 반환하여 재시도 중단 + log.error("Webhook processing failed (Business Logic): {}", e.getMessage()); + return ResponseEntity.ok("Ignored: " + e.getMessage()); + } catch (Exception e) { + // 시스템 오류(DB 접속 불가 등)는 재시도 필요하므로 500 반환 + log.error("Webhook processing failed (System Error)", e); + return ResponseEntity.internalServerError().body("Internal Server Error"); + } + return ResponseEntity.ok("Received"); } From f747e467848b94dce77e56e6be12bf68797e7602 Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:57:51 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[REFACTOR]=20PaymentWebhookController=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B2=BD?= =?UTF-8?q?=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentWebhookController.java | 48 ++++++------------- .../payment/service/TossPaymentService.java | 20 ++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) 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 index 13adaa5..4aec3c4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java @@ -14,14 +14,9 @@ import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.RequestHeader; import jakarta.validation.Validator; import jakarta.validation.ConstraintViolation; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.Set; @Slf4j @@ -32,12 +27,10 @@ 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; - @Value("${payment.toss.widget-secret-key}") - private String widgetSecretKey; - @Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.") @PostMapping public ResponseEntity handleWebhook( @@ -46,23 +39,16 @@ public ResponseEntity handleWebhook( @RequestHeader("tosspayments-webhook-transmission-time") String timestamp) throws JsonProcessingException { try { - verifySignature(jsonBody, signature, timestamp); + tossPaymentService.verifyWebhookSignature(jsonBody, signature, timestamp); } catch (Exception e) { - // 서명이 다르면 401 Unauthorized 반환하여 즉시 차단 log.error("Webhook signature verification failed", e); return ResponseEntity.status(401).body("Invalid Signature"); } PaymentWebhookDTO dto = objectMapper.readValue(jsonBody, PaymentWebhookDTO.class); - 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 ResponseEntity.badRequest().body("Validation failed: " + sb.toString()); + if (hasValidationErrors(dto)) { + return ResponseEntity.badRequest().body("Validation failed"); } log.info("Webhook received: orderId={}, status={}", dto.data().orderId(), dto.data().status()); @@ -70,11 +56,9 @@ public ResponseEntity handleWebhook( try { paymentService.processWebhook(dto); } catch (PaymentException e) { - // 비즈니스 로직 오류(결제 없음 등)는 재시도해도 해결되지 않으므로 200 OK 반환하여 재시도 중단 log.error("Webhook processing failed (Business Logic): {}", e.getMessage()); return ResponseEntity.ok("Ignored: " + e.getMessage()); } catch (Exception e) { - // 시스템 오류(DB 접속 불가 등)는 재시도 필요하므로 500 반환 log.error("Webhook processing failed (System Error)", e); return ResponseEntity.internalServerError().body("Internal Server Error"); } @@ -82,20 +66,16 @@ public ResponseEntity handleWebhook( return ResponseEntity.ok("Received"); } - private void verifySignature(String jsonBody, String signature, String timestamp) throws Exception { - String payload = timestamp + "." + jsonBody; - String calculatedSignature = hmacSha256(payload, widgetSecretKey); - - // Toss가 보낸 서명에, 내가 만든 암호문이 포함되어 있는지 확인 - if (!signature.contains("v1:" + calculatedSignature)) { - throw new SecurityException("Signature verification failed"); + 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; } - } - - private String hmacSha256(String data, String key) throws Exception { - Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); - SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - sha256_HMAC.init(secret_key); - return Base64.getEncoder().encodeToString(sha256_HMAC.doFinal(data.getBytes(StandardCharsets.UTF_8))); + return false; } } 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))); + } } From 971c9bc17e11604ae1a8888ae58d8dc6d317019f Mon Sep 17 00:00:00 2001 From: CHAN <150508884+zerochani@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:06:04 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[FEAT]=20Payment=20Entity=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD(@Version)=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/eatsfine/eatsfine/domain/payment/entity/Payment.java | 4 ++++ 1 file changed, 4 insertions(+) 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;