Compare commits
2 Commits
59ca353b25
...
03149a637d
| Author | SHA1 | Date | |
|---|---|---|---|
| 03149a637d | |||
| bc6c05b3ea |
@@ -6,20 +6,71 @@ import kr.co.vividnext.sodalive.common.SodaException
|
|||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
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.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/charge")
|
@RequestMapping("/charge")
|
||||||
class ChargeController(
|
class ChargeController(
|
||||||
private val service: ChargeService,
|
private val service: ChargeService,
|
||||||
private val trackingService: AdTrackingService
|
private val trackingService: AdTrackingService,
|
||||||
|
|
||||||
|
@Value("\${payverse.inbound-ip}")
|
||||||
|
private val payverseInboundIp: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
@PostMapping
|
||||||
fun charge(
|
fun charge(
|
||||||
@RequestBody chargeRequest: ChargeRequest,
|
@RequestBody chargeRequest: ChargeRequest,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
||||||
|
|
||||||
@@ -44,3 +45,46 @@ data class GoogleChargeRequest(
|
|||||||
val purchaseToken: String,
|
val purchaseToken: String,
|
||||||
val paymentGateway: PaymentGateway
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.apache.commons.codec.digest.DigestUtils
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -34,6 +35,8 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -63,9 +66,88 @@ class ChargeService(
|
|||||||
@Value("\${apple.iap-verify-sandbox-url}")
|
@Value("\${apple.iap-verify-sandbox-url}")
|
||||||
private val appleInAppVerifySandBoxUrl: String,
|
private val appleInAppVerifySandBoxUrl: String,
|
||||||
@Value("\${apple.iap-verify-url}")
|
@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
|
||||||
|
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
|
@Transactional
|
||||||
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
||||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||||
@@ -126,6 +208,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
|
@Transactional
|
||||||
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
||||||
val can = canRepository.findByIdOrNull(request.canId)
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.can.payment
|
package kr.co.vividnext.sodalive.can.payment
|
||||||
|
|
||||||
enum class PaymentGateway {
|
enum class PaymentGateway {
|
||||||
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ weraser:
|
|||||||
apiUrl: ${WERASER_API_URL}
|
apiUrl: ${WERASER_API_URL}
|
||||||
apiKey: ${WERASER_API_KEY}
|
apiKey: ${WERASER_API_KEY}
|
||||||
|
|
||||||
|
payverse:
|
||||||
|
mid: ${PAYVERSE_MID}
|
||||||
|
clientKey: ${PAYVERSE_CLIENT_KEY}
|
||||||
|
secretKey: ${PAYVERSE_SECRET_KEY}
|
||||||
|
host: ${PAYVERSE_HOST}
|
||||||
|
inboundIp: ${PAYVERSE_INBOUND_IP}
|
||||||
|
|
||||||
bootpay:
|
bootpay:
|
||||||
applicationId: ${BOOTPAY_APPLICATION_ID}
|
applicationId: ${BOOTPAY_APPLICATION_ID}
|
||||||
privateKey: ${BOOTPAY_PRIVATE_KEY}
|
privateKey: ${BOOTPAY_PRIVATE_KEY}
|
||||||
|
|||||||
Reference in New Issue
Block a user