PAYATHON 2026

Flutter WebView 如何通过 POST 发送 form data

支付老李

结论

当请求头声明为 application/x-www-form-urlencoded 时,请求体不能使用 jsonEncode(map),而要编码为表单格式:

key1=value1&key2=value2

在 Dart 中,可以通过 Uri(queryParameters: ...) 生成编码后的表单请求体,再由 WebView 直接发起 POST 请求。支付服务器返回的页面会保留在 WebView 的正常浏览上下文中,JavaScript、Cookie、页面跳转和表单交互也能继续工作。

问题原因

当前代码发送的其实是 JSON:

utf8.encode(jsonEncode(map))

例如:

{"amount":"100","orderId":"123"}

请求头声明的却是表单数据:

Content-Type: application/x-www-form-urlencoded

服务器通常期望收到这样的请求体:

amount=100&orderId=123

请求头和请求体的格式不一致,可能导致服务器无法解析支付参数、签名校验失败,或返回异常页面。

先通过 Dio 获取 HTML,再把 HTML 字符串放进 WebView,也不等于让 WebView 直接打开支付页面。这个过程可能丢失原始 URL、Cookie、重定向上下文、相对资源路径和浏览器提交过程。结果可能是支付页面能够显示,但后续操作无法正常进行。

直接通过 WebView 发送 POST

下面的示例适用于使用 WebViewController 的新版 webview_flutter API:

import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class PaymentPage extends StatefulWidget {
  const PaymentPage({super.key});

  @override
  State<PaymentPage> createState() => _PaymentPageState();
}

class _PaymentPageState extends State<PaymentPage> {
  late final WebViewController controller;

  final Map<String, dynamic> paymentData = {
    'orderId': 'ORDER_123',
    'amount': '100.00',
    'currency': 'INR',
    // 添加 PayGoal 要求的其他字段
  };

  @override
  void initState() {
    super.initState();

    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setBackgroundColor(Colors.transparent)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (request) {
            debugPrint('Navigate to: ${request.url}');
            return NavigationDecision.navigate;
          },
          onWebResourceError: (error) {
            debugPrint(
              'WebView error: ${error.errorCode} '
              '${error.description} '
              '${error.url}',
            );
          },
        ),
      );

    _loadPaymentPage();
  }

  Future<void> _loadPaymentPage() async {
    final formFields = paymentData.map(
      (key, value) => MapEntry(key, value.toString()),
    );

    final formBody = Uri(queryParameters: formFields).query;

    await controller.loadRequest(
      Uri.parse('https://uat.paygoal.in/order/v1/payment'),
      method: LoadRequestMethod.post,
      headers: const {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: Uint8List.fromList(utf8.encode(formBody)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: WebViewWidget(controller: controller),
      ),
    );
  }
}

如果项目使用的是问题中展示的旧版 WebView API,改法相同:不要调用 jsonEncode(map)。

final formFields = map.map(
  (key, value) => MapEntry(key.toString(), value.toString()),
);

final formBody = Uri(queryParameters: formFields).query;

controller.loadRequest(
  WebViewRequest(
    uri: Uri.parse('https://uat.paygoal.in/order/v1/payment'),
    headers: const {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    method: WebViewRequestMethod.post,
    body: Uint8List.fromList(
      utf8.encode(formBody),
    ),
  ),
);

假设参数如下:

final map = {
  'orderId': 'ORDER 123',
  'amount': '100.00',
};

编码后的请求体类似:

orderId=ORDER+123&amount=100.00

编码过程会正确转义特殊字符,因此不能用简单的字符串拼接代替。

如果 Android 无法正确处理 POST 请求头

部分 webview_flutter 版本或底层 Android WebView 无法正确处理带有自定义 Header 的 POST 请求,实际行为取决于插件版本和平台实现。

对于标准的 application/x-www-form-urlencoded 表单,可以让 WebView 加载一个自动提交的 HTML 表单。POST 请求由页面本身发起,这种方式通常更接近支付网关提供的普通网页接入流程。

import 'dart:convert';

String escapeHtml(String value) {
  return const HtmlEscape(HtmlEscapeMode.attribute).convert(value);
}

Future<void> submitPaymentForm(
  WebViewController controller,
  Map<String, dynamic> paymentData,
) async {
  const actionUrl =
      'https://uat.paygoal.in/order/v1/payment';

  final inputs = paymentData.entries.map((entry) {
    final name = escapeHtml(entry.key);
    final value = escapeHtml(entry.value.toString());

    return '<input type="hidden" name="$name" value="$value">';
  }).join();

  final html = '''
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1.0">
</head>
<body>
  <form id="paymentForm"
        method="post"
        action="$actionUrl">
    $inputs
  </form>

  <script>
    document.getElementById('paymentForm').submit();
  </script>
</body>
</html>
''';

  await controller.loadHtmlString(
    html,
    baseUrl: 'https://uat.paygoal.in/',
  );
}

字段名和字段值都必须经过 HTML 转义。如果参数中包含引号、& 或 <,未转义的内容可能破坏页面结构,也可能造成注入风险。

支付页面仍不能运行时的检查项

1. 确认字段类型和签名规则

支付接口通常会严格校验金额、订单号、商户编号、回调地址和签名等字段。生成签名时使用的原始值、字段顺序和字符编码,都必须符合 PayGoal 的接入文档。

不要再次对已经编码的值执行 URL 编码,否则会出现双重编码:

%2F → %252F

2. 不要手动设置错误的 Content-Length

请求体长度应由 WebView 或底层网络实现计算。错误的 Content-Length 可能导致服务器只能读取到部分数据。

3. 保持 JavaScript 可用

支付页面通常依赖 JavaScript:

controller.setJavaScriptMode(JavaScriptMode.unrestricted);

如果页面使用弹窗、新窗口、文件选择或第三方应用跳转,还需要处理相应的 WebView 平台能力。

4. 监听支付结果 URL

支付结束后,网关通常会跳转到成功、失败或取消地址。可以在导航回调中识别这些地址:

onNavigationRequest: (request) {
  final uri = Uri.parse(request.url);

  if (uri.host == 'example.com' &&
      uri.path == '/payment/success') {
    Navigator.of(context).pop(true);
    return NavigationDecision.prevent;
  }

  if (uri.host == 'example.com' &&
      uri.path == '/payment/failure') {
    Navigator.of(context).pop(false);
    return NavigationDecision.prevent;
  }

  return NavigationDecision.navigate;
},

判断条件应使用支付平台或后端配置的回调地址,不能只根据页面标题或 HTML 文本判断结果。

5. 不要把前端跳转当作最终支付凭证

WebView 中出现”支付成功”只能用于更新界面。订单的最终状态仍应由服务端通过 PayGoal 的回调通知或订单查询接口确认,不能仅凭客户端的跳转结果更新订单。

优先使用”编码后的表单请求体 + WebView POST”。如果当前插件或 Android 平台无法可靠地附加 POST 请求头,再使用自动提交的 HTML <form>。无论采用哪种方式,实际请求体都必须符合 application/x-www-form-urlencoded 格式,不能发送 JSON。

备注:内容仅供参考。