PAYATHON 2026

用户提交 Web 表单后如何自动返回 Flutter 应用

支付老李

结论

用户提交银行支付页面后,Flutter 应用无法直接控制银行网页自动返回。一般需要按以下方式处理:

  1. 在银行支付网关中配置支付完成后的 returnUrl;
  2. 让该地址跳转到 Flutter 应用支持的 Deep Link、Android App Link 或 iOS Universal Link;
  3. Flutter 收到链接并恢复运行后,根据订单号向业务服务器查询支付结果。

如果支付页面通过应用内 WebView 打开,也可以监听页面跳转,命中 returnUrl 时关闭 WebView。无论使用哪种方式,都不能只根据跳转参数判断支付成功。最终支付状态必须由服务端向银行支付网关核验。

推荐的处理流程

完整流程通常如下:

Flutter 应用
    → 请求业务服务器创建支付订单
    → 服务器向银行 IPG 创建支付会话
    → Flutter 打开银行支付页面
    → 用户填写表单并提交
    → 银行跳转到 returnUrl
    → returnUrl 唤起 Flutter 应用
    → Flutter 向业务服务器查询订单
    → 服务器向银行核验并返回最终支付状态

这个流程通常会用到两个不同的地址:

  • returnUrl:用户完成操作后,浏览器跳转到的页面,用于返回应用。
  • callbackUrl 或 notifyUrl:银行服务器用于通知业务服务器支付结果的地址。

各家银行对这些参数的名称和调用方式可能不同,应以接入的 IPG 接口文档为准。有些网关只接受预先登记的 HTTPS 地址,不支持自定义 URL Scheme。

生产环境通常优先使用 HTTPS 链接,例如:

https://pay.example.com/payment/result?orderId=ORDER_123

配置完成后:

  • Android 会将该地址作为 App Link 打开;
  • iOS 会将该地址作为 Universal Link 打开;
  • 如果用户没有安装应用,该地址仍可显示普通网页。

服务器创建支付订单时,把该地址作为银行网关的返回地址:

{
  "amount": 2500,
  "currency": "LKR",
  "orderId": "ORDER_123",
  "returnUrl": "https://pay.example.com/payment/result?orderId=ORDER_123",
  "notifyUrl": "https://api.example.com/payments/ipg/notify"
}

银行要求哪些字段、是否需要签名以及金额采用什么单位,都要以银行提供的协议为准。

Flutter 可以通过 Deep Link 处理库接收链接。下面以 app_links 为例说明基本思路,具体 API 可能随依赖版本变化:

import 'dart:async';

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

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

  @override
  State<PaymentLinkHandler> createState() => _PaymentLinkHandlerState();
}

class _PaymentLinkHandlerState extends State<PaymentLinkHandler> {
  final AppLinks _appLinks = AppLinks();
  StreamSubscription<Uri>? _subscription;

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

    _subscription = _appLinks.uriLinkStream.listen((Uri uri) {
      _handlePaymentLink(uri);
    });
  }

  Future<void> _handlePaymentLink(Uri uri) async {
    if (uri.host != 'pay.example.com' ||
        uri.path != '/payment/result') {
      return;
    }

    final orderId = uri.queryParameters['orderId'];
    if (orderId == null || orderId.isEmpty) {
      return;
    }

    // 不要直接相信 URL 中的 success、status 等参数。
    // 应调用业务服务器查询并核验订单状态。
    final status = await queryPaymentStatus(orderId);

    if (!mounted) return;

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

  Future<String> queryPaymentStatus(String orderId) async {
    // 示例:调用自己的后端接口。
    // GET https://api.example.com/payments/$orderId/status
    throw UnimplementedError();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('正在处理支付结果…')),
    );
  }
}

除 Dart 代码外,还需要完成相应的平台配置:

  • Android:在 AndroidManifest.xml 中声明对应的 intent-filter,并在域名下部署 assetlinks.json。
  • iOS:启用 Associated Domains,并在域名下部署 apple-app-site-association。
  • 服务器:确保返回地址使用 HTTPS,并正确处理银行可能发起的 GET 或 POST 请求。

只添加 Flutter 端的监听代码,系统还无法识别这些链接。平台设置和域名配置也必须完成。

方案二:使用自定义 URL Scheme

如果银行允许将非 HTTPS 地址设为 returnUrl,也可以使用自定义 Scheme:

tollpay://payment/result?orderId=ORDER_123

应用收到链接后的处理方式与前面的示例相近:

void handleUri(Uri uri) {
  if (uri.scheme != 'tollpay' ||
      uri.host != 'payment' ||
      uri.path != '/result') {
    return;
  }

  final orderId = uri.queryParameters['orderId'];
  if (orderId == null) return;

  // 使用 orderId 向服务端查询真实支付结果。
}

这种方式配置比较简单,但存在一些限制:

  • 银行网关可能只接受 https:// 返回地址;
  • 其他应用也可能注册同一个自定义 Scheme;
  • 用户未安装应用时,链接通常无法正常打开;
  • 浏览器或银行页面可能阻止跳转到自定义 Scheme。

因此,只要能够使用 App Link 或 Universal Link,就不宜将自定义 Scheme 作为唯一的返回机制。

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

如果银行允许通过 WebView 支付,可以监听页面导航。网页即将进入约定的返回地址时,阻止 WebView 继续加载,并关闭支付页面。

下面使用 webview_flutter 展示基本逻辑:

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

class PaymentWebViewPage extends StatefulWidget {
  const PaymentWebViewPage({
    super.key,
    required this.paymentUrl,
  });

  final String paymentUrl;

  @override
  State<PaymentWebViewPage> createState() => _PaymentWebViewPageState();
}

class _PaymentWebViewPageState extends State<PaymentWebViewPage> {
  late final WebViewController _controller;

  @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.host == 'pay.example.com' &&
                uri.path == '/payment/result') {
              final orderId = uri.queryParameters['orderId'];

              Navigator.of(context).pop(orderId);
              return NavigationDecision.prevent;
            }

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

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

打开 WebView 并获取结果:

final orderId = await Navigator.of(context).push<String>(
  MaterialPageRoute(
    builder: (_) => PaymentWebViewPage(
      paymentUrl: paymentUrl,
    ),
  ),
);

if (orderId != null) {
  final status = await queryPaymentStatus(orderId);
  // 根据服务端返回的状态更新界面。
}

不要根据页面按钮文字、DOM 结构或 JavaScript 点击事件判断支付是否完成。银行页面可能改版,支付结果也可能仍在处理中。更可靠的方式是监听固定的返回 URL,再由服务器核验订单状态。

部分银行、3-D Secure 验证服务或电子钱包明确禁止使用嵌入式 WebView。遇到这种情况,需要改用系统浏览器或银行提供的官方 SDK。

返回地址是普通 HTTPS 页面时如何唤起应用

有些 IPG 只允许填写 HTTPS 返回地址,又无法直接配置 App Link。此时可以通过服务器的返回页面尝试唤起应用:

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>支付处理</title>
</head>
<body>
  <p>正在返回应用,请稍候……</p>

  <script>
    const params = new URLSearchParams(window.location.search);
    const orderId = params.get('orderId');

    if (orderId) {
      window.location.href =
        `tollpay://payment/result?orderId=${encodeURIComponent(orderId)}`;
    }
  </script>
</body>
</html>

这种中转方式无法保证在所有浏览器中自动执行。浏览器可能要求用户确认,也可能阻止没有用户操作触发的外部应用跳转。因此,页面还应提供一个可以手动点击的备用按钮:

<a id="open-app" href="#">返回应用</a>

<script>
  const params = new URLSearchParams(window.location.search);
  const orderId = params.get('orderId');
  const link =
    `tollpay://payment/result?orderId=${encodeURIComponent(orderId ?? '')}`;

  document.getElementById('open-app').href = link;
</script>

支付结果必须在服务端核验

返回应用只能说明用户离开了支付页面,并不能证明付款成功。实际流程中可能出现以下情况:

  • 用户取消支付后仍被重定向;
  • 请求参数遭到人为修改;
  • 银行回调尚未到达;
  • 支付已经扣款,但返回页面加载失败;
  • 应用被系统回收,没有收到 Deep Link;
  • 回调被重复发送。

Flutter 收到 orderId 后,应向自己的后端发起请求:

GET /payments/ORDER_123/status
Authorization: Bearer <access-token>

后端根据银行的服务器通知或订单查询接口,返回可信的支付状态:

{
  "orderId": "ORDER_123",
  "status": "PAID"
}

常见状态可以设计为:

PENDING
PAID
FAILED
CANCELLED
EXPIRED

如果第一次查询得到的状态仍是 PENDING,应用可以短时间轮询,或提示用户稍后刷新。不能因为返回 URL 中含有 status=success,就直接将欠款标记为已支付。

还需要注意的安全问题

  • orderId 应由服务端生成,并绑定当前登录用户。
  • 金额、币种和欠款信息应由服务端确定,不能采用客户端提交的值。
  • 银行通知中的签名、证书、摘要或 MAC 必须经过验证,具体方式以银行协议为准。
  • 回调接口应支持幂等处理,避免同一支付通知重复到达时重复入账。
  • 密钥、商户私钥和银行 API 凭据不能放在 Flutter 应用中。
  • Deep Link 只应携带订单标识等非敏感数据,不能包含银行卡信息、访问令牌或完整支付凭证。
  • 严格校验返回链接的 scheme、host、path 和参数,避免将任意链接当成支付结果处理。
  • 用户手动关闭浏览器或 WebView 后,应用仍应允许其重新查询订单状态。

具体采用哪种返回方式,要看银行 IPG 支持哪些 returnUrl 类型,以及是否允许使用 WebView。通常可以依次考虑 App Link 或 Universal Link、系统浏览器配合 HTTPS 中转页、自定义 URL Scheme。只有银行明确允许时,才使用 WebView。

备注:内容仅供参考。