PAYATHON 2026

如何在没有 Flutter SDK 时集成 payment gateway

技术老齐

结论

即使没有 Flutter SDK,也可以完成接入。Flutter 端负责打开支付页面、监听跳转和展示结果。支付请求签名、订单状态确认及回调验签则由业务服务器处理。

接入流程可以这样设计:

  1. App 向业务服务器提交下单请求。
  2. 业务服务器调用 payment gateway 的 Payment Request API。
  3. App 获取 transaction redirect URL,使用 WebView 或系统浏览器打开。
  4. 支付流程结束后,网关跳转到预先配置的 return_url,App 随后关闭支付页面。
  5. 业务服务器接收网关的 webhook/callback,并调用交易查询接口确认状态。
  6. 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 后,服务端应按以下步骤处理:

  1. 读取回调的原始请求数据。
  2. 按照网关文档校验签名。
  3. 核对订单号、金额、币种和商户信息。
  4. 必要时调用交易查询 API 再次确认。
  5. 以幂等方式更新订单。
  6. 返回网关要求的 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 是否提供回调和查询接口,并核对相关签名要求。

如果网关允许配置自定义回跳地址,可以使用:

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 或交易查询结果后确定。

备注:内容仅供参考。