PAYATHON 2026

Mastercard Hosted Checkout 在 Flutter WebView 中一直加载

排障赵师傅

结论

问题通常出在 loadHtmlString() 创建的页面缺少正常的 HTTPS origin,而不是 session ID 未传入。部分 Android WebView 或 WKWebView 会把这类页面视为 data:、about:blank 或其他受限来源,导致页面无法访问 sessionStorage。Hosted Checkout 脚本读取存储失败后,初始化会中断,页面也就一直停在加载动画。

Android 或 iOS 的运行时权限无法开放 sessionStorage。建议按以下方式处理:

  1. 将承载 checkout.min.js 的启动页部署到自己控制的 HTTPS 域名。
  2. 在 Flutter 中使用 loadRequest() 打开该页面,避免继续使用缺少正常来源的 loadHtmlString()。
  3. 启用 JavaScript,并放行支付网关、3-D Secure 页面和回调地址的正常跳转。
  4. 如果 Hosted Checkout 或发卡行认证页面仍无法兼容 WebView,改用系统浏览器、Android Custom Tabs 或 iOS SFSafariViewController。

loadHtmlString() 为什么会触发错误

这条日志直接指出了问题:

Uncaught SecurityError: Failed to read the 'sessionStorage' property from 'Window': Access is denied for this document.

Web Storage 需要有效的页面来源,也就是由 scheme、host 和 port 组成的 origin。直接加载字符串生成的文档可能没有可用的 HTTPS origin。即使脚本来自:

https://test-xxx.gateway.mastercard.com/static/checkout/checkout.min.js

顶层文档仍可能属于 data: 或 about:blank 来源。加载外部脚本并不会让顶层页面自动获得 Mastercard 域名的来源。

另外两条日志要分开判断:

Unrecognized feature: 'payment'.

这通常表示当前 WebView 无法识别 iframe Permissions Policy 中的 payment 功能。它可能只是兼容性警告,单凭这条日志无法确定页面失败。如果某种支付方式确实依赖 Payment Request API,那么它可能无法在当前 WebView 中运行。

Possible Unhandled Promise Rejection: [object DOMException]

这通常是某项浏览器能力调用失败后引发的连带错误。需要结合 sessionStorage、弹窗、Cookie 和导航拦截日志一起判断,不能仅凭这一条确定原因。

通过 HTTPS 页面启动 Hosted Checkout

可以在自己的后端提供一个启动页面,例如:

https://pay.example.com/checkout/start

由服务器将 session ID 安全地写入页面,再加载 Hosted Checkout:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >

  <script
    src="https://test-xxx.gateway.mastercard.com/static/checkout/checkout.min.js"
    data-error="errorCallback"
    data-cancel="cancelCallback">
  </script>

  <script>
    function errorCallback(error) {
      console.error('Hosted Checkout error:', error);
    }

    function cancelCallback() {
      console.log('Payment cancelled');
    }

    window.addEventListener('unhandledrejection', function (event) {
      console.error('Unhandled rejection:', event.reason);
    });

    window.addEventListener('load', function () {
      Checkout.configure({
        session: {
          id: SERVER_RENDERED_SESSION_ID
        }
      });

      Checkout.showPaymentPage();
    });
  </script>
</head>
<body>
  <p>Loading payment...</p>
</body>
</html>

后端应将 SERVER_RENDERED_SESSION_ID 输出为合法的 JSON 字符串,不能直接拼接未经转义的请求参数。也不要把 session ID 长期写入静态文件、日志或前端配置。

Flutter 端直接打开这个 HTTPS 地址:

late final WebViewController controller;

void initPaymentWebView(Uri checkoutUri) {
  controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..setNavigationDelegate(
      NavigationDelegate(
        onNavigationRequest: (NavigationRequest request) {
          final uri = Uri.tryParse(request.url);

          if (uri == null) {
            return NavigationDecision.prevent;
          }

          if (uri.scheme == 'https') {
            return NavigationDecision.navigate;
          }

          // 如果 returnUrl 使用自定义 scheme,在这里交给应用处理。
          if (uri.scheme == 'myapp') {
            handlePaymentReturn(uri);
            return NavigationDecision.prevent;
          }

          return NavigationDecision.prevent;
        },
        onWebResourceError: (WebResourceError error) {
          debugPrint(
            'WebView error: ${error.errorCode} ${error.description}',
          );
        },
      )
    )
    ..loadRequest(checkoutUri);
}

实际项目中,不要把允许访问的地址限制为 Mastercard 的某一个固定主机名。3-D Secure 验证可能跳转到其他认证域名。如果无法维护可靠的域名允许列表,使用系统浏览器通常更合适。

Android 还需要声明联网权限:

<uses-permission android:name="android.permission.INTERNET" />

HTTPS 网关通常不需要开启明文流量。排查问题时也不要启用 cleartextTrafficPermitted 或忽略 TLS 错误。

暂时保留 loadHtmlString() 的处理方式

暂时无法部署启动页时,可以为 HTML 指定真实的 HTTPS base URL。webview_flutter 是否支持这种方式,以及实际效果如何,取决于插件版本和平台实现:

import 'dart:convert';

Future<void> loadPaymentPage() async {
  if (sessionId == null) {
    return;
  }

  final encodedSessionId = jsonEncode(sessionId);

  final htmlContent = '''
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
  >
  <script
    src="https://test-xxx.gateway.mastercard.com/static/checkout/checkout.min.js"
    data-error="errorCallback"
    data-cancel="cancelCallback">
  </script>
  <script>
    function errorCallback(error) {
      console.error('Hosted Checkout error:', error);
    }

    function cancelCallback() {
      console.log('Payment cancelled');
    }

    window.addEventListener('unhandledrejection', function (event) {
      console.error('Unhandled rejection:', event.reason);
    });

    window.addEventListener('load', function () {
      Checkout.configure({
        session: {
          id: $encodedSessionId
        }
      });

      Checkout.showPaymentPage();
    });
  </script>
</head>
<body>
  <p>Loading payment...</p>
</body>
</html>
''';

  await controller.loadHtmlString(
    htmlContent,
    baseUrl: 'https://pay.example.com/checkout/',
  );
}

baseUrl 应指向自己控制的 HTTPS 域名,不能伪装成 Mastercard 网关域名。Android WebView、WKWebView 以及不同插件版本对 baseUrl 和存储来源的处理可能并不一致,因此这种方法更适合用来验证问题,可靠性不如直接加载托管的 HTTPS 页面。

如果设置 baseUrl 后仍然出现相同的 sessionStorage 错误,就应停用字符串页面,改为加载托管页面。

WebView 设置检查

必须启用 JavaScript:

..setJavaScriptMode(JavaScriptMode.unrestricted)

还要检查以下设置和行为:

  • 不要在 NavigationDelegate 中拦截网关或 3-D Secure 的 HTTPS 跳转。
  • 如果 WebView 插件提供 DOM Storage 设置,应保持启用。这不是 Android 运行时权限。
  • 部分认证流程需要 Cookie,其中可能包括第三方 Cookie。Android 是否需要显式允许,取决于 WebView 插件和实际认证页面。
  • 如果支付流程会打开新窗口,插件需要正确处理 window.open 或多窗口请求。
  • 除非网关支持人员明确要求,否则不要强制修改 User-Agent。
  • 不要通过关闭同源策略、忽略证书错误或允许任意混合内容来解决问题。

这些设置无法修复无效的 origin。应先处理 loadHtmlString() 造成的来源问题,再检查 Cookie、弹窗和重定向。

页面仍然无法加载时检查 session

拿到 session ID 并不表示该 session 一定能展示支付页。后端还应确认:

  • 创建 session 时使用的环境与脚本域名一致,测试 session 只能用于测试环境。
  • session 由同一网关区域、merchant 配置和凭据创建。
  • Hosted Checkout 所需的订单金额、币种、操作类型和其他必要字段已经写入 session。
  • session 未过期、未重复使用,也没有被后续请求覆盖。
  • 回调地址和 returnUrl 使用有效的 HTTPS 地址,或者使用应用能够处理的深链接。
  • 网关返回的 session 创建或更新响应中没有被忽略的错误。

具体必填字段应以当前商户配置对应的 API version 83 文档为准。不同支付方式和网关配置可能还会要求其他字段。

WebView 不兼容时的替代方案

支付过程中还可能进入 3-D Secure、银行登录或钱包授权页面。这些页面对 Cookie、弹窗、跨站存储和浏览器能力有较高要求,嵌入式 WebView 未必能全部满足。

这种情况下,可以用系统浏览器打开自己托管的 HTTPS 启动页,再通过 returnUrl、App Link 或 Universal Link 返回应用。不能只根据页面跳转判断支付结果,应用或后端还应调用网关查询接口,或者通过已经配置的服务端通知确认最终状态。

排查时,先移除缺少 origin 的字符串页面,再检查 WebView 的导航和存储能力,最后核对 session 内容。如果同一个 session 能在外部浏览器中完成支付,却在 WebView 中失败,通常可以把问题缩小到 WebView 兼容性。

备注:内容仅供参考。