PAYATHON 2026

如何在 Flutter WebView 中读取重定向响应

支付老李

结论

不要用 window.document.body.outerHTML 判断支付是否成功。更可靠的处理方式是:

  1. 为支付网关配置一个由自己控制的 returnUrl。
  2. 在 navigationDelegate 中识别支付后的回跳地址。
  3. 从 URL 查询参数中读取交易标识或临时状态。
  4. 阻止 WebView 继续跳转,并关闭支付页面。
  5. 由 App 请求后端接口,再由后端向支付网关确认最终交易状态。

如果支付结果只通过 webhook 发送,WebView 无法直接读取。Webhook 是支付网关发给服务器的请求,需要由后端接收。

拦截回跳地址

假设支付网关配置的返回地址是:

https://example.com/payment/return

支付完成后,页面可能跳转到:

https://example.com/payment/return?transactionId=TX123&status=success

可以在 navigationDelegate 中拦截这个地址:

WebView(
  initialUrl: url,
  javascriptMode: JavascriptMode.unrestricted,
  navigationDelegate: (NavigationRequest request) {
    final uri = Uri.tryParse(request.url);

    if (uri != null &&
        uri.host == 'example.com' &&
        uri.path == '/payment/return') {
      final transactionId = uri.queryParameters['transactionId'];
      final status = uri.queryParameters['status'];

      _handlePaymentReturn(
        transactionId: transactionId,
        returnedStatus: status,
      );

      return NavigationDecision.prevent;
    }

    return NavigationDecision.navigate;
  },
  onWebViewCreated: (WebViewController controller) {
    _controller = controller;
  },
  gestureNavigationEnabled: true,
  debuggingEnabled: true,
);

接着处理支付结果:

Future<void> _handlePaymentReturn({
  required String? transactionId,
  required String? returnedStatus,
}) async {
  if (transactionId == null || transactionId.isEmpty) {
    _showPaymentResult(false);
    return;
  }

  // 推荐:通过自己的后端查询真实支付状态。
  final bool success = await verifyPaymentWithBackend(transactionId);

  _showPaymentResult(success);
}

void _showPaymentResult(bool success) {
  if (success) {
    print('Payment successful');
  } else {
    print('Payment failed or not confirmed');
  }
}

verifyPaymentWithBackend() 应请求自己的服务器,不要让 App 直接携带支付网关密钥:

Future<bool> verifyPaymentWithBackend(String transactionId) async {
  // 示例:请求自己的后端确认交易状态。
  // 不要将支付平台的 secret key 写入 Flutter App。
  throw UnimplementedError();
}

新版 webview_flutter 的写法

较新的 webview_flutter 版本通常先通过 WebViewController 配置导航代理,再把控制器交给 WebViewWidget:

late final WebViewController _controller;

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

          if (uri != null &&
              uri.host == 'example.com' &&
              uri.path == '/payment/return') {
            final transactionId =
                uri.queryParameters['transactionId'];
            final returnedStatus =
                uri.queryParameters['status'];

            _handlePaymentReturn(
              transactionId: transactionId,
              returnedStatus: returnedStatus,
            );

            return NavigationDecision.prevent;
          }

          return NavigationDecision.navigate;
        },
      ),
    )
    ..loadRequest(Uri.parse(paymentUrl));
}

页面组件如下:

WebViewWidget(controller: _controller)

项目该使用旧版 WebView 还是新版 WebViewWidget,取决于当前安装的 webview_flutter 版本。

使用自定义 URL Scheme 回到 App

如果支付网关支持自定义 Scheme,也可以把返回地址设为:

myapp://payment/result?transactionId=TX123&status=success

然后在 WebView 中拦截:

navigationDelegate: (NavigationRequest request) {
  final uri = Uri.tryParse(request.url);

  if (uri != null &&
      uri.scheme == 'myapp' &&
      uri.host == 'payment' &&
      uri.path == '/result') {
    final transactionId = uri.queryParameters['transactionId'];

    _handlePaymentReturn(
      transactionId: transactionId,
      returnedStatus: uri.queryParameters['status'],
    );

    return NavigationDecision.prevent;
  }

  return NavigationDecision.navigate;
},

并非所有支付网关都接受非 HTTPS 返回地址。如果网关只允许 HTTPS,应使用自己域名下的 HTTPS 回跳地址。

如果结果通过 POST 返回

有些网关不会把状态放在查询参数中,而是向 returnUrl 发送 POST 请求。遇到这种情况,navigationDelegate 通常只能取得目标 URL,无法可靠读取 POST body 或底层 HTTP response body。

可以把 returnUrl 指向自己的服务器:

https://api.example.com/payment/return

服务器接收并验证 POST 数据后,再返回一个简单的重定向地址:

https://example.com/payment/complete?transactionId=TX123

WebView 只需拦截最终地址,App 再用 transactionId 向后端查询支付状态。

为什么读取 HTML 不可靠

下面这段代码不适合用来判断交易状态:

_controller.runJavascriptReturningResult(
  "window.document.body.outerHTML",
);

原因包括:

  • onPageStarted 触发时,页面 DOM 通常还没有加载完成。
  • HTML 只是页面展示的内容,不能代表可信的支付状态。
  • 支付页面可能由 JavaScript 动态渲染,很难保证读取时机。
  • 跨域页面、重定向链和支付平台的安全策略可能会限制脚本行为。
  • 网关可能只通过 webhook 发送最终状态,页面中没有交易响应。
  • 页面内容可能被篡改,不能作为订单入账的依据。

如果业务需要在 onPageFinished 后读取页面文本,可以执行 JavaScript,但读取结果只能用于页面交互或辅助识别,不能代替服务端验签和交易查询。

注意事项

不要完全信任 URL 中的 status

用户可以手动构造这样的地址:

https://example.com/payment/return?status=success

因此,URL 中的状态最多只能用于更新界面,例如显示“正在确认支付”。支付是否真正成功,应由后端通过以下方式确认:

  • 验证支付网关返回数据的签名。
  • 接收并验证 webhook。
  • 使用交易编号调用网关的服务端查询接口。
  • 检查订单号、金额、币种和商户身份是否一致。

webhook 与 return URL 用途不同

returnUrl 面向用户浏览器,用于支付结束后的页面跳转。webhook 面向服务器,用于异步通知最终交易状态。用户可能在页面跳转前将其关闭,因此不能只依赖 returnUrl。

避免重复处理

支付网关可能重复跳转,也可能重复发送 webhook。后端需要按照订单号或交易号进行幂等处理,以免重复入账或重复发货。

精确匹配回跳地址

不要只做简单的字符串包含判断:

request.url.contains('success')

应通过 Uri 检查 scheme、host 和 path,避免把普通页面或伪造地址误判为支付成功。

备注:内容仅供参考。