From 662f18bceb6515fa92acd974957373bab49d26d9 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 1 Oct 2025 01:47:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(can-charge):=20=EC=9D=B4=EB=A1=AC=EB=84=B7?= =?UTF-8?q?(Payverse)=20=ED=86=B5=ED=95=A9=EA=B2=B0=EC=A0=9C=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 --- app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 13 +- app/src/main/assets/payverse_starter.html | 25 +++ .../main/assets/payverse_starter_debug.html | 25 +++ .../sodalive/main/DeepLinkActivity.kt | 22 ++- .../vividnext/sodalive/mypage/can/CanApi.kt | 15 ++ .../sodalive/mypage/can/CanRepository.kt | 12 ++ .../mypage/can/payment/CanPaymentActivity.kt | 154 ++++++++++++++++-- .../can/payment/CanPaymentTempActivity.kt | 11 +- .../mypage/can/payment/CanPaymentViewModel.kt | 63 +++++++ .../can/payment/payverse/PayverseChargeDto.kt | 20 +++ .../main/res/layout/activity_can_payment.xml | 68 ++++---- 12 files changed, 366 insertions(+), 64 deletions(-) create mode 100644 app/src/main/assets/payverse_starter.html create mode 100644 app/src/main/assets/payverse_starter_debug.html create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/payverse/PayverseChargeDto.kt diff --git a/app/build.gradle b/app/build.gradle index 863cb6e9..87559580 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,7 @@ android { buildConfigField 'String', 'NOTIFLY_PASSWORD', '"c6c585db0aaa4189be44d0467c7d66b6@A"' buildConfigField 'String', 'KAKAO_APP_KEY', '"231cf78acfa8252fca38b9eedf87c5cb"' buildConfigField 'String', 'GOOGLE_CLIENT_ID', '"983594297130-5hrmkh6vpskeq6v34350kmilf74574h2.apps.googleusercontent.com"' + buildConfigField 'String', 'APPSCHEME', '"voiceon"' manifestPlaceholders = [ URISCHEME : "voiceon", APPLINK_HOST : "voiceon.onelink.me", @@ -79,6 +80,7 @@ android { buildConfigField 'String', 'NOTIFLY_PASSWORD', '"c6c585db0aaa4189be44d0467c7d66b6@A"' buildConfigField 'String', 'KAKAO_APP_KEY', '"20cf19413d63bfdfd30e8e6dff933d33"' buildConfigField 'String', 'GOOGLE_CLIENT_ID', '"758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.googleusercontent.com"' + buildConfigField 'String', 'APPSCHEME', '"voiceon-test"' manifestPlaceholders = [ URISCHEME : "voiceon-test", APPLINK_HOST : "voiceon-test.onelink.me", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a83761bc..e18f1a14 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,6 +85,15 @@ + + + + + + + - + diff --git a/app/src/main/assets/payverse_starter.html b/app/src/main/assets/payverse_starter.html new file mode 100644 index 00000000..59c1ed69 --- /dev/null +++ b/app/src/main/assets/payverse_starter.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/app/src/main/assets/payverse_starter_debug.html b/app/src/main/assets/payverse_starter_debug.html new file mode 100644 index 00000000..16af628d --- /dev/null +++ b/app/src/main/assets/payverse_starter_debug.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt index a364310b..c9b9ea90 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt @@ -1,19 +1,39 @@ package kr.co.vividnext.sodalive.main import android.content.Intent +import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper import androidx.appcompat.app.AppCompatActivity +import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity import kr.co.vividnext.sodalive.splash.SplashActivity class DeepLinkActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val data: Uri? = intent?.data + if (data != null && data.scheme != null) { + val host = data.host + val path = data.path + // Payverse 결제 완료 딥링크라면 결제 화면으로 직접 전달 + if (host == "payverse" && path == "/result") { + val paymentIntent = Intent(this, CanPaymentActivity::class.java).apply { + action = Intent.ACTION_VIEW + this.data = data + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + startActivity(paymentIntent) + finish() + return + } + } + + // 그 외 일반 딥링크는 기존처럼 Splash로 위임 startActivity( Intent(applicationContext, SplashActivity::class.java).apply { - data = intent.data + setData(intent.data) } ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanApi.kt index 15f395f7..356217c1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanApi.kt @@ -8,6 +8,9 @@ import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeRequest import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeResponse import kr.co.vividnext.sodalive.mypage.can.charge.pg.VerifyRequest import kr.co.vividnext.sodalive.mypage.can.coupon.UseCanCouponRequest +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseChargeRequest +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseChargeResponse +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseVerifyRequest import kr.co.vividnext.sodalive.mypage.can.status.GetCanStatusResponse import kr.co.vividnext.sodalive.mypage.can.status.charge.GetCanChargeStatusResponseItem import kr.co.vividnext.sodalive.mypage.can.status.use.GetCanUseStatusResponseItem @@ -42,6 +45,18 @@ interface CanApi { @Header("Authorization") authHeader: String ): Single> + @POST("/charge/payverse") + fun payverseChargeCan( + @Body request: PayverseChargeRequest, + @Header("Authorization") authHeader: String + ): Single> + + @POST("/charge/payverse/verify") + fun payverseVerifyCharge( + @Body request: PayverseVerifyRequest, + @Header("Authorization") authHeader: String + ): Single> + @GET("/can") fun getCans( @Header("Authorization") authHeader: String diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanRepository.kt index 3bd5e2fb..31ee1e1f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/CanRepository.kt @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.mypage.can.charge.iap.GoogleChargeRequest import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeRequest import kr.co.vividnext.sodalive.mypage.can.charge.pg.VerifyRequest import kr.co.vividnext.sodalive.mypage.can.coupon.UseCanCouponRequest +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseChargeRequest +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseVerifyRequest import java.util.TimeZone class CanRepository(private val api: CanApi) { @@ -57,4 +59,14 @@ class CanRepository(private val api: CanApi) { request = UseCanCouponRequest(couponNumber), authHeader = token ) + + fun payverseChargeCan(canId: Long, token: String) = api.payverseChargeCan( + request = PayverseChargeRequest(canId), + authHeader = token + ) + + fun payverseVerify(transactionId: String, orderId: String, token: String) = api.payverseVerifyCharge( + request = PayverseVerifyRequest(transactionId = transactionId, orderId = orderId), + authHeader = token + ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentActivity.kt index b49c0611..4ba01c65 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentActivity.kt @@ -2,19 +2,26 @@ package kr.co.vividnext.sodalive.mypage.can.payment import android.annotation.SuppressLint import android.content.Intent +import android.net.Uri import android.os.Handler import android.os.Looper +import android.view.View +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.content.res.ResourcesCompat +import androidx.core.net.toUri import com.google.gson.Gson import com.orhanobut.logger.Logger import kr.co.bootpay.android.Bootpay import kr.co.bootpay.android.events.BootpayEventListener import kr.co.bootpay.android.models.BootUser import kr.co.bootpay.android.models.Payload +import kr.co.bootpay.android.webview.BootpayUrlHelper import kr.co.vividnext.sodalive.BuildConfig import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseActivity @@ -27,14 +34,14 @@ import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.can.charge.pg.CanResponse import kr.co.vividnext.sodalive.mypage.can.charge.pg.VerifyRequest import kr.co.vividnext.sodalive.mypage.can.status.CanStatusActivity +import org.json.JSONObject import org.koin.android.ext.android.inject class CanPaymentActivity : BaseActivity( ActivityCanPaymentBinding::inflate ) { enum class PaymentMethod(val method: String) { - CARD("카드"), - BANK("계좌이체"), + UNIFIED("통합 결제"), PHONE("휴대폰"), KAKAOPAY("카카오페이") } @@ -106,8 +113,7 @@ class CanPaymentActivity : BaseActivity( requestCharge() } - binding.tvMethodCard.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.CARD) } - binding.tvMethodBank.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.BANK) } + binding.tvMethodUnified.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.UNIFIED) } binding.tvMethodPhone.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.PHONE) } binding.flMethodKakaopay.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.KAKAOPAY) @@ -118,8 +124,7 @@ class CanPaymentActivity : BaseActivity( if (it != null) { when (it) { - PaymentMethod.CARD -> paymentMethodSelect(binding.tvMethodCard) - PaymentMethod.BANK -> paymentMethodSelect(binding.tvMethodBank) + PaymentMethod.UNIFIED -> paymentMethodSelect(binding.tvMethodUnified) PaymentMethod.PHONE -> paymentMethodSelect(binding.tvMethodPhone) PaymentMethod.KAKAOPAY -> { isKakao = true @@ -133,8 +138,7 @@ class CanPaymentActivity : BaseActivity( private fun allPaymentMethodSelectFalse() { isKakao = false - paymentMethodSelectFalse(binding.tvMethodBank) - paymentMethodSelectFalse(binding.tvMethodCard) + paymentMethodSelectFalse(binding.tvMethodUnified) paymentMethodSelectFalse(binding.tvMethodPhone) binding.flMethodKakaopay .setBackgroundResource(R.drawable.bg_round_corner_10_232323_777777) @@ -161,16 +165,37 @@ class CanPaymentActivity : BaseActivity( } private fun requestCharge() { - viewModel.chargeCan( - canId = canResponse!!.id, - paymentGateway = PaymentGateway.PG, - onSuccess = { - requestPayment(chargeId = it) - }, - onFailure = { - Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() + when (viewModel.paymentMethodLiveData.value) { + PaymentMethod.UNIFIED -> { + viewModel.payverseChargeCan( + canId = canResponse!!.id, + onSuccess = { response -> + // Payverse payloadJson을 이용하여 WebView 결제 시작 + startPayverse(response.payloadJson) + }, + onFailure = { + Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() + } + ) } - ) + + PaymentMethod.KAKAOPAY, PaymentMethod.PHONE -> { + viewModel.chargeCan( + canId = canResponse!!.id, + paymentGateway = PaymentGateway.PG, + onSuccess = { + requestPayment(chargeId = it) + }, + onFailure = { + Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() + } + ) + } + + else -> { + Toast.makeText(applicationContext, "결제수단을 다시 선택해 주세요.", Toast.LENGTH_LONG).show() + } + } } private fun requestPayment(chargeId: Long) { @@ -259,4 +284,99 @@ class CanPaymentActivity : BaseActivity( } ) } + + @SuppressLint("SetJavaScriptEnabled") + private fun startPayverse(payloadJson: String) { + try { + val appScheme = BuildConfig.APPSCHEME + val payload = JSONObject(payloadJson) + payload.put("returnUrl", "$appScheme://payverse/result") + payload.put("webhookUrl", "${BuildConfig.BASE_URL}/charge/payverse/webhook") + payload.put("appScheme", appScheme) + + val jsonForJs = payload.toString() + + val webView: WebView = binding.webviewPayverse + webView.visibility = View.VISIBLE + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + val escaped = JSONObject.quote(jsonForJs) + view.evaluateJavascript("startPay($escaped)", null) + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val url = request.url.toString() + if (url.startsWith("$appScheme://")) { + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + return true + } + return BootpayUrlHelper.shouldOverrideUrlLoading(view, url) + } + } + + if (BuildConfig.DEBUG && appScheme.contains("test", ignoreCase = true)) { + webView.loadUrl("file:///android_asset/payverse_starter_debug.html") + } else { + webView.loadUrl("file:///android_asset/payverse_starter.html") + } + } catch (e: Exception) { + Logger.e(e.message ?: "payverse start error") + Toast.makeText(applicationContext, "결제 초기화에 실패했습니다.", Toast.LENGTH_LONG).show() + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + intent?.data?.let { handlePayverseDeeplink(it) } + } + + private fun handlePayverseDeeplink(uri: Uri) { + val resultStatus = uri.getQueryParameter("resultStatus") + val transactionId = uri.getQueryParameter("tid") + val orderId = uri.getQueryParameter("orderId") + if ( + resultStatus == "FAILED" || + resultStatus == "DECLINE" || + transactionId.isNullOrEmpty() || + orderId.isNullOrEmpty() + ) { + Toast.makeText( + applicationContext, + "결제를 하지 못했습니다.\n다시 시도해 주세요", + Toast.LENGTH_LONG + ).show() + + binding.webviewPayverse.visibility = View.GONE + finish() + return + } + + viewModel.payverseVerify( + transactionId = transactionId, + orderId = orderId, + onSuccess = { + completePaymentSuccess() + }, + onFailure = { + Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() + } + ) + } + + private fun completePaymentSuccess() { + Toast.makeText(applicationContext, "캔이 충전되었습니다", Toast.LENGTH_LONG).show() + SharedPreferenceManager.can += (canResponse!!.rewardCan + canResponse!!.can) + if (gotoPrevPage) { + setResult(RESULT_OK) + } else { + val intent = Intent(applicationContext, CanStatusActivity::class.java) + startActivity(intent) + } + finish() + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentTempActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentTempActivity.kt index f9b9a692..14666708 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentTempActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentTempActivity.kt @@ -31,7 +31,7 @@ class CanPaymentTempActivity : BaseActivity( ActivityCanPaymentBinding::inflate ) { enum class PaymentMethod(val method: String) { - CARD("카드"), BANK("계좌이체"), PHONE("휴대폰") + UNIFIED("통합 결제"), PHONE("휴대폰") } private val viewModel: CanPaymentTempViewModel by inject() @@ -101,14 +101,12 @@ class CanPaymentTempActivity : BaseActivity( requestCharge() } - binding.tvMethodCard.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.CARD) } - binding.tvMethodBank.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.BANK) } + binding.tvMethodUnified.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.UNIFIED) } binding.tvMethodPhone.setOnClickListener { viewModel.setPaymentMethod(PaymentMethod.PHONE) } } private fun allPaymentMethodSelectFalse() { - paymentMethodSelectFalse(binding.tvMethodBank) - paymentMethodSelectFalse(binding.tvMethodCard) + paymentMethodSelectFalse(binding.tvMethodUnified) paymentMethodSelectFalse(binding.tvMethodPhone) } @@ -219,8 +217,7 @@ class CanPaymentTempActivity : BaseActivity( if (it != null) { when (it) { - PaymentMethod.CARD -> paymentMethodSelect(binding.tvMethodCard) - PaymentMethod.BANK -> paymentMethodSelect(binding.tvMethodBank) + PaymentMethod.UNIFIED -> paymentMethodSelect(binding.tvMethodUnified) PaymentMethod.PHONE -> paymentMethodSelect(binding.tvMethodPhone) } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentViewModel.kt index 3ae7e809..ccecd2e8 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/CanPaymentViewModel.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.mypage.can.CanRepository import kr.co.vividnext.sodalive.mypage.can.charge.pg.ChargeRequest import kr.co.vividnext.sodalive.mypage.can.charge.pg.VerifyRequest +import kr.co.vividnext.sodalive.mypage.can.payment.payverse.PayverseChargeResponse class CanPaymentViewModel(private val repository: CanRepository) : BaseViewModel() { @@ -120,4 +121,66 @@ class CanPaymentViewModel(private val repository: CanRepository) : BaseViewModel fun setPaymentMethod(paymentMethod: CanPaymentActivity.PaymentMethod) { _paymentMethodLiveData.value = paymentMethod } + + fun payverseChargeCan( + canId: Long, + onSuccess: (PayverseChargeResponse) -> Unit, + onFailure: (String) -> Unit + ) { + _isLoading.value = true + compositeDisposable.add( + repository.payverseChargeCan( + canId = canId, + token = "Bearer ${SharedPreferenceManager.token}" + ).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success && it.data != null) { + onSuccess(it.data) + } else { + onFailure(it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + }, + { + _isLoading.value = false + it.message?.let { m -> Logger.e(m) } + onFailure("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } + + fun payverseVerify( + transactionId: String, + orderId: String, + onSuccess: () -> Unit, + onFailure: (String) -> Unit + ) { + _isLoading.value = true + compositeDisposable.add( + repository.payverseVerify( + transactionId = transactionId, + orderId = orderId, + token = "Bearer ${SharedPreferenceManager.token}" + ).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + _isLoading.value = false + if (it.success) { + onSuccess() + } else { + onFailure(it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + }, + { + _isLoading.value = false + it.message?.let { m -> Logger.e(m) } + onFailure("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + } + ) + ) + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/payverse/PayverseChargeDto.kt b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/payverse/PayverseChargeDto.kt new file mode 100644 index 00000000..a696d57f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/mypage/can/payment/payverse/PayverseChargeDto.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.mypage.can.payment.payverse + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +@Keep +data class PayverseChargeRequest( + @SerializedName("canId") val canId: Long +) + +@Keep +data class PayverseChargeResponse( + @SerializedName("chargeId") val chargeId: Long, + @SerializedName("payloadJson") val payloadJson: String +) + +data class PayverseVerifyRequest( + @SerializedName("transactionId") val transactionId: String, + @SerializedName("orderId") val orderId: String +) diff --git a/app/src/main/res/layout/activity_can_payment.xml b/app/src/main/res/layout/activity_can_payment.xml index 4fa54a54..f38e861a 100644 --- a/app/src/main/res/layout/activity_can_payment.xml +++ b/app/src/main/res/layout/activity_can_payment.xml @@ -103,10 +103,11 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="13.3dp" - android:layout_marginTop="16.7dp"> + android:layout_marginTop="16.7dp" + android:orientation="horizontal"> - - + android:paddingVertical="8.3dp"> - + + - - - - - + +