From 03149a637d1cd799f03c1f560b210217a411d773 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 25 Sep 2025 21:18:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(charge):=20payverse=20pg=20-=20webhook=20A?= =?UTF-8?q?PI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/can/charge/ChargeController.kt | 27 +++++++- .../sodalive/can/charge/ChargeData.kt | 16 +++++ .../sodalive/can/charge/ChargeService.kt | 67 +++++++++++++++++++ src/main/resources/application.yml | 1 + 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt index 1d78533..983239f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -6,18 +6,25 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingService import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal 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 org.springframework.web.server.ResponseStatusException import java.time.LocalDateTime +import javax.servlet.http.HttpServletRequest @RestController @RequestMapping("/charge") class ChargeController( private val service: ChargeService, - private val trackingService: AdTrackingService + private val trackingService: AdTrackingService, + + @Value("\${payverse.inbound-ip}") + private val payverseInboundIp: String ) { @PostMapping("/payverse") @@ -46,6 +53,24 @@ class ChargeController( ApiResponse.ok(Unit) } + // Payverse Webhook 엔드포인트 (payverseVerify 아래) + @PostMapping("/payverse/webhook") + fun payverseWebhook( + @RequestBody request: PayverseWebhookRequest, + servletRequest: HttpServletRequest + ): PayverseWebhookResponse { + val remoteIp = servletRequest.remoteAddr ?: "" + if (remoteIp != payverseInboundIp) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + + val success = service.payverseWebhook(request) + if (!success) { + throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + return PayverseWebhookResponse(receiveResult = "SUCCESS") + } + @PostMapping fun charge( @RequestBody chargeRequest: ChargeRequest, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt index 68b0eb0..312c713 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt @@ -72,3 +72,19 @@ data class PayverseVerifyResponse( val processingCurrency: String, val processingAmount: BigDecimal ) + +data class PayverseWebhookRequest( + val type: String, + val mid: String, + val tid: String, + val schemeCode: String, + val orderId: String, + val productName: String, + val requestCurrency: String, + val requestAmount: BigDecimal, + val resultStatus: String, + val approvalDay: String, + val sign: String +) + +data class PayverseWebhookResponse(val receiveResult: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index c71028e..cdfe200 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -81,6 +81,73 @@ class ChargeService( private val serverEnv: String ) { + @Transactional + fun payverseWebhook(request: PayverseWebhookRequest): Boolean { + val chargeId = request.orderId.toLongOrNull() ?: return false + val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false + + // 결제수단 확인 + if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { + return false + } + + // 결제 상태 분기 처리 + return when (charge.payment?.status) { + PaymentStatus.REQUEST -> { + // 성공 조건 검증 + val expectedSign = DigestUtils.sha512Hex( + String.format( + "||%s||%s||%s||%s||%s||", + payverseSecretKey, + payverseMid, + request.orderId, + request.requestAmount, + request.approvalDay + ) + ) + + val isAmountMatch = request.requestAmount.compareTo( + BigDecimal.valueOf(charge.payment!!.price) + ) == 0 + + val isSuccess = request.resultStatus == "SUCCESS" && + request.mid == payverseMid && + charge.title == request.productName && + isAmountMatch && + request.sign == expectedSign + + if (isSuccess) { + // payverseVerify의 226~246 라인과 동일 처리 + charge.payment?.receiptId = request.tid + charge.payment?.method = request.schemeCode + charge.payment?.status = PaymentStatus.COMPLETE + charge.payment?.locale = request.requestCurrency + + val member = charge.member!! + member.charge(charge.chargeCan, charge.rewardCan, "pg") + + applicationEventPublisher.publishEvent( + ChargeSpringEvent( + chargeId = charge.id!!, + memberId = member.id!! + ) + ) + true + } else { + false + } + } + PaymentStatus.COMPLETE -> { + // 이미 결제가 완료된 경우 성공 처리(idempotent) + true + } + else -> { + // 그 외 상태는 404 + false + } + } + } + @Transactional fun chargeByCoupon(couponNumber: String, member: Member): String { val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1851faf..cf444de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,7 @@ payverse: clientKey: ${PAYVERSE_CLIENT_KEY} secretKey: ${PAYVERSE_SECRET_KEY} host: ${PAYVERSE_HOST} + inboundIp: ${PAYVERSE_INBOUND_IP} bootpay: applicationId: ${BOOTPAY_APPLICATION_ID}