如何在没有 Flutter SDK 时集成 payment gateway
结论
即使没有 Flutter SDK,也可以完成接入。Flutter 端负责打开支付页面、监听跳转和展示结果。支付请求签名、订单状态确认及回调验签则由业务服务器处理。
接入流程可以这样设计:
- App 向业务服务器提交下单请求。
- 业务服务器调用 payment gateway 的 Payment Request API。
- App 获取
transaction redirect URL,使用 WebView 或系统浏览器打开。 - 支付流程结束后,网关跳转到预先配置的
return_url,App 随后关闭支付页面。 - 业务服务器接收网关的 webhook/callback,并调用交易查询接口确认状态。
- App 向业务服务器查询最终结果。
WebView 捕获到成功页面,只能确认页面发生了跳转,无法证明付款已经成功。最终状态必须以服务端验签后的回调或网关交易查询结果为准。
整体流程
Flutter App
│
│ 创建业务订单
▼
Business Server
│
│ Payment Request(含金额、订单号、return_url 等)
▼
Payment Gateway
│
│ 返回 transaction redirect URL
▼
Flutter App
│
│ WebView 打开支付页面
▼
用户完成支付 / 取消支付
│
├── 跳转 return_url,供 App 判断页面流程已经结束
│
└── webhook/callback 通知 Business Server
│
│ 验签或查询交易状态
▼
更新业务订单
│
Flutter App ◄─────────────────┘
查询最终支付状态
return_url 用于让页面回到 App。订单是否支付成功,则由 webhook 和查询接口确认。
在 Flutter WebView 中监听跳转
假设创建支付请求时配置了以下地址:
https://api.example.com/payment/return
Flutter 可以监听 WebView 的每次导航。页面进入该地址时,停止加载并关闭 WebView,然后向业务服务器查询订单状态。
以下代码使用 webview_flutter 的常见 API。不同版本的初始化方式可能略有差异,请以项目实际安装的版本为准。
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class PaymentPage extends StatefulWidget {
const PaymentPage({
super.key,
required this.redirectUrl,
required this.orderId,
});
final String redirectUrl;
final String orderId;
@override
State<PaymentPage> createState() => _PaymentPageState();
}
class _PaymentPageState extends State<PaymentPage> {
late final WebViewController _controller;
bool _finishing = false;
@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 == 'api.example.com' &&
uri.path == '/payment/return') {
_finishPaymentFlow();
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (WebResourceError error) {
// 不要仅凭 WebView 加载错误把订单标记为失败。
// 页面加载失败时,交易可能已经在网关侧完成。
},
),
)
..loadRequest(Uri.parse(widget.redirectUrl));
}
Future<void> _finishPaymentFlow() async {
if (_finishing) return;
_finishing = true;
// 此时只知道页面已返回,不代表支付成功。
// 可以先关闭页面,再由上一页查询业务服务器。
if (mounted) {
Navigator.of(context).pop(
PaymentPageResult(
orderId: widget.orderId,
shouldVerify: true,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('支付'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop(
PaymentPageResult(
orderId: widget.orderId,
shouldVerify: true,
),
);
},
),
),
body: WebViewWidget(controller: _controller),
);
}
}
class PaymentPageResult {
const PaymentPageResult({
required this.orderId,
required this.shouldVerify,
});
final String orderId;
final bool shouldVerify;
}
不要使用这种宽泛的判断:
if (request.url.contains('success')) {
// 标记支付成功
}
第三方页面或查询参数中也可能包含相同文本。应该解析 Uri,严格比较 scheme、host 和 path,并且只接受自己的回跳域名。
从支付页面返回后查询订单
支付页面关闭后,无论用户已经完成支付、取消支付,还是手动关闭了 WebView,都可以查询一次订单状态:
final result = await Navigator.of(context).push<PaymentPageResult>(
MaterialPageRoute(
builder: (_) => PaymentPage(
redirectUrl: redirectUrl,
orderId: orderId,
),
),
);
if (result?.shouldVerify == true) {
final status = await paymentApi.getOrderStatus(orderId);
switch (status) {
case PaymentStatus.paid:
// 展示支付成功页面
break;
case PaymentStatus.failed:
// 展示失败信息,并根据业务规则允许重新支付
break;
case PaymentStatus.cancelled:
// 用户取消或网关确认取消
break;
case PaymentStatus.pending:
// webhook 可能尚未到达,可稍后再次查询
break;
}
}
订单状态至少可以分为以下几种:
enum PaymentStatus {
pending,
paid,
failed,
cancelled,
}
网络超时、WebView 被关闭或 App 进入后台,都不能直接映射为 failed。这些情况通常只说明客户端暂时无法确定结果,应保留 pending 状态,再向服务器查询。
服务端确认支付结果
业务服务器需要保存业务订单号和 payment gateway 返回的交易标识。收到网关的 webhook/callback 后,服务端应按以下步骤处理:
- 读取回调的原始请求数据。
- 按照网关文档校验签名。
- 核对订单号、金额、币种和商户信息。
- 必要时调用交易查询 API 再次确认。
- 以幂等方式更新订单。
- 返回网关要求的 HTTP 响应。
以下代码仅展示处理结构。OnePay 的字段名称、签名算法、HTTP Header、交易状态值和响应格式,必须以其当前文档为准。
app.post('/payment/webhook', async (req, res) => {
const payload = req.body;
const signatureValid = verifyGatewaySignature({
rawBody: req.rawBody,
headers: req.headers,
});
if (!signatureValid) {
return res.status(400).send('invalid signature');
}
const order = await orderRepository.findByMerchantOrderId(
payload.merchantOrderId
);
if (!order) {
return res.status(404).send('order not found');
}
// 字段名仅用于说明要校验哪些内容。
if (
payload.amount !== order.amount ||
payload.currency !== order.currency
) {
return res.status(400).send('payment data mismatch');
}
const transaction = await queryGatewayTransaction(
payload.transactionId
);
if (transaction.status === 'SUCCESS') {
// updateAsPaid 必须是幂等操作,避免重复回调造成重复发货。
await orderRepository.updateAsPaid({
orderId: order.id,
gatewayTransactionId: transaction.id,
});
}
return res.status(200).send('ok');
});
App 应查询业务服务器,不能直接携带密钥调用 payment gateway:
GET /api/orders/{orderId}/payment-status
Authorization: Bearer <user-token>
响应可以使用业务自己的统一格式:
{
"orderId": "ORDER-20261023-001",
"status": "pending"
}
服务器还要确认当前登录用户有权查询该订单,以免他人通过可猜测的 orderId 获取订单信息。
回调尚未到达时的处理
用户返回 App 时,服务端可能还没有收到 webhook。App 可以在有限时间内轮询。先立即查询一次,再逐步延长查询间隔:
Future<PaymentStatus> waitForFinalStatus(String orderId) async {
const delays = [
Duration.zero,
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 3),
Duration(seconds: 5),
];
for (final delay in delays) {
if (delay != Duration.zero) {
await Future.delayed(delay);
}
final status = await paymentApi.getOrderStatus(orderId);
if (status != PaymentStatus.pending) {
return status;
}
}
return PaymentStatus.pending;
}
如果轮询结束后状态仍为 pending,可以提示“支付结果确认中”,并允许用户稍后刷新。不要因为几次查询没有结果就自动创建新订单,以免用户重复付款。
如果 payment gateway 不提供 webhook,业务服务器只能定期调用交易查询接口。采用这种方案前,应先确认 OnePay 当前 API 是否提供回调和查询接口,并核对相关签名要求。
使用自定义 URL Scheme 或 App Link
如果网关允许配置自定义回跳地址,可以使用:
myapp://payment/result?orderId=ORDER-20261023-001
WebView 捕获到该地址后关闭页面:
onNavigationRequest: (NavigationRequest request) {
final uri = Uri.tryParse(request.url);
if (uri?.scheme == 'myapp' &&
uri?.host == 'payment' &&
uri?.path == '/result') {
_finishPaymentFlow();
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
部分 payment gateway 只接受 https 回跳地址。遇到这种限制时,可以使用 HTTPS App Link 或 Universal Link,也可以先跳转到业务服务器的 return_url,再由该页面唤起 App。具体方案取决于网关对回跳地址的要求。
即使回跳 URL 带有 status=success 之类的参数,也只能将其用于界面提示,不能据此更新订单。
WebView 并非适合所有支付页面
支付页面可能包含 3-D Secure、银行 App 跳转、短信验证、弹窗、多窗口或外部 URL Scheme。嵌入式 WebView 未必能完整处理这些行为,有些网关也会明确要求使用系统浏览器。
接入前应检查 OnePay 文档是否允许使用 WebView。如果文档没有明确说明,需要测试以下情况:
- Android 和 iOS 的真实设备;
- 3-D Secure 验证;
- 银行 App 或钱包唤起;
- 用户取消和返回;
- WebView 被关闭;
- App 进入后台后被系统回收;
- webhook 延迟或重复到达;
- 网络中断后重新打开 App。
如果 WebView 无法兼容认证流程,可以改用系统浏览器或 Custom Tabs,再通过 App Link、Universal Link 或自定义 URL Scheme 返回 App。无论使用哪种页面容器,服务端确认交易状态的流程都不变。
安全注意事项
- 不要将 merchant secret、API secret、私钥或签名密钥放进 Flutter 安装包。
- Payment Request 和签名计算应由服务端完成。
- 只允许加载预期的 HTTPS 域名,并拦截未知 URL Scheme。
- 不要根据 WebView 页面标题、HTML 内容或 JavaScript 返回值判断支付成功。
- 不要信任
return_url中未经服务端验证的金额和状态。 - webhook 必须验签,同时核对订单号、金额、币种和商户身份。
- 订单更新、积分发放和发货逻辑必须支持幂等处理。
- 每次支付尝试都应保存独立的交易标识,避免混用新旧 redirect URL。
- App 重启后应能根据业务订单号恢复查询,不能只将状态保存在页面内存中。
- 日志中不要记录银行卡信息、完整签名、密钥或其他敏感支付数据。
WebView 返回后的处理包括捕获受信任的回跳地址、关闭支付页和触发订单查询。交易是否完成,必须由业务服务器验证 webhook 或交易查询结果后确定。
备注:内容仅供参考。
