如何从 Web payment gateway 返回 Flutter App
结论
return_url 不是 Flutter 页面名称,而是支付完成后由银行网关跳转到的 URL。具体地址要按银行网关的接口规范填写。
常见做法有两种:
- 支付全程在 App 内的 WebView 中完成:把
return_url设置为自己的 HTTPS 地址,在 WebView 中拦截该地址,再关闭 WebView 并打开PaymentResultScreen。 - 支付过程中可能打开系统浏览器或银行 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
服务器负责:
- 接收银行返回的字段。
- 校验签名。
- 保存或查询交易状态。
- 返回普通的重定向响应,例如:
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 的平台配置。
Android App Links
在 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 Universal Links
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 链接。
推荐的完整支付流程
一个较为安全、稳定的支付流程如下:
- Flutter 向自己的后端申请创建订单。
- 后端生成订单号,并按照银行要求计算签名。
- Flutter 在 WebView 或系统浏览器中打开银行支付页面。
- 银行通过服务端通知把结果发送给商户后端。
- 银行将用户重定向到预先登记的 HTTPS
return_url。 - WebView 拦截该 URL,或由系统通过 App Link / Universal Link 唤起 App。
- App 根据订单号向自己的后端查询支付状态。
- 后端验证银行签名,或调用银行查询接口,然后返回最终状态。
- App 显示成功、失败、取消或处理中页面。
需要特别注意的事项
- 不要把银行密钥、签名私钥或商户密码写进 Flutter App。
- 不要信任 URL 中的
status=success,因为用户可以手动构造或修改 URL。 return_url只负责页面跳转,不能替代可靠的支付通知。- 每笔交易都应使用唯一订单号,并在后端进行幂等处理,避免重复入账。
- 用户可能在支付成功后直接关闭页面,所以不能只依赖浏览器返回。
- 支付流程应处理
success、failed、cancelled、pending和网络中断等状态。 - 如果银行要求使用白名单域名、固定路径或 HTTPS 证书,必须严格使用已登记的地址。
htmlpackage 主要用于解析或操作 HTML,无法完成 WebView 导航拦截、深度链接注册或支付结果验证,因此不是解决这个问题的主要工具。
备注:内容仅供参考。
