From bc6c05b3ea8a2d7e196a87d1d57a1bfbe322ba67 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 25 Sep 2025 20:37:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(charge):=20payverse=20pg=20-=20=EC=B6=A9?= =?UTF-8?q?=EC=A0=84/=EA=B2=80=EC=A6=9D=20API=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 | 26 ++++ .../sodalive/can/charge/ChargeData.kt | 28 ++++ .../sodalive/can/charge/ChargeService.kt | 140 +++++++++++++++++- .../sodalive/can/payment/PaymentGateway.kt | 2 +- src/main/resources/application.yml | 6 + 5 files changed, 200 insertions(+), 2 deletions(-) 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 3748c96..1d78533 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 @@ -20,6 +20,32 @@ class ChargeController( private val trackingService: AdTrackingService ) { + @PostMapping("/payverse") + fun payverseCharge( + @RequestBody request: PayverseChargeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.payverseCharge(member, request)) + } + + @PostMapping("/payverse/verify") + fun payverseVerify( + @RequestBody verifyRequest: PayverseVerifyRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val response = service.payverseVerify(memberId = member.id!!, verifyRequest) + trackingCharge(member, response) + ApiResponse.ok(Unit) + } + @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 2fc7a14..68b0eb0 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 @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import java.math.BigDecimal data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) @@ -44,3 +45,30 @@ data class GoogleChargeRequest( val purchaseToken: String, val paymentGateway: PaymentGateway ) + +data class PayverseChargeRequest( + val canId: Long +) + +data class PayverseChargeResponse( + val chargeId: Long, + val payloadJson: String +) + +data class PayverseVerifyRequest( + val transactionId: String, + val orderId: String +) + +data class PayverseVerifyResponse( + val resultStatus: String, + val tid: String, + val schemeCode: String, + val transactionType: String, + val transactionStatus: String, + val transactionMessage: String, + val orderId: String, + val customerId: String, + val processingCurrency: String, + val processingAmount: BigDecimal +) 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 dfc7a4b..c71028e 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 @@ -22,6 +22,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.apache.commons.codec.digest.DigestUtils import org.json.JSONObject import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -34,6 +35,8 @@ import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.math.RoundingMode import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID @Service @Transactional(readOnly = true) @@ -63,7 +66,19 @@ class ChargeService( @Value("\${apple.iap-verify-sandbox-url}") private val appleInAppVerifySandBoxUrl: String, @Value("\${apple.iap-verify-url}") - private val appleInAppVerifyUrl: String + private val appleInAppVerifyUrl: String, + + @Value("\${payverse.mid}") + private val payverseMid: String, + @Value("\${payverse.client-key}") + private val payverseClientKey: String, + @Value("\${payverse.secret-key}") + private val payverseSecretKey: String, + @Value("\${payverse.host}") + private val payverseHost: String, + + @Value("\${server.env}") + private val serverEnv: String ) { @Transactional @@ -126,6 +141,129 @@ class ChargeService( } } + @Transactional + fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse { + val can = canRepository.findByIdOrNull(request.canId) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val charge = Charge(can.can, can.rewardCan) + charge.title = can.title + charge.member = member + charge.can = can + + val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE) + payment.price = can.price.toDouble() + charge.payment = payment + + val savedCharge = chargeRepository.save(charge) + + val chargeId = savedCharge.id!! + val amount = BigDecimal(savedCharge.payment!!.price) + val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + val sign = DigestUtils.sha512Hex( + String.format("||%s||%s||%s||%s||%s||", payverseSecretKey, payverseMid, chargeId, amount, reqDate) + ) + val customerId = UUID.nameUUIDFromBytes("${serverEnv}_user_${member.id!!}".toByteArray()).toString() + val requestCurrency = "KRW" + + val payload = linkedMapOf( + "mid" to payverseMid, + "clientKey" to payverseClientKey, + "orderId" to chargeId.toString(), + "customerId" to customerId, + "productName" to can.title, + "requestCurrency" to requestCurrency, + "requestAmount" to amount.toString(), + "reqDate" to reqDate, + "billkeyReq" to "N", + "mallReserved" to "", + "sign" to sign + ) + val payloadJson = objectMapper.writeValueAsString(payload) + + return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson) + } + + @Transactional + fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { + val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + ?: throw SodaException("결제정보에 오류가 있습니다.") + val member = memberRepository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + // 결제수단 확인 + if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { + throw SodaException("결제정보에 오류가 있습니다.") + } + + // 결제 상태에 따른 분기 처리 + when (charge.payment?.status) { + PaymentStatus.REQUEST -> { + try { + val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}" + val request = Request.Builder() + .url(url) + .addHeader("mid", payverseMid) + .addHeader("clientKey", payverseClientKey) + .get() + .build() + + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) { + throw SodaException("결제정보에 오류가 있습니다.") + } + + val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.") + val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java) + + val isSuccess = verifyResponse.resultStatus == "SUCCESS" && + verifyResponse.transactionStatus == "SUCCESS" && + verifyResponse.orderId.toLongOrNull() == charge.id && + verifyResponse.processingAmount.compareTo(BigDecimal.valueOf(charge.can!!.price.toLong())) == 0 + + if (isSuccess) { + // verify 함수의 232~248 라인과 동일 처리 + charge.payment?.receiptId = verifyResponse.tid + charge.payment?.method = verifyResponse.schemeCode + charge.payment?.status = PaymentStatus.COMPLETE + // 통화코드 설정 + charge.payment?.locale = verifyResponse.processingCurrency + + member.charge(charge.chargeCan, charge.rewardCan, "pg") + + applicationEventPublisher.publishEvent( + ChargeSpringEvent( + chargeId = charge.id!!, + memberId = member.id!! + ) + ) + + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } catch (_: Exception) { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + PaymentStatus.COMPLETE -> { + // 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환 + return ChargeCompleteResponse( + price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), + currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", + isFirstCharged = chargeRepository.isFirstCharged(memberId) + ) + } + else -> { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + } + @Transactional fun charge(member: Member, request: ChargeRequest): ChargeResponse { val can = canRepository.findByIdOrNull(request.canId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt index a37459d..b5b414a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt @@ -1,5 +1,5 @@ package kr.co.vividnext.sodalive.can.payment enum class PaymentGateway { - PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD + PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa37c6d..1851faf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,6 +13,12 @@ weraser: apiUrl: ${WERASER_API_URL} apiKey: ${WERASER_API_KEY} +payverse: + mid: ${PAYVERSE_MID} + clientKey: ${PAYVERSE_CLIENT_KEY} + secretKey: ${PAYVERSE_SECRET_KEY} + host: ${PAYVERSE_HOST} + bootpay: applicationId: ${BOOTPAY_APPLICATION_ID} privateKey: ${BOOTPAY_PRIVATE_KEY}