用户提交 Web 表单后如何自动返回 Flutter 应用
结论
用户提交银行支付页面后,Flutter 应用无法直接控制银行网页自动返回。一般需要按以下方式处理:
- 在银行支付网关中配置支付完成后的
returnUrl; - 让该地址跳转到 Flutter 应用支持的 Deep Link、Android App Link 或 iOS Universal Link;
- Flutter 收到链接并恢复运行后,根据订单号向业务服务器查询支付结果。
如果支付页面通过应用内 WebView 打开,也可以监听页面跳转,命中 returnUrl 时关闭 WebView。无论使用哪种方式,都不能只根据跳转参数判断支付成功。最终支付状态必须由服务端向银行支付网关核验。
推荐的处理流程
完整流程通常如下:
Flutter 应用
→ 请求业务服务器创建支付订单
→ 服务器向银行 IPG 创建支付会话
→ Flutter 打开银行支付页面
→ 用户填写表单并提交
→ 银行跳转到 returnUrl
→ returnUrl 唤起 Flutter 应用
→ Flutter 向业务服务器查询订单
→ 服务器向银行核验并返回最终支付状态
这个流程通常会用到两个不同的地址:
returnUrl:用户完成操作后,浏览器跳转到的页面,用于返回应用。callbackUrl或notifyUrl:银行服务器用于通知业务服务器支付结果的地址。
各家银行对这些参数的名称和调用方式可能不同,应以接入的 IPG 接口文档为准。有些网关只接受预先登记的 HTTPS 地址,不支持自定义 URL Scheme。
方案一:使用 App Link 或 Universal Link
生产环境通常优先使用 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。
备注:内容仅供参考。
