Android App 如何获取在线支付网关的支付状态
结论
不要让 Android 识别银行网页里的 CONFIRM 按钮,也不要根据 WebView 的页面内容判断支付是否成功。
支付状态应由支付网关通过以下方式返回:
- 服务端异步通知(Webhook/Callback):支付网关通知商户服务器。这是判断最终支付结果的主要依据。
- 支付完成跳转(Return URL):WebView 跳转到约定地址后,App 可以及时知道网页流程已经返回。
- 查询商户服务器的订单状态:App 可以定时轮询自己的服务器,也可以由服务器通过 WebSocket、推送等方式通知 App。
较稳妥的组合是:用 Webhook 确认结果,用 Return URL 加快响应,再用服务端查询处理通知延迟或丢失等情况。只有商户服务器确认订单已经支付,App 才能停止倒计时并显示支付成功。
为什么不能直接监听银行页面
银行或第三方支付页面通常不受 App 控制,Android 无法可靠地从页面按钮判断业务结果,原因包括:
- 点击
CONFIRM不代表支付成功,后面可能还有短信验证、3-D Secure、风控审核或银行处理。 - 网页结构、按钮 ID 和 JavaScript 随时可能改变。
- WebView 的同源策略会限制对跨域页面的访问。
- 如果网络中断,银行可能已经扣款,但 WebView 没有收到成功页面。
- 客户端页面和回调地址可能被伪造,不能作为到账依据。
因此,WebView 返回只能说明用户完成了网页流程,或者页面跳转到了某个地址,不能单独证明付款已经完成。
推荐的整体流程
可以按以下流程处理:
- App 向商户服务器创建订单。
- 商户服务器调用支付网关,获取支付地址和支付网关订单号。
- App 使用 WebView 或系统浏览器打开支付地址。
- 用户在银行页面完成支付。
- 支付网关向商户服务器发送异步通知。
- 商户服务器验证通知中的签名、金额、币种和订单号,然后更新订单状态。
- App 查询自己的服务器,获取
PAID、FAILED、PENDING等状态。 - 收到
PAID后,App 停止倒计时,关闭支付页面并显示结果。
即使支付网关支持同步跳转,也要保留异步通知和主动查询。用户可能在跳转前关闭页面,网络也可能在跳转过程中断开。
在 WebView 中拦截返回地址
如果支付网关允许配置 returnUrl,可以将它设置为 HTTPS App Link,例如:
https://pay.example.com/payment/return
如果网关明确支持,也可以使用自定义 Scheme:
myapp://payment/result
WebView 可以通过 WebViewClient.shouldOverrideUrlLoading() 检测导航地址:
class PaymentWebViewClient(
private val onPaymentReturned: (orderId: String) -> Unit
) : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val uri = request.url
val isReturnUrl =
uri.scheme == "https" &&
uri.host == "pay.example.com" &&
uri.path == "/payment/return"
if (!isReturnUrl) {
return false
}
val orderId = uri.getQueryParameter("order_id")
if (!orderId.isNullOrBlank()) {
onPaymentReturned(orderId)
}
return true
}
}
WebView 的配置如下:
webView.settings.javaScriptEnabled = true
webView.webViewClient = PaymentWebViewClient { orderId ->
viewModel.refreshPaymentStatus(orderId)
}
webView.loadUrl(paymentUrl)
onPaymentReturned() 不应直接显示“支付成功”,而应发起一次服务端状态查询。
部分支付页面会使用 POST、iframe、新窗口或外部银行 App,shouldOverrideUrlLoading() 不一定能覆盖所有情况。它适合捕获已知的返回 URL,但不能取代服务端通知。
通过服务器查询支付状态
App 应查询商户自己的接口,不要在客户端携带支付密钥直接请求支付网关:
enum class PaymentStatus {
PENDING,
PAID,
FAILED,
EXPIRED,
CANCELLED
}
data class PaymentStatusResponse(
val orderId: String,
val status: PaymentStatus
)
interface PaymentApi {
@GET("api/payments/{orderId}/status")
suspend fun getPaymentStatus(
@Path("orderId") orderId: String
): PaymentStatusResponse
}
可以在 ViewModel 中轮询订单状态:
class PaymentViewModel(
private val paymentApi: PaymentApi
) : ViewModel() {
private var pollingJob: Job? = null
private val _status = MutableStateFlow(PaymentStatus.PENDING)
val status: StateFlow<PaymentStatus> = _status.asStateFlow()
fun startPolling(orderId: String) {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
while (isActive) {
try {
val result = paymentApi.getPaymentStatus(orderId)
_status.value = result.status
if (result.status != PaymentStatus.PENDING) {
break
}
} catch (e: IOException) {
// 网络暂时不可用时保持 PENDING,稍后重试。
}
delay(2_000)
}
}
}
fun refreshPaymentStatus(orderId: String) {
viewModelScope.launch {
runCatching {
paymentApi.getPaymentStatus(orderId)
}.onSuccess { result ->
_status.value = result.status
if (result.status != PaymentStatus.PENDING) {
pollingJob?.cancel()
}
}
}
}
override fun onCleared() {
pollingJob?.cancel()
}
}
轮询间隔和最长持续时间应根据支付网关限制、业务时效及服务器负载确定,不能高频、无限地查询。生产环境还应对异常采用退避策略。
收到成功状态后停止倒计时
如果使用 CountDownTimer,应保留计时器实例,并在服务端确认支付成功后调用 cancel():
private var paymentTimer: CountDownTimer? = null
private fun startPaymentTimer(expireAtMillis: Long) {
val remaining = (expireAtMillis - System.currentTimeMillis())
.coerceAtLeast(0L)
paymentTimer?.cancel()
paymentTimer = object : CountDownTimer(remaining, 1_000L) {
override fun onTick(millisUntilFinished: Long) {
updateCountdown(millisUntilFinished)
}
override fun onFinish() {
viewModel.refreshPaymentStatus(orderId)
}
}.start()
}
private fun observePaymentStatus() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.status.collect { status ->
when (status) {
PaymentStatus.PAID -> {
paymentTimer?.cancel()
showPaymentSuccess()
}
PaymentStatus.FAILED,
PaymentStatus.CANCELLED -> {
paymentTimer?.cancel()
showPaymentFailure(status)
}
PaymentStatus.EXPIRED -> {
paymentTimer?.cancel()
showPaymentExpired()
}
PaymentStatus.PENDING -> Unit
}
}
}
}
}
倒计时结束时,不要马上判定支付失败。用户可能在最后一秒完成扣款,而异步通知还没有送达。此时应先查询一次服务端状态:
- 已支付:显示成功。
- 确认未支付且订单已过期:显示过期。
- 状态仍不明确:显示“支付结果确认中”,并继续执行有限次数的补偿查询。
服务端还应保存绝对过期时间,例如 expireAt。不能只依赖客户端倒计时,因为进程退出、系统休眠或用户修改设备时间都可能影响计时。
服务端异步通知的处理要点
不同支付网关使用的通知参数和验签算法并不相同,应严格按照对应网关的官方 SDK 或文档处理。通用流程如下:
接收支付网关通知
↓
验证签名或证书
↓
校验商户号、订单号、金额和币种
↓
查询本地订单
↓
以幂等方式更新订单状态
↓
返回网关要求的确认响应
服务端至少需要做到以下几点:
- 不信任客户端上报的“支付成功”。
- 验证通知签名,必要时主动调用网关查询接口。
- 核对订单号、金额、币种和商户身份。
- 防止同一通知被重复处理。
- 使用事务或条件更新,保证状态转换正确。
- 保存网关交易号和原始通知记录,以便对账。
- 支付密钥、证书和签名私钥只保存在服务器端。
JavaScript 接口是否可用
如果返回页由商户控制,可以通过 JavaScript Bridge 通知 Android:
class PaymentJsBridge(
private val onReturn: (String) -> Unit
) {
@JavascriptInterface
fun paymentReturned(orderId: String) {
onReturn(orderId)
}
}
webView.addJavascriptInterface(
PaymentJsBridge { orderId ->
viewModel.refreshPaymentStatus(orderId)
},
"AndroidPayment"
)
网页调用:
window.AndroidPayment.paymentReturned(orderId);
这种接口只能开放给可信且可控的页面。不要向任意第三方银行页面注入 JavaScript 接口,否则会增加安全风险。即使 App 收到了 JavaScript 消息,也必须向服务器确认订单状态。
其他注意事项
- 如果支付网关提供官方 Android SDK,应优先使用 SDK,不要自行解析网页。
- 如果没有必须内嵌页面的要求,可以考虑 Custom Tabs 或系统浏览器。它们通常比 WebView 更安全,也更适合银行登录和外部 App 跳转。
- 使用 HTTPS,并完整匹配返回地址的
scheme、host和path,不要只检查 URL 是否包含某个字符串。 - 不要根据页面标题、HTML 文本或按钮点击事件判断支付成功。
- 正确处理应用进入后台、进程被终止、页面重建以及用户重新进入订单页等情况。
- 订单状态应保存在服务端。App 恢复后重新查询,就能恢复支付流程。
- “页面过期”和“支付失败”是两个不同的状态。订单过期后仍可能收到延迟通知,服务端需要按照支付网关的规则处理。
核心原则是:Android 负责展示支付流程并获取服务端订单状态,最终支付结果由支付网关和商户服务器共同确定。
备注:内容仅供参考。
