如何在没有 Flutter SDK 时集成 payment gateway?
结论
没有 Flutter SDK 也可以接入。只要支付网关支持创建交易、浏览器跳转和服务端支付结果通知,就能完成整个支付流程。
建议按下面的流程处理:
- Flutter App 请求业务后端创建订单。
- 业务后端调用 payment gateway 的 Payment Request API。
- Flutter 使用 WebView 打开网关返回的
transaction redirect URL。 - 支付结束后,通过
return URL返回 App,或跳转到 App 能识别的结果页。 - 业务后端接收 webhook/callback,或主动调用网关 API 查询交易状态。
- Flutter 向业务后端查询订单状态,然后显示成功、失败、取消或处理中。
WebView 捕获到某个 URL,只能说明页面发生了跳转,不能证明支付成功。支付结果必须由服务端验证。
推荐的支付流程
较安全的完整流程如下:
Flutter App
│
│ 1. 请求创建订单
▼
业务后端
│
│ 2. 调用 OnePay Payment Request API
▼
Payment Gateway
│
│ 3. 返回 transaction redirect URL
▼
Flutter WebView
│
│ 4. 用户完成、取消或支付失败
▼
return URL / cancel URL
│
│ 5. Flutter 捕获跳转并关闭 WebView
▼
Flutter App
│
│ 6. 查询业务后端中的订单状态
▼
业务后端
│
│ 7. 根据 webhook 或查询 API 确认状态
▼
显示最终支付结果
如果 OnePay 使用的字段名称与本文不同,请以其文档为准,把文中的 returnUrl、callbackUrl、transactionId 等名称替换成实际字段,不要自行假设网关支持某个参数。
Payment Request 应由后端发起
不要在 Flutter 中保存 merchant secret、API secret 或签名密钥,也不建议由 Flutter 直接调用支付网关。移动端安装包可以被反编译,客户端中的密钥不能视为安全凭据。
Flutter 只需要向自己的后端发送业务信息:
final response = await http.post(
Uri.parse('https://api.example.com/payments'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({
'orderId': orderId,
}),
);
if (response.statusCode != 200) {
throw Exception('Unable to create payment');
}
final result = jsonDecode(response.body) as Map<String, dynamic>;
final paymentId = result['paymentId'] as String;
final redirectUrl = result['redirectUrl'] as String;
后端应根据 orderId 读取并校验金额、币种、商品和收款账户,不能直接相信客户端传来的金额。
创建支付请求时,后端通常还需要提供:
- 商户订单号
- 金额和币种
return URLcancel URL- 服务端通知地址,也就是 webhook/callback URL
- 网关要求的签名或认证信息
后端保存网关返回的交易编号和重定向地址,再把内部 paymentId 和 redirectUrl 返回给 Flutter。
在 Flutter WebView 中捕获返回地址
下面的示例采用 webview_flutter 的常见写法。插件版本不同,具体 API 也可能变化,请按项目当前使用的版本调整。
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class PaymentWebViewPage extends StatefulWidget {
const PaymentWebViewPage({
super.key,
required this.redirectUrl,
required this.paymentId,
});
final String redirectUrl;
final String paymentId;
@override
State<PaymentWebViewPage> createState() => _PaymentWebViewPageState();
}
class _PaymentWebViewPageState extends State<PaymentWebViewPage> {
late final WebViewController _controller;
bool _returnHandled = false;
static const String returnOrigin = 'https://pay.example.com';
static const String returnPath = '/payment/return';
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: _handleNavigation,
onWebResourceError: (error) {
// 主页面加载失败时可以显示重试界面。
// 网络错误本身不代表支付失败。
},
),
)
..loadRequest(Uri.parse(widget.redirectUrl));
}
NavigationDecision _handleNavigation(NavigationRequest request) {
final uri = Uri.tryParse(request.url);
if (uri != null && _isTrustedReturnUrl(uri)) {
_finishWebFlow();
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
bool _isTrustedReturnUrl(Uri uri) {
final expected = Uri.parse(returnOrigin);
return uri.scheme == expected.scheme &&
uri.host == expected.host &&
uri.path == returnPath;
}
void _finishWebFlow() {
if (_returnHandled || !mounted) {
return;
}
_returnHandled = true;
Navigator.of(context).pop(
PaymentWebResult(
paymentId: widget.paymentId,
returnedFromGateway: true,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('支付'),
),
body: WebViewWidget(controller: _controller),
);
}
}
class PaymentWebResult {
const PaymentWebResult({
required this.paymentId,
required this.returnedFromGateway,
});
final String paymentId;
final bool returnedFromGateway;
}
WebView 关闭后,调用页面可以查询后端:
final webResult = await Navigator.of(context).push<PaymentWebResult>(
MaterialPageRoute(
builder: (_) => PaymentWebViewPage(
redirectUrl: redirectUrl,
paymentId: paymentId,
),
),
);
if (!mounted) return;
if (webResult == null) {
// 用户主动关闭了支付页面。
// 此时支付仍可能已经提交,因此仍应向后端查询一次。
}
final payment = await paymentApi.getPaymentStatus(paymentId);
if (!mounted) return;
switch (payment.status) {
case PaymentStatus.succeeded:
showSuccessPage();
break;
case PaymentStatus.failed:
showFailurePage(payment.message);
break;
case PaymentStatus.cancelled:
showCancelledMessage();
break;
case PaymentStatus.pending:
showPendingPage();
break;
}
这里要完整匹配 scheme、host 和 path,不要使用下面这种宽松的判断:
if (request.url.contains('success')) {
// 不安全,也容易误判
}
攻击者控制的地址、查询参数或普通网页内容中都可能出现 success。
不要根据返回 URL 直接判定成功
假设网关跳转到下面的地址:
https://pay.example.com/payment/return?status=success&transactionId=123
Flutter 可以在捕获这次跳转后关闭 WebView,但不能仅凭 status=success 把订单改成已支付。查询参数可能遭到修改,返回页面也可能被重复打开。
Flutter 应查询后端:
final payment = await paymentApi.getPaymentStatus(paymentId);
后端再返回统一的状态,例如:
{
"paymentId": "pay_8f21c7",
"orderId": "order_10001",
"status": "SUCCEEDED"
}
后端需要根据 OnePay 实际提供的能力,通过下面一种或两种方式确认状态:
- 接收网关发送的服务端 webhook/callback。
- 调用网关的交易查询 API 主动查询。
如果 OnePay 同时支持这两种方式,通常以 webhook 为主,主动查询用于补偿。
后端如何处理支付通知
下面是通用伪代码。签名字段、请求格式和状态值都必须按照 OnePay 文档实现:
app.post('/webhooks/onepay', async (req, res) => {
const payload = req.body;
const signatureValid = verifyOnePaySignature({
payload,
signature: req.headers['x-signature'],
secret: process.env.ONEPAY_WEBHOOK_SECRET,
});
if (!signatureValid) {
return res.status(401).send('invalid signature');
}
const payment = await findPaymentByGatewayTransactionId(
payload.transactionId,
);
if (!payment) {
return res.status(404).send('payment not found');
}
if (
payload.amount !== payment.amount ||
payload.currency !== payment.currency
) {
return res.status(400).send('payment data mismatch');
}
await updatePaymentIdempotently({
paymentId: payment.id,
gatewayTransactionId: payload.transactionId,
gatewayStatus: payload.status,
});
return res.status(200).send('ok');
});
处理通知时,至少要验证以下信息:
- 签名、MAC 或网关规定的认证信息
- 商户编号
- 网关交易编号
- 商户订单号
- 金额
- 币种
- 当前订单状态
回调还必须支持幂等。同一条成功通知可能发送多次,系统不能因此重复发货、充值或记账。
成功、失败、取消和处理中应如何区分
建议在业务后端定义统一状态,不要让 Flutter 直接依赖网关返回的原始状态字符串:
CREATED
PENDING
SUCCEEDED
FAILED
CANCELLED
EXPIRED
状态映射必须以 OnePay 文档中的实际返回值为准。只有收到网关明确的成功状态,并完成签名、金额等校验,才能把订单状态改为 SUCCEEDED。
还需要注意这些情况:
- 用户关闭 WebView,不代表支付已经取消。
- WebView 加载失败,不代表交易失败。
- 用户返回 App 时,webhook 可能尚未到达。
- 网关返回页面显示成功时,后端状态仍可能是
PENDING。 - 即使 App 被系统杀死,支付仍可能在浏览器或银行 App 中完成。
状态为 PENDING 时,可以在短时间内轮询后端:
Future<PaymentStatus> waitForPaymentResult(String paymentId) async {
const delays = [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 3),
Duration(seconds: 5),
];
for (final delay in delays) {
final payment = await paymentApi.getPaymentStatus(paymentId);
if (payment.status != PaymentStatus.pending) {
return payment.status;
}
await Future<void>.delayed(delay);
}
return PaymentStatus.pending;
}
超过合理的轮询时间后,应显示”支付结果确认中”,并允许用户稍后在订单页面刷新,不要直接显示支付失败。
如何安全地返回 App
常用方式有两种。
返回 HTTPS 页面
让 payment gateway 跳转到自己的 HTTPS 地址:
https://pay.example.com/payment/return
Flutter WebView 捕获这个地址后关闭支付页,再查询后端。这种方式较为简单,也不依赖自定义 URL scheme。
使用 App Link 或 Universal Link
服务端返回页确认请求格式正常后,再跳转到:
https://app.example.com/payment/result?paymentId=pay_8f21c7
Android App Links 或 iOS Universal Links 可以把用户带回 App,适合支付过程中需要跳转到外部浏览器、银行 App 或第三方认证 App 的情况。
paymentId 只用于定位本地支付流程,不能包含密钥,也不能作为支付成功的证明。App 被唤起后仍需查询后端。
如果使用自定义 scheme,例如:
myapp://payment/result
还要防范其他 App 抢占相同 scheme。条件允许时,应优先使用经过域名验证的 App Links 或 Universal Links。
WebView 与外部应用跳转
部分支付页面会跳转到银行 App、电话、Intent 地址或其他非 HTTP scheme。WebView 需要拦截这些地址,再交给系统处理。
Future<NavigationDecision> handleNavigation(
NavigationRequest request,
) async {
final uri = Uri.tryParse(request.url);
if (uri == null) {
return NavigationDecision.prevent;
}
if (_isTrustedReturnUrl(uri)) {
_finishWebFlow();
return NavigationDecision.prevent;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
// 可使用 url_launcher,并限制允许打开的 scheme。
// 不要无条件打开任意自定义 scheme。
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
是否可以使用内嵌 WebView,还要确认支付网关、发卡行和 3-D Secure 页面的要求。有些支付流程依赖外部浏览器、Cookie、弹窗或银行 App,无法在内嵌 WebView 中正常完成。遇到这种情况,应改用系统浏览器,并通过 App Link 或 Universal Link 返回应用。
关键注意事项
- API secret 和签名密钥只能保存在后端。
- Flutter 不能直接决定订单是否已经支付。
- 使用 HTTPS,不要加载不受信任的明文 HTTP 页面。
- 严格匹配返回地址的域名和路径。
- 不要根据 WebView 页面标题、DOM 文本或 JavaScript 回调判断支付结果。
- 服务端需要验证签名、金额、币种、订单号和交易号。
- webhook 处理必须支持幂等。
- 为重复付款请求设置唯一订单号或幂等键。
- 保存必要的支付审计记录,但不要记录银行卡号、CVV、密钥或完整的敏感报文。
- 用户关闭页面、网络中断或 App 重启后,都应允许用户在订单页面重新查询状态。
- 只有后端状态变为
SUCCEEDED,才能执行发货、充值或开通权益。
正式接入前,应查看 OnePay 文档,确认它支持的返回地址参数、服务端通知方式、签名算法、交易查询接口和状态码定义。如果文档只提供浏览器返回地址,没有可信的 webhook,后端必须调用交易查询接口完成最终确认。如果两者都没有,就无法只靠 WebView 安全判断支付结果,需要向支付网关索取服务端验单方案。
备注:内容仅供参考。
