PAYATHON 2026

Android App 如何获取在线支付网关的支付状态

支付老李

结论

不要让 Android 识别银行网页里的 CONFIRM 按钮,也不要根据 WebView 的页面内容判断支付是否成功。

支付状态应由支付网关通过以下方式返回:

  1. 服务端异步通知(Webhook/Callback):支付网关通知商户服务器。这是判断最终支付结果的主要依据。
  2. 支付完成跳转(Return URL):WebView 跳转到约定地址后,App 可以及时知道网页流程已经返回。
  3. 查询商户服务器的订单状态:App 可以定时轮询自己的服务器,也可以由服务器通过 WebSocket、推送等方式通知 App。

较稳妥的组合是:用 Webhook 确认结果,用 Return URL 加快响应,再用服务端查询处理通知延迟或丢失等情况。只有商户服务器确认订单已经支付,App 才能停止倒计时并显示支付成功。

为什么不能直接监听银行页面

银行或第三方支付页面通常不受 App 控制,Android 无法可靠地从页面按钮判断业务结果,原因包括:

  • 点击 CONFIRM 不代表支付成功,后面可能还有短信验证、3-D Secure、风控审核或银行处理。
  • 网页结构、按钮 ID 和 JavaScript 随时可能改变。
  • WebView 的同源策略会限制对跨域页面的访问。
  • 如果网络中断,银行可能已经扣款,但 WebView 没有收到成功页面。
  • 客户端页面和回调地址可能被伪造,不能作为到账依据。

因此,WebView 返回只能说明用户完成了网页流程,或者页面跳转到了某个地址,不能单独证明付款已经完成。

推荐的整体流程

可以按以下流程处理:

  1. App 向商户服务器创建订单。
  2. 商户服务器调用支付网关,获取支付地址和支付网关订单号。
  3. App 使用 WebView 或系统浏览器打开支付地址。
  4. 用户在银行页面完成支付。
  5. 支付网关向商户服务器发送异步通知。
  6. 商户服务器验证通知中的签名、金额、币种和订单号,然后更新订单状态。
  7. App 查询自己的服务器,获取 PAID、FAILED、PENDING 等状态。
  8. 收到 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 负责展示支付流程并获取服务端订单状态,最终支付结果由支付网关和商户服务器共同确定。

备注:内容仅供参考。