如何在 Flutter 中实现双向 payment gateway(收款与退款)?
结论
Flutter 客户端无法独立实现完整的“双向 payment gateway”。合理的职责划分是:
- Flutter 展示支付方式,调用支付 SDK,发起支付并显示结果。
- 自有后端创建支付订单、验证支付结果、接收 Webhook、发起退款,并查询退款状态。
- 支付网关通过 UPI、银行卡、钱包等通道完成实际扣款和原路退款。
- “退款”和“向用户打款”需要分开设计。退款关联原支付订单;主动转账通常需要使用网关的 Payout/Transfer 产品,并完成收款人验证和额外的合规流程。
如果只是撤销一笔已经完成的交易,应优先调用支付网关的 Refund API,把款项退回原支付方式。不要在 Flutter 中保存银行卡或 UPI 凭据,再发起所谓的反向交易。
推荐架构
典型调用关系如下:
Flutter App
│
├── 创建订单 ───────────────> 自有后端
│ │
│ ├── Create Payment ──> Payment Gateway
│ │
│<──── paymentSession/token ───┘
│
├── 调起 Gateway Flutter SDK
│
└── 查询最终状态 ────────────> 自有后端
Payment Gateway
└── Webhook ─────────────────> 自有后端
├── 验签
├── 更新订单
└── 触发业务履约
Flutter App
└── 申请退款 ────────────────> 自有后端
└── Refund API ──────> Payment Gateway
客户端收到的“支付成功”只能用于界面提示,不能作为发货、充值或记账的唯一依据。后端需要主动查询支付网关,或验证 Webhook 签名后,再确认最终支付结果。
先定义支付和退款状态
不要只用一个 success 布尔值表示交易状态。UPI 等支付方式可能需要等待用户授权,也可能出现延迟确认或退款处理中等情况,因此应分别维护支付状态和退款状态。
PaymentStatus:
CREATED
PENDING
PAID
FAILED
CANCELLED
EXPIRED
RefundStatus:
REQUESTED
PROCESSING
SUCCEEDED
FAILED
订单表至少要保存以下数据:
orders
- id
- user_id
- amount_minor
- currency
- gateway
- gateway_order_id
- gateway_payment_id
- payment_status
- created_at
- updated_at
refunds
- id
- order_id
- amount_minor
- reason
- idempotency_key
- gateway_refund_id
- refund_status
- created_at
- updated_at
金额应以最小货币单位的整数保存。例如,₹100.50 可以保存为 10050 paise,避免使用浮点数造成精度误差。
收款流程
1. Flutter 向后端创建业务订单
客户端只提交商品或业务标识,不应决定最终金额。后端需要根据商品、优惠和账户状态重新计算价格。
import 'dart:convert';
import 'package:http/http.dart' as http;
class PaymentApi {
PaymentApi(this.baseUrl, this.authToken);
final String baseUrl;
final String authToken;
Future<Map<String, dynamic>> createPayment({
required String productId,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/payments'),
headers: {
'Authorization': 'Bearer $authToken',
'Content-Type': 'application/json',
},
body: jsonEncode({
'productId': productId,
}),
);
if (response.statusCode != 201) {
throw Exception('Unable to create payment');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
}
后端先创建本地订单,再调用具体网关的 Create Order、Payment Intent 或 Payment Session API,最后把客户端需要的临时参数返回给 Flutter。
响应可以采用类似的结构:
{
"orderId": "ord_01J...",
"amount": 10050,
"currency": "INR",
"gateway": "example_gateway",
"paymentSession": "short_lived_session_value"
}
paymentSession 只是示意字段。实际字段名和内容由所选支付服务商决定,不能直接套用到其他网关。
2. Flutter 调起支付网关 SDK
各家服务商的 Flutter SDK 接口并不统一,可以通过适配层抽象出一套通用接口:
abstract interface class PaymentGatewayClient {
Future<PaymentLaunchResult> startPayment({
required String orderId,
required String paymentSession,
});
}
class PaymentLaunchResult {
const PaymentLaunchResult({
required this.reference,
required this.userCancelled,
});
final String? reference;
final bool userCancelled;
}
页面只依赖这个抽象接口:
Future<void> pay(
PaymentApi api,
PaymentGatewayClient gateway,
String productId,
) async {
final session = await api.createPayment(productId: productId);
final result = await gateway.startPayment(
orderId: session['orderId'] as String,
paymentSession: session['paymentSession'] as String,
);
if (result.userCancelled) {
return;
}
// SDK 返回成功不等于资金已经最终到账。
// 下一步仍需向自有后端查询订单状态。
}
支付网关的密钥、Webhook Secret、私钥等机密信息不能写入 Flutter 应用。移动端安装包可以被反编译,其中的长期密钥无法作为安全秘密保存。
3. 后端确认最终支付结果
后端需要同时处理两种确认方式:
- 接收支付网关发送的 Webhook;
- 使用支付 ID 或订单 ID 主动查询网关。
下面是与具体框架无关的伪代码:
async function handlePaymentWebhook(request, response) {
const rawBody = request.rawBody;
const signature = request.headers["x-gateway-signature"];
if (!gateway.verifyWebhook(rawBody, signature)) {
return response.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody);
if (await webhookEventExists(event.id)) {
return response.status(200).send("ok");
}
await database.transaction(async (tx) => {
await tx.insertWebhookEvent(event.id);
if (event.type === "payment.succeeded") {
const payment = await gateway.fetchPayment(event.paymentId);
await tx.markOrderPaid({
gatewayOrderId: payment.orderId,
gatewayPaymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
});
}
});
return response.status(200).send("ok");
}
处理成功事件时,还要核对订单号、金额、币种和商户账户,不能只检查事件类型。
Webhook 可能重复发送,也可能乱序到达,因此处理逻辑必须具备幂等性。业务履约也要通过唯一订单约束避免重复充值或发货。
退款流程
1. Flutter 提交退款申请
Flutter 只向自有后端提交订单信息和退款原因,不直接调用网关的 Refund API。
Future<Map<String, dynamic>> requestRefund({
required String baseUrl,
required String authToken,
required String orderId,
required String reason,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/orders/$orderId/refunds'),
headers: {
'Authorization': 'Bearer $authToken',
'Content-Type': 'application/json',
'Idempotency-Key': 'refund-$orderId',
},
body: jsonEncode({
'reason': reason,
}),
);
if (response.statusCode != 202 && response.statusCode != 200) {
throw Exception('Unable to request refund');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
生产环境中的幂等键不应永久固定为 refund-$orderId。如果一笔订单允许多次部分退款,需要为每次合法退款生成独立的幂等键,并将其保存在数据库中。
2. 后端校验并调用 Refund API
后端发起退款前,至少要检查:
- 当前用户是否有权操作该订单;
- 原支付是否成功;
- 订单是否已经全额退款;
- 本次退款金额是否超过剩余可退金额;
- 是否超过网关或具体支付方式规定的退款期限;
- 是否已有相同的退款请求正在处理;
- 商户余额或网关账户是否满足退款条件。
async function createRefund(userId, orderId, amount, idempotencyKey) {
return database.transaction(async (tx) => {
const previous = await tx.findRefundByIdempotencyKey(idempotencyKey);
if (previous) {
return previous;
}
const order = await tx.lockOrder(orderId);
if (!order || order.userId !== userId) {
throw new Error("ORDER_NOT_FOUND");
}
if (order.paymentStatus !== "PAID") {
throw new Error("PAYMENT_NOT_REFUNDABLE");
}
const refundableAmount =
order.amountMinor - await tx.sumSuccessfulAndPendingRefunds(order.id);
if (amount <= 0 || amount > refundableAmount) {
throw new Error("INVALID_REFUND_AMOUNT");
}
const refund = await tx.createRefund({
orderId: order.id,
amountMinor: amount,
status: "REQUESTED",
idempotencyKey,
});
return refund;
});
}
调用支付网关时,应把本地退款 ID 或幂等键一并传给网关。如果网关原生支持 Idempotency-Key,也应使用该字段。
async function submitRefund(refund) {
try {
const gatewayRefund = await gateway.createRefund({
paymentId: refund.gatewayPaymentId,
amount: refund.amountMinor,
idempotencyKey: refund.idempotencyKey,
metadata: {
localRefundId: refund.id,
},
});
await updateRefund(refund.id, {
gatewayRefundId: gatewayRefund.id,
status: mapGatewayRefundStatus(gatewayRefund.status),
});
} catch (error) {
// 网络超时不代表网关一定没有创建退款。
// 应先用幂等键或本地退款 ID 查询网关,再决定是否重试。
await scheduleRefundReconciliation(refund.id);
}
}
退款通常不会同步完成。接口返回成功往往只表示网关已经接受请求,最终状态仍需通过退款 Webhook 或定时查询确认。
“原路退款”和“主动打款”不是同一件事
如果要退回一笔已经完成的付款,应调用 Refund API。网关通常会根据原交易记录,把款项退回银行卡、UPI 账户或钱包等原支付渠道。平台不需要,也通常无法取得完整的付款凭据。
以下需求属于 Payout,而不是 Refund:
- 向没有原始订单的用户转账;
- 把退款转入原付款方式以外的账户;
- 发放佣金、奖励或商户结算款;
- 用户把余额提现到银行账户或 UPI ID。
Payout 一般需要单独开通,可能还涉及 KYC、收款人管理、银行账户验证、UPI VPA 验证、限额、手续费和当地监管要求。具体支持哪些能力,取决于支付网关和商户所在地区。不能假设所有收款网关都支持主动打款。
不要在 Flutter 中保存付款方式敏感信息
“已保存的付款方式”应指支付网关提供的 token、customer ID 或 payment method reference,而不是以下信息:
- 银行卡号和 CVV;
- UPI PIN;
- 网银密码;
- 可以直接完成扣款的长期密钥;
- 未加密的银行账户信息。
保存付款方式时,通常应使用网关提供的 Tokenization 或 Vault 功能:
用户付款信息
-> 网关托管页面或官方 SDK
-> 网关生成 payment_method_token
-> 后端保存 token 与 gateway_customer_id
即使已经保存 token,退款通常仍应引用原始 paymentId,而不是使用 token 发起一笔“反向付款”。保存付款方式后的再次扣款也可能需要用户授权,或需要遵守 recurring/mandate 规则。不能因为已有 token,就默认可以任意扣款。
API 设计建议
客户端接口可以设计成以下形式:
POST /payments
GET /orders/{orderId}
POST /orders/{orderId}/refunds
GET /refunds/{refundId}
POST /webhooks/payment-gateway
创建退款时,响应不必伪装成已经同步成功:
{
"refundId": "ref_01J...",
"orderId": "ord_01J...",
"amount": 10050,
"currency": "INR",
"status": "PROCESSING"
}
Flutter 可以轮询 GET /refunds/{refundId},也可以通过业务消息、推送或页面刷新来展示最终结果。
上线前需要特别检查
- 使用支付网关官方提供的 Flutter SDK,不要自行拼装 UPI 深链后直接认定支付成功。
- 所有金额、订单归属和退款资格都由后端校验。
- 密钥只保存在服务端的密钥管理系统或安全环境变量中。
- Webhook 使用原始请求体验签,并防止事件重放。
- 支付、退款和履约操作都要实现幂等控制。
- 针对 Webhook 丢失、网络超时和长期
PENDING状态建立定时对账任务。 - 记录网关订单 ID、支付 ID、退款 ID 和状态变化,但不要把敏感支付数据写入日志。
- 分别测试支付成功、用户取消、支付超时、重复回调、部分退款、重复退款和退款失败。
- 明确退款到账时间只是预计时间,实际速度可能受网关、银行和支付方式影响。
- 根据业务所在地核实 PCI DSS、KYC、税务、数据保护和支付监管要求。
可维护的方案不是在 Flutter 中寻找一个同时处理“收款和返钱”的插件,而是由 Flutter 通过网关 SDK 完成用户交互,再由后端围绕订单、Webhook、Refund API 和 Payout API 建立可审计、可重试且幂等的资金流程。
备注:内容仅供参考。
