PAYATHON 2026

Android 未安装支付宝APP时调起支付的返回与落地页问题

支付小周

结论

这两个问题需要分开处理:

  1. 返回 App 后再次进入支付页,通常是因为支付逻辑写在 onResume()、页面加载回调或路由初始化代码中,用户返回时又触发了一遍。应只在用户主动操作时启动支付,并通过支付状态和防重复标记避免多次调起。
  2. 未安装支付宝时,支付一般会降级到 H5 收银台。承载页面的可能是系统浏览器、厂商浏览器、WebView,也可能是支付宝服务端的中间页。商户无法完全控制返回按钮和最终落地页。比较可靠的方案是配置明确的 HTTPS 返回地址,在商户自己的中转页提供“返回订单”和“重新支付”入口,再通过 Android App Links 或页面按钮返回 App。

不要依赖支付宝页面左上角的返回按钮,也不能因为用户已经返回 App 就认定支付成功。最终支付状态必须以服务端异步通知或主动查询结果为准。

为什么会重复进入支付页

用户从 App 前往 H5 收银台,再切回原页面时,Activity 或 Fragment 通常会执行 onResume()。如果支付代码放在以下位置,就可能再次发起支付:

  • Activity.onResume()
  • Fragment.onResume()
  • 页面路由初始化
  • WebView 的 onPageFinished()
  • 订单状态刷新后的回调
  • Compose 中没有正确限定 key 的 LaunchedEffect

例如,下面的代码存在重复发起支付的风险:

override fun onResume() {
    super.onResume()
    startAlipay()
}

支付不应由页面生命周期直接触发,而应来自用户点击按钮或一个明确的一次性事件。

Android 侧避免重复调起

可以用一个简单的状态机管理支付流程:

enum class PayState {
    IDLE,
    LAUNCHING,
    WAITING_RESULT,
    FINISHED
}

用户点击支付按钮时,先检查当前状态:

class CheckoutActivity : AppCompatActivity() {

    private var payState = PayState.IDLE

    private fun onPayButtonClicked() {
        if (payState == PayState.LAUNCHING ||
            payState == PayState.WAITING_RESULT
        ) {
            return
        }

        payState = PayState.LAUNCHING
        requestPayOrder()
    }

    private fun requestPayOrder() {
        // orderInfo 必须由服务端生成和签名
        val orderInfo = obtainOrderInfoFromServer()

        payState = PayState.WAITING_RESULT

        Thread {
            val result = PayTask(this).payV2(orderInfo, true)

            runOnUiThread {
                handleSdkResult(result)
            }
        }.start()
    }

    private fun handleSdkResult(result: Map<String, String>) {
        // SDK 返回值只用于界面提示,不能作为最终到账依据
        refreshOrderStatusFromServer()
    }

    private fun refreshOrderStatusFromServer() {
        // 查询商户服务端订单状态
        // 确认成功后设置 FINISHED;
        // 未支付或已取消时恢复为 IDLE,允许用户主动重试。
    }
}

如果页面重建后仍需保留状态,可以使用 ViewModel 和 SavedStateHandle 保存“是否已经发起过”:

class CheckoutViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    var payState: PayState
        get() = savedStateHandle["pay_state"] ?: PayState.IDLE
        set(value) {
            savedStateHandle["pay_state"] = value
        }

    fun canLaunchPayment(): Boolean {
        return payState == PayState.IDLE
    }
}

支付按钮还可以增加短时间防抖,但防抖只能阻止快速连点,不能替代支付状态控制。

不要在返回时自动重新支付

用户从浏览器或收银台返回后,可以刷新订单状态,但不要马上重新调用支付接口:

private var shouldRefreshOrder = false

private fun launchPayment() {
    if (payState != PayState.IDLE) return

    shouldRefreshOrder = true
    payState = PayState.WAITING_RESULT

    // 调起支付
}

override fun onResume() {
    super.onResume()

    if (shouldRefreshOrder) {
        shouldRefreshOrder = false
        refreshOrderStatusFromServer()
    }
}

这里的 onResume() 只查询订单,不再次发起支付。如果查询结果是未支付,应显示“继续支付”按钮,让用户决定是否重试。

如何保证用户能够返回

未安装支付宝时,承载 H5 收银台的容器并不固定。有的容器带工具栏,有的没有;有的会打开新页面,有的会覆盖当前 WebView。商户很难稳定控制第三方页面是否显示左上角返回按钮。

可以采用以下返回链路:

支付宝 H5 收银台
        ↓
商户 HTTPS 支付结果中转页
        ↓
Android App Links 返回 App
        ↓
App 向服务端查询订单状态

创建支付请求时,应按照当前接入产品的接口文档配置商户返回地址。如果接口支持 return_url,可以将其设置为商户自己的 HTTPS 页面,例如:

https://pay.example.com/alipay/return?orderNo=202609090001

支持哪些参数、参数应该放在哪个业务对象中,取决于实际使用的支付宝产品和接口版本。应以对应产品的当前文档为准,不要直接套用其他支付接口的参数。

中转页至少要提供两个清晰的入口:

<a href="https://www.example.com/orders/202609090001">
  返回订单页
</a>

<a href="https://app.example.com/pay/result?orderNo=202609090001">
  返回 App 查看结果
</a>

第二个 HTTPS 地址可以配置为 Android App Link。即使 App Link 没有成功唤起 App,用户也能留在正常的网页结果页,不会困在无法控制的支付页面里。

在 Manifest 中声明可验证的 HTTPS 链接:

<activity
    android:name=".PayResultActivity"
    android:exported="true">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="app.example.com"
            android:pathPrefix="/pay/result" />
    </intent-filter>
</activity>

对应域名还需要部署合法的 assetlinks.json。App 收到链接后,只读取订单号,然后向商户服务端查询状态:

class PayResultActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val orderNo = intent?.data
            ?.getQueryParameter("orderNo")
            .orEmpty()

        if (orderNo.isBlank()) {
            finish()
            return
        }

        queryOrderStatus(orderNo)
    }

    private fun queryOrderStatus(orderNo: String) {
        // 调用商户服务端查询接口,展示支付成功、未支付或处理中
    }
}

不要只根据 URL 中的 result=success、客户端 SDK 返回值或页面跳转结果,把订单状态改成“已支付”。这些数据可能被伪造,也可能因为网络问题或页面关闭而不完整。

如何控制不同手机上的落地行为

支付宝收银台及其中间页面可能因浏览器能力、系统版本、风控策略、应用安装情况和服务端配置而变化。商户通常无法强制所有设备显示完全一致的支付宝页面。

商户可以控制的是支付前后的自有页面:

  • 支付前统一进入商户自己的确认页。
  • 支付参数由服务端生成,客户端不要自行拼接签名内容。
  • 配置固定的商户 HTTPS 返回地址。
  • 返回地址指向商户中转页,不依赖第三方页面的导航。
  • 中转页同时提供网页订单入口和返回 App 入口。
  • App 恢复后只查询订单状态,不自动重新调起支付。
  • 对同一个商户订单号做幂等控制,避免重复创建支付订单。

如果支付页面必须放在自有 WebView 中,还应保留可见的原生关闭按钮,不能只依靠网页自己的返回按钮:

binding.closeButton.setOnClickListener {
    finish()
}

override fun onBackPressed() {
    if (binding.webView.canGoBack()) {
        binding.webView.goBack()
    } else {
        finish()
    }
}

如果支付流程已经离开当前 WebView,进入系统浏览器或其他应用,这个原生按钮就无法控制外部页面。因此,HTTPS 返回页仍然需要作为兜底方案。

注意事项

  • 支付订单应由服务端创建和签名,私钥不能放在 Android 客户端。
  • 客户端返回、H5 跳转和 SDK 同步结果都不能作为最终支付凭证。
  • 服务端收到异步通知后需要验签,并对订单更新做幂等处理。
  • 用户返回时,订单可能仍在处理中。此时可以轮询查询,但不要自动重新支付。
  • return_url 只处理用户页面跳转,不能保证异步通知已经完成。
  • 自定义 Scheme 返回 App 可能被浏览器拦截,应优先使用经过域名验证的 Android App Links。
  • 不同支付宝产品的返回参数和降级行为可能不同,需要结合实际使用的 SDK、支付接口和当前官方文档确认。

备注:内容仅供参考。