Mastercard Hosted Checkout 在 Flutter WebView 中一直加载
结论
问题通常出在 loadHtmlString() 创建的页面缺少正常的 HTTPS origin,而不是 session ID 未传入。部分 Android WebView 或 WKWebView 会把这类页面视为 data:、about:blank 或其他受限来源,导致页面无法访问 sessionStorage。Hosted Checkout 脚本读取存储失败后,初始化会中断,页面也就一直停在加载动画。
Android 或 iOS 的运行时权限无法开放 sessionStorage。建议按以下方式处理:
- 将承载
checkout.min.js的启动页部署到自己控制的 HTTPS 域名。 - 在 Flutter 中使用
loadRequest()打开该页面,避免继续使用缺少正常来源的loadHtmlString()。 - 启用 JavaScript,并放行支付网关、3-D Secure 页面和回调地址的正常跳转。
- 如果 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 兼容性。
备注:内容仅供参考。
