PAYATHON 2026

如何从 Web payment gateway 返回 Flutter App

支付老李

结论

return_url 不是 Flutter 页面名称,而是支付完成后由银行网关跳转到的 URL。具体地址要按银行网关的接口规范填写。

常见做法有两种:

  1. 支付全程在 App 内的 WebView 中完成:把 return_url 设置为自己的 HTTPS 地址,在 WebView 中拦截该地址,再关闭 WebView 并打开 PaymentResultScreen。
  2. 支付过程中可能打开系统浏览器或银行 App:配置 Android App Links 或 iOS Universal Links,让 HTTPS 地址能够重新唤起 App。

不能仅凭 return_url 中的 success=true 等参数判断付款成功。最终支付状态必须由服务器根据订单号向银行查询,或通过银行提供的服务端通知确认。

先确认银行提供的是哪种回调

支付接口通常包含两类地址,两者的名称很容易混淆:

  • 浏览器返回地址:支付完成后,银行把用户页面重定向到这个地址,通常叫 return_url、redirect_url 或 success_url。
  • 服务端通知地址:银行服务器直接调用商户服务器,通常叫 notify_url、callback_url 或 webhook_url。

服务端通知地址不能填写 my-app://...,因为银行服务器无法访问安装在用户手机中的 App。这个地址必须是公网可访问的 HTTPS 接口,例如:

https://api.example.com/payments/bank/notify

浏览器返回地址可以填写:

https://example.com/payment/return

如果银行明确支持自定义 URL Scheme,也可能允许填写:

my-app://payment/result

不过,很多银行只接受预先登记的 HTTPS 域名,最终应以该银行的 IPG 接口文档为准。

方案一:在 WebView 中拦截返回地址

如果银行页面始终在 Flutter WebView 中运行,直接拦截返回地址即可。

假设提交给银行的返回地址是:

https://example.com/payment/return

使用 webview_flutter 时,可以通过 NavigationDelegate 检查 WebView 即将访问的 URL:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class PaymentWebViewScreen extends StatefulWidget {
  const PaymentWebViewScreen({super.key});

  @override
  State<PaymentWebViewScreen> createState() =>
      _PaymentWebViewScreenState();
}

class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
  late final WebViewController controller;

  static const returnUrl =
      'https://example.com/payment/return';

  @override
  void initState() {
    super.initState();

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

            if (uri != null &&
                uri.scheme == 'https' &&
                uri.host == 'example.com' &&
                uri.path == '/payment/return') {
              _handlePaymentReturn(uri);
              return NavigationDecision.prevent;
            }

            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(
        Uri.parse('https://bank.example.com/payment'),
      );
  }

  Future<void> _handlePaymentReturn(Uri uri) async {
    final transactionId =
        uri.queryParameters['transaction_id'];

    if (!mounted) {
      return;
    }

    Navigator.of(context).pushReplacement(
      MaterialPageRoute(
        builder: (_) => PaymentResultScreen(
          transactionId: transactionId,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Payment')),
      body: WebViewWidget(controller: controller),
    );
  }
}

结果页面接收订单号或交易号后,再向自己的服务器查询实际支付状态:

class PaymentResultScreen extends StatefulWidget {
  const PaymentResultScreen({
    super.key,
    required this.transactionId,
  });

  final String? transactionId;

  @override
  State<PaymentResultScreen> createState() =>
      _PaymentResultScreenState();
}

class _PaymentResultScreenState
    extends State<PaymentResultScreen> {
  bool loading = true;
  String status = 'unknown';

  @override
  void initState() {
    super.initState();
    _loadPaymentStatus();
  }

  Future<void> _loadPaymentStatus() async {
    // 调用自己的后端:
    // GET /payments/{orderId}/status
    //
    // 后端再根据银行通知或银行查询接口确认支付结果。
    final verifiedStatus = 'pending';

    if (!mounted) {
      return;
    }

    setState(() {
      status = verifiedStatus;
      loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Payment result')),
      body: Center(
        child: loading
            ? const CircularProgressIndicator()
            : Text('Payment status: $status'),
      ),
    );
  }
}

PaymentResultScreen 不需要与 return_url 建立系统级映射。WebView 的 onNavigationRequest 会手动完成这层映射:检测到指定 URL 后,调用 Navigator 打开结果页面。

不要直接比较完整字符串

银行可能在返回地址后附加查询参数,例如:

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

因此,不要只写:

if (request.url == returnUrl) {
  // ...
}

应该用 Uri 分别检查 scheme、host 和 path,然后读取 queryParameters。这样也能避免误拦截名称相似的恶意域名。

银行使用 POST 返回时的处理

有些支付网关不会通过 GET 重定向,而是向 return_url 提交 HTML 表单,也就是发送 POST 请求。WebView 的导航回调不一定能直接读取 POST body,具体行为还取决于 WebView 插件和平台实现。

更稳妥的方式是让银行把 POST 请求发送到自己的服务器:

POST https://api.example.com/payments/bank/return

服务器负责:

  1. 接收银行返回的字段。
  2. 校验签名。
  3. 保存或查询交易状态。
  4. 返回普通的重定向响应,例如:
HTTP/1.1 302 Found
Location: https://example.com/payment/return?order_id=ORDER_123

WebView 随后会访问这个 HTTPS 地址,Flutter 可以通过 NavigationDelegate 将其拦截。

如果银行不允许使用 302 重定向,服务器也可以返回一个简单的成功页面。WebView 根据该页面的固定 URL 判断支付流程已经结束。

方案二:从系统浏览器重新唤起 App

如果支付过程会离开 WebView,转到系统浏览器、银行 App 或第三方验证页面,只靠 WebView 拦截无法处理。这种情况需要配置深度链接。

建议使用 HTTPS App Link 或 Universal Link,例如:

https://example.com/payment/result?order_id=ORDER_123

App 已安装时,系统可以把这个地址交给 App 处理;App 未安装时,地址仍可作为普通网页打开。

Flutter 可以通过路由库或深度链接插件接收该 URI。下面是使用 go_router 定义路由的示例:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/payment/result',
      builder: (context, state) {
        final orderId =
            state.uri.queryParameters['order_id'];

        return PaymentResultScreen(
          transactionId: orderId,
        );
      },
    ),
  ],
);

void main() {
  runApp(
    MaterialApp.router(
      routerConfig: router,
    ),
  );
}

Flutter 路由只负责收到链接后的页面跳转。要让操作系统把 HTTPS 链接交给 App,还需要完成 Android 和 iOS 的平台配置。

在 AndroidManifest.xml 中,为相应的 Activity 添加 intent-filter。Activity 的实际名称应以项目现有配置为准:

<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="example.com"
        android:pathPrefix="/payment/result" />
</intent-filter>

还要在自己的域名部署:

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

这个文件用于证明域名与 Android 应用的关系,内容需要包含应用的 package name 和签名证书指纹。

iOS 项目需要启用 Associated Domains,并添加:

applinks:example.com

同时在域名部署:

https://example.com/.well-known/apple-app-site-association

文件中需要配置应用的 Team ID、Bundle ID 以及允许打开的路径。

Universal Link 的具体接收方式取决于 Flutter 版本、路由方案和所用插件,但整体流程相同:

银行返回 HTTPS URL
        ↓
Android / iOS 验证域名关联
        ↓
系统唤起 App
        ↓
Flutter 收到 URI
        ↓
根据 /payment/result 打开 PaymentResultScreen

自定义 URL Scheme 的替代方案

如果银行明确允许使用非 HTTPS 返回地址,可以注册:

my-app://payment/result

Android 示例:

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

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

    <data
        android:scheme="my-app"
        android:host="payment"
        android:pathPrefix="/result" />
</intent-filter>

iOS 需要在 Info.plist 的 CFBundleURLTypes 中注册 my-app。

自定义 Scheme 无法像 App Links 和 Universal Links 那样验证域名所有权,也可能被其他 App 抢占。因此,支付场景更适合使用经过验证的 HTTPS 链接。

推荐的完整支付流程

一个较为安全、稳定的支付流程如下:

  1. Flutter 向自己的后端申请创建订单。
  2. 后端生成订单号,并按照银行要求计算签名。
  3. Flutter 在 WebView 或系统浏览器中打开银行支付页面。
  4. 银行通过服务端通知把结果发送给商户后端。
  5. 银行将用户重定向到预先登记的 HTTPS return_url。
  6. WebView 拦截该 URL,或由系统通过 App Link / Universal Link 唤起 App。
  7. App 根据订单号向自己的后端查询支付状态。
  8. 后端验证银行签名,或调用银行查询接口,然后返回最终状态。
  9. App 显示成功、失败、取消或处理中页面。

需要特别注意的事项

  • 不要把银行密钥、签名私钥或商户密码写进 Flutter App。
  • 不要信任 URL 中的 status=success,因为用户可以手动构造或修改 URL。
  • return_url 只负责页面跳转,不能替代可靠的支付通知。
  • 每笔交易都应使用唯一订单号,并在后端进行幂等处理,避免重复入账。
  • 用户可能在支付成功后直接关闭页面,所以不能只依赖浏览器返回。
  • 支付流程应处理 success、failed、cancelled、pending 和网络中断等状态。
  • 如果银行要求使用白名单域名、固定路径或 HTTPS 证书,必须严格使用已登记的地址。
  • html package 主要用于解析或操作 HTML,无法完成 WebView 导航拦截、深度链接注册或支付结果验证,因此不是解决这个问题的主要工具。

备注:内容仅供参考。