PAYATHON 2026

Android 中如何处理 3d-secure redirect response?

支付老李

结论

不要完全依赖 WebView 接收“验证完成通知”。建议采用以下方案:

  1. 优先使用支付服务商提供的 Android SDK 或 3DS2 SDK。
  2. 需要跳转网页时,通过 Chrome Custom Tabs 或系统浏览器完成验证。
  3. 将支付回调地址配置为 HTTPS App Link,或者由服务端跳转到应用的自定义 URI。
  4. 应用收到回跳后,只提取交易标识,再向服务端查询支付结果。
  5. 如果用户从 BankID 手动返回应用,但没有触发回跳,可在 onResume() 中查询状态或进行短时轮询。

如果业务上必须使用 WebView,可以通过 WebViewClient.shouldOverrideUrlLoading() 拦截回调 URL,同时处理 BankID 等外部应用的 URI。不过,这种方式无法覆盖所有 3D Secure 流程。

为什么 WebView 可能收不到回调

3D Secure 验证通常涉及多个参与方:

  • Android 应用
  • 支付服务商
  • 发卡行的 ACS 页面
  • BankID 等外部身份验证应用
  • 商户服务端

用户离开 WebView 并打开 BankID 后,验证结果通常会先发送给支付平台或商户服务端。后续是否重新导航到原来的 WebView,取决于支付服务商、ACS 页面和回调地址的配置。

所以,BankID 显示“验证成功”,不代表原来的 WebView 一定会收到新的页面请求。实际流程中还可能出现以下情况:

  • 验证完成后只更新服务端状态,并不跳回应用。
  • 回调在系统浏览器中打开,而不是原来的 WebView。
  • 回调使用自定义 scheme,但 WebView 没有拦截。
  • 外部应用返回后,页面仍需依靠 JavaScript、Cookie 或重定向链继续执行。
  • 用户在另一台设备上完成 BankID 验证,当前 Android 设备不会收到应用切换事件。
  • 3DS 页面禁止嵌入,或者依赖第三方 Cookie、弹窗和多窗口。
  • Android 进程在外部验证期间被系统回收。

因此,支付结果必须由服务端确认,不能只根据某个网页 URL 判断是否成功。

假设支付服务商允许设置以下返回地址:

https://pay.example.com/3ds/return?payment_id=abc123

服务端处理该地址后,可以显示完成页面,也可以通过 Android App Link 将用户带回应用。应用收到链接后,再向自己的服务端查询支付状态。

<activity
    android:name=".PaymentResultActivity"
    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="pay.example.com"
            android:pathPrefix="/3ds/return" />
    </intent-filter>
</activity>

使用 android:autoVerify="true" 时,还需要在对应域名部署正确的文件:

https://pay.example.com/.well-known/assetlinks.json

如果暂时无法配置 App Link,也可以使用自定义 scheme,例如:

myapp://payment/3ds-return?payment_id=abc123

不过,其他应用也可能声明同一个自定义 scheme,其安全性和归属验证能力都不如 HTTPS App Link。

启动 Custom Tabs

import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent

fun openThreeDSecurePage(activity: Activity, verificationUrl: String) {
    val customTabsIntent = CustomTabsIntent.Builder().build()
    customTabsIntent.launchUrl(activity, Uri.parse(verificationUrl))
}

Custom Tabs 使用真实的浏览器环境,对 Cookie、JavaScript、外部应用跳转和安全策略的兼容性通常优于 WebView,更适合银行认证页面。

接收回跳链接

class PaymentResultActivity : AppCompatActivity() {

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

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        handleCallback(intent)
    }

    private fun handleCallback(intent: Intent) {
        val uri = intent.data ?: return

        if (uri.scheme == "https" &&
            uri.host == "pay.example.com" &&
            uri.path == "/3ds/return"
        ) {
            val paymentId = uri.getQueryParameter("payment_id") ?: return

            // 不要仅根据 URL 中的 status 判断支付成功。
            // 使用 paymentId 向自己的服务端查询最终支付状态。
            queryPaymentStatus(paymentId)
        }
    }

    private fun queryPaymentStatus(paymentId: String) {
        // 调用商户服务端 API。
        // 服务端再通过支付服务商 API 或 webhook 结果确认支付状态。
    }
}

回调参数是不可信输入。应用需要严格检查 scheme、host 和 path,并确认支付会话与当前用户匹配。

必须使用 WebView 时的处理方式

如果支付服务商明确支持在嵌入式浏览器中运行 3DS,可以设置 WebViewClient,在每次导航时检查页面是否到达预先约定的回调地址。

@SuppressLint("SetJavaScriptEnabled")
fun configurePaymentWebView(
    webView: WebView,
    expectedPaymentId: String
) {
    webView.settings.javaScriptEnabled = true
    webView.settings.domStorageEnabled = true

    webView.webViewClient = object : WebViewClient() {

        override fun shouldOverrideUrlLoading(
            view: WebView,
            request: WebResourceRequest
        ): Boolean {
            return handleUrl(view.context, request.url, expectedPaymentId)
        }

        @Deprecated("Used for older Android WebView implementations")
        override fun shouldOverrideUrlLoading(
            view: WebView,
            url: String
        ): Boolean {
            return handleUrl(view.context, Uri.parse(url), expectedPaymentId)
        }
    }
}

private fun handleUrl(
    context: Context,
    uri: Uri,
    expectedPaymentId: String
): Boolean {
    val isReturnUrl =
        uri.scheme == "https" &&
        uri.host == "pay.example.com" &&
        uri.path == "/3ds/return"

    if (isReturnUrl) {
        val paymentId = uri.getQueryParameter("payment_id")

        if (paymentId == expectedPaymentId) {
            // 关闭或隐藏 WebView,然后向服务端查询支付状态。
            queryPaymentStatusFromServer(paymentId)
        }

        return true
    }

    if (uri.scheme != "http" && uri.scheme != "https") {
        return openExternalApplication(context, uri)
    }

    // 返回 false,让 WebView 正常加载 HTTP/HTTPS 页面。
    return false
}

外部应用 URI 可以交给 Android 处理:

private fun openExternalApplication(context: Context, uri: Uri): Boolean {
    val intent = Intent(Intent.ACTION_VIEW, uri).apply {
        addCategory(Intent.CATEGORY_BROWSABLE)
    }

    return try {
        context.startActivity(intent)
        true
    } catch (e: ActivityNotFoundException) {
        false
    }
}

不要无条件执行所有非 HTTP URL。应用只应允许支付流程明确需要的 scheme,并验证来源和目标,否则恶意页面可能借此启动非预期的应用或 Intent。

处理用户从 BankID 返回但没有 URL 回调

在同一设备上完成验证时,应用通常会经历 onPause() 和 onResume()。应用恢复到前台后,可以重新查询尚未完成的支付:

class CheckoutActivity : AppCompatActivity() {

    private var pendingPaymentId: String? = null
    private var externalVerificationStarted = false

    override fun onResume() {
        super.onResume()

        val paymentId = pendingPaymentId ?: return
        if (!externalVerificationStarted) return

        refreshPaymentStatus(paymentId)
    }

    private fun refreshPaymentStatus(paymentId: String) {
        // 向商户服务端查询:
        // pending、authorized、failed、cancelled、expired 等状态。
    }
}

onResume() 只表示应用重新进入前台,并不代表验证成功,因此不能在这里直接把订单标记为已支付。

如果用户在另一台设备上完成 BankID 验证,当前设备通常不会发生生命周期变化,也收不到深链回跳。这类场景需要采用以下方式之一:

  • 支付服务商向商户服务端发送 webhook。
  • 客户端定期查询商户服务端。
  • 使用服务端推送机制通知客户端重新查询。

轮询需要设置查询间隔、超时和终止条件。例如,只在支付状态为 pending 时继续查询,并在成功、失败、取消或过期后立即停止。

服务端应当作为最终状态来源

完整流程可以这样设计:

  1. 应用向商户服务端创建支付。
  2. 服务端返回 paymentId 和 3DS 验证地址。
  3. 应用通过支付 SDK 或 Custom Tabs 打开验证页面。
  4. 用户在 BankID 中完成操作。
  5. 支付服务商通过 API 状态或 webhook 更新服务端记录。
  6. 浏览器通过 App Link 返回应用。如果没有回跳,应用恢复前台后主动查询。
  7. 应用向商户服务端查询 paymentId 的最终状态。
  8. 只有服务端确认状态为 authorized、captured 或支付平台定义的等价成功状态,应用才更新订单。

以下信息不能单独用于判定支付成功:

  • 回调 URL 中的 success=true
  • WebView 显示的“支付成功”文字
  • JavaScript 回调
  • 用户从 BankID 返回应用
  • 客户端自行保存的支付状态

这些信号只能用来触发一次服务端状态查询。

其他注意事项

  • 优先遵循支付服务商针对 Android 和 3DS2 的集成文档。部分服务商明确不支持嵌入式 WebView。
  • 不要使用 addJavascriptInterface() 接收不受信任支付页面的数据,以免扩大攻击面。
  • 不要关闭 TLS 校验,也不要在 onReceivedSslError() 中无条件调用 handler.proceed()。
  • 为支付会话设置一次性随机 state 或具有同等作用的关联值,避免其他深链冒充当前支付回调。
  • 保存待处理的 paymentId,以便在进程被系统回收后恢复支付流程,但不要在本地保存卡片信息或敏感认证数据。
  • 对返回、取消、网络中断、重复回调和超时进行幂等处理。
  • 如果验证流程依赖弹窗或新窗口,WebView 可能还需要配置 WebChromeClient 并处理多窗口。不过,这通常说明 Custom Tabs 更适合该流程。
  • BankID 的具体 URI、启动参数和返回行为应以接入的 BankID 或支付服务商协议为准,不能假设所有验证流程都会返回同一种 callback。

回跳只负责把用户带回应用,支付结果则由服务端查询确认。将这两个环节分开,才能覆盖 WebView 没有收到导航、用户在另一台设备上完成验证,以及 Android 进程被系统回收等情况。

备注:内容仅供参考。