PAYATHON 2026

如何在 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. 后端确认最终支付结果

后端需要同时处理两种确认方式:

  1. 接收支付网关发送的 Webhook;
  2. 使用支付 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 建立可审计、可重试且幂等的资金流程。

备注:内容仅供参考。