PAYATHON 2026

用户关闭支付浏览器后,Android app 应如何处理 pending 状态?

支付阿杰

结论

Android 无法可靠判断处于 pending 状态的支付究竟属于哪种情况:

  • 用户已经提交付款,支付机构仍在异步处理;
  • 用户没有进行付款,直接关闭了 Chrome Custom Tab。

即使检测到 Custom Tab 已隐藏,或宿主 Activity 再次执行 onResume(),也只能说明用户回到了 app,不能证明支付已经取消。进程被杀、切换应用、浏览器崩溃和网络延迟都可能产生类似现象。

因此,不应由客户端推测用户是否放弃支付。更稳妥的处理方式是:

  1. 由服务端保存支付尝试,并以支付机构的 webhook 或异步通知为最终依据。
  2. 将 pending 视为正常的中间状态,而不是错误或取消。
  3. 使用幂等键、订单唯一约束和重复付款匹配规则,防止产生重复订单。
  4. 允许用户离开等待页面,同时继续跟踪原支付。用户再次提交前,先检查是否有未决支付。
  5. 必要时将当前接口中的 pending 拆成更准确的业务状态,例如 CREATED、PROCESSING、SUCCEEDED、FAILED、CANCELLED 和 EXPIRED。

为什么检测浏览器关闭解决不了问题

Chrome Custom Tabs 由浏览器进程管理。app 可以通过 Activity 生命周期或 Custom Tabs 回调发现标签页已隐藏,但这些信号并不等于用户放弃了支付。

例如,下面的代码最多只能触发一次状态刷新:

override fun onResume() {
    super.onResume()

    if (paymentUiWasOpened) {
        refreshPaymentStatus()
    }
}

权限页面关闭、应用切换、屏幕解锁或配置变化也可能触发 onResume(),因此不能据此将支付标记为 cancelled。

部分浏览器和 AndroidX Browser 的某些组合可能提供 TAB_HIDDEN 一类的导航事件,但这些事件仍有明显限制:

  • 不同浏览器的实现可能不一致;
  • app 进程终止后,回调可能无法送达;
  • 标签页已隐藏不代表用户没有提交支付;
  • 即使准确知道标签页何时关闭,业务上仍然无法区分场景 3 和场景 4。

支付网页的 deep link 只能用于改善跳转体验,不能作为最终支付凭证。用户可能在重定向前关闭页面,deep link 也可能重复到达或完全丢失。

推荐的支付状态模型

目前的三个状态不足以表达完整的支付过程。服务端最好分别记录支付会话是否已经创建、支付机构是否已经受理,以及支付是否最终结束:

CREATED       已创建支付尝试,尚不确定用户是否提交
PROCESSING    支付机构确认正在处理
SUCCEEDED     支付最终成功
FAILED        支付最终失败
CANCELLED     支付机构或服务端确认取消
EXPIRED       超过有效期且不能再成功

如果支付机构只能返回 pending,你们仍然无法自行判断它属于 CREATED 还是 PROCESSING。这种情况下,可以在自己的数据库中保留两个维度:

{
  "paymentStatus": "PENDING",
  "clientUiState": "CUSTOM_TAB_CLOSED",
  "mayStillSucceed": true,
  "expiresAt": "..."
}

clientUiState 只用于界面展示,不能作为退款、履约或创建新订单的依据。

只有当支付机构明确保证某个支付会话超过有效期后绝不可能成功时,服务端才能将其标记为 EXPIRED。不能因为客户端等待了 30 秒,就认定支付失败。

服务端必须保证幂等

防止重复订单要依靠服务端约束,而不是客户端 dialog。

建议先创建业务订单或结账会话,再创建支付尝试:

Checkout / Order
  ├── Payment attempt A: PENDING
  └── Payment attempt B: SUCCEEDED

同一次结账产生的多次支付尝试必须关联到同一个业务对象。收到成功通知后,服务端应在事务中确认订单,并保证订单只会履约一次。

示意接口:

POST /checkouts/{checkoutId}/payment-attempts
Idempotency-Key: 7e67d756-...

GET /payment-attempts/{paymentAttemptId}

POST /payments/provider-webhook

数据库至少应设置类似的约束:

CREATE UNIQUE INDEX uq_provider_payment
    ON payment_attempts(provider, provider_payment_id);

CREATE UNIQUE INDEX uq_order_fulfillment
    ON fulfillments(order_id);

支付确认逻辑也必须支持幂等处理:

@Transactional
fun handlePaymentSucceeded(providerPaymentId: String) {
    val attempt = paymentRepository.lockByProviderPaymentId(providerPaymentId)

    if (attempt.status == PaymentStatus.SUCCEEDED) {
        return // 重复 webhook,安全忽略
    }

    attempt.status = PaymentStatus.SUCCEEDED
    orderRepository.markPaidIfNotAlreadyPaid(attempt.orderId)
    fulfillmentRepository.createIfAbsent(attempt.orderId)
}

实际实现需要使用数据库唯一约束、行锁或条件更新,不能只做一次普通的“先查询、再插入”。否则,并发 webhook 仍可能创建重复记录。

pending 时的客户端流程

用户返回 app 后,可以立即查询一次支付状态,但不要让页面无限等待:

sealed interface PaymentState {
    data object Created : PaymentState
    data object Processing : PaymentState
    data object Succeeded : PaymentState
    data object Cancelled : PaymentState
    data object Failed : PaymentState
    data object Expired : PaymentState
}

suspend fun handleReturnFromBrowser(paymentId: String) {
    when (val state = paymentApi.getPaymentState(paymentId)) {
        PaymentState.Succeeded -> openConfirmation()
        PaymentState.Cancelled,
        PaymentState.Failed,
        PaymentState.Expired -> returnToCheckout()

        PaymentState.Created,
        PaymentState.Processing -> showPendingPaymentScreen(paymentId)
    }
}

用户应该可以离开等待页面,但页面不能暗示支付已经取消。可以提供这些操作:

  • “继续等待”:在短时间内轮询状态。
  • “返回支付页面”:支付 URL 仍然有效时,重新打开该页面。
  • “稍后查看”:返回订单列表,后台继续同步状态。
  • “修改订单”:先说明原支付仍有可能成功,再按照业务规则取消旧尝试或创建新的结账版本。
  • “取消支付”:只有服务端和支付机构真正支持取消时才提供。

提示语可以写成:

支付结果尚未确认。你可以离开此页面,我们会继续更新结果。为避免重复扣款,请暂时不要再次支付。

不要使用一个必须等待 30 秒且无法关闭的 dialog。它无法保证支付会在 30 秒内结束,也会妨碍想要修改信息的用户。

后台同步和结果通知

服务端应通过支付机构的 webhook 获取最终状态,客户端轮询只能作为补充:

suspend fun pollPayment(paymentId: String) {
    val delays = listOf(1_000L, 2_000L, 4_000L, 8_000L)

    for (delayMs in delays) {
        when (paymentApi.getPaymentState(paymentId)) {
            PaymentState.Succeeded -> {
                openConfirmation()
                return
            }

            PaymentState.Cancelled,
            PaymentState.Failed,
            PaymentState.Expired -> {
                returnToCheckout()
                return
            }

            else -> delay(delayMs)
        }
    }

    showStillPendingMessage()
}

短轮询结束后,支付仍然可以保持 pending。后续可以通过以下方式通知用户:

  • app 再次启动或用户打开订单页面时重新查询;
  • 服务端发送推送通知;
  • 在订单列表中显示“支付确认中”;
  • 必要时发送邮件或短信。

WorkManager 可以用于尽力而为的后台刷新,但它无法保证精确的执行时间,也不能代替 webhook。

用户再次下单时如何处理

用户再次尝试支付时,服务端应先检查同一个结账或业务订单下是否有仍可能成功的支付尝试。

一种保守的处理方式是暂时拒绝创建第二个支付:

{
  "code": "PAYMENT_STILL_PENDING",
  "paymentAttemptId": "pay_123",
  "message": "A previous payment may still complete."
}

如果业务必须允许用户重新支付,可以采用以下方案之一:

  1. 先调用支付机构 API 取消旧支付,确认取消已经生效后,再创建新的支付尝试。
  2. 等旧支付明确变为 FAILED、CANCELLED 或 EXPIRED 后再创建。
  3. 允许多次支付成功,但只生成一个订单或只履约一次,多余款项自动进入退款流程。
  4. 用户修改商品、地址或金额时,创建新的 checkout 版本。即使旧支付稍后成功,也必须关联旧版本,再由服务端决定履约、转人工审核或退款。

第三种方案尤其重要,因为部分支付方式无法即时取消。即使前端做了严格限制,延迟通知、重复请求和并发操作仍可能导致多笔支付成功。服务端必须能够识别这些支付,并退回多余款项。

需要避免的做法

  • 不要将关闭 Custom Tab 视为取消支付。
  • 不要因为没有收到 deep link 就认定支付失败。
  • 不要依靠固定的 30 秒超时判断最终状态。
  • 不要只在客户端防止重复点击。
  • 不要让 webhook 无条件创建新订单。
  • 不要因为客户端没有显示确认页面,就让已经成功的支付“无人认领”。
  • 支付机构仍可能发送成功通知时,不要将本地记录永久改成失败。

浏览器关闭属于 UI 事件,支付成功属于服务端业务事件,两者没有可靠的一一对应关系。客户端只需要展示“结果尚未确定”,服务端则通过最终通知、状态机和幂等约束,确保用户关闭页面、重复点击或重新进入 app 时不会造成重复履约。如果确实出现多笔成功扣款,服务端也应能够识别并退款。

备注:内容仅供参考。