PAYATHON 2026

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

支付老李

结论

没有 Flutter SDK 也可以接入。只要支付网关支持创建交易、浏览器跳转和服务端支付结果通知,就能完成整个支付流程。

建议按下面的流程处理:

  1. Flutter App 请求业务后端创建订单。
  2. 业务后端调用 payment gateway 的 Payment Request API。
  3. Flutter 使用 WebView 打开网关返回的 transaction redirect URL。
  4. 支付结束后,通过 return URL 返回 App,或跳转到 App 能识别的结果页。
  5. 业务后端接收 webhook/callback,或主动调用网关 API 查询交易状态。
  6. 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 URL
  • cancel 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。

服务端返回页确认请求格式正常后,再跳转到:

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 安全判断支付结果,需要向支付网关索取服务端验单方案。

备注:内容仅供参考。