PAYATHON 2026

PHP 如何携带 POST data 自动重定向到支付网关

支付老李

结论

可以让客户只点击一次,但普通 HTTP 重定向做不到,也不适合使用 cURL。

process.php 校验数据、保存订单并生成 invoice number 后,向浏览器返回一个隐藏的 HTML <form>,再通过 JavaScript 自动提交。POST 请求会由客户浏览器发出,提交后浏览器进入支付网关页面,客户可以继续填写信用卡信息。

为什么 cURL 和普通重定向不适用

cURL 在服务器端发出请求:

process.php → thirdparty.com

支付网关返回的页面只会传回服务器,不会出现在客户的浏览器中。cURL 适合调用服务器 API,但不适合将客户带到支付页面。

普通重定向也解决不了这个问题:

header('Location: https://thirdparty.com/payment');

浏览器跟随 Location 时通常会发送 GET 请求,无法携带新的 POST body。

HTTP 307 或 308 可以保留原始 POST 方法和请求体,但它们只能重新发送客户提交给 process.php 的原始数据,无法可靠地加入刚刚生成的 invoice number,所以也不适合这个流程。

推荐流程

整个过程可以这样设计:

checkout.php
    ↓ 客户点击一次提交
process.php
    ↓ 校验数据
    ↓ 创建 pending 订单
    ↓ 生成 invoice number
    ↓ 保存数据库
    ↓ 输出隐藏表单并自动提交
thirdparty.com

process.php 需要完成两件事:

  1. 在服务器端创建订单并生成 invoice number。
  2. 输出一个 action 指向支付网关的 POST 表单,然后自动提交。

PHP 示例

下面是一个通用示例。实际使用时,需要按照支付网关文档调整字段名称、签名算法和支付地址。

<?php

declare(strict_types=1);

session_start();

$pdo = new PDO(
    'mysql:host=localhost;dbname=shop;charset=utf8mb4',
    'db_user',
    'db_password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]
);

// 不要直接信任或完整转发 $_POST。
$name  = trim((string) ($_POST['name'] ?? ''));
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$phone = trim((string) ($_POST['phone'] ?? ''));

// 金额和商品信息应根据服务器端购物车重新计算。
$amount = calculateCartTotal($_SESSION['cart'] ?? []);

if ($name === '' || $email === false || $amount <= 0) {
    http_response_code(422);
    exit('订单信息无效,请返回检查后重试。');
}

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare(
        'INSERT INTO orders
            (customer_name, customer_email, customer_phone, amount, status, created_at)
         VALUES
            (:name, :email, :phone, :amount, :status, NOW())'
    );

    $stmt->execute([
        'name'   => $name,
        'email'  => $email,
        'phone'  => $phone,
        'amount' => number_format($amount, 2, '.', ''),
        'status' => 'pending_payment',
    ]);

    $orderId = (int) $pdo->lastInsertId();

    // 示例格式。实际格式应满足支付网关对 invoice number 的要求。
    $invoiceNumber = sprintf('INV-%s-%08d', date('Ymd'), $orderId);

    $stmt = $pdo->prepare(
        'UPDATE orders
         SET invoice_number = :invoice_number
         WHERE id = :id'
    );

    $stmt->execute([
        'invoice_number' => $invoiceNumber,
        'id'             => $orderId,
    ]);

    $pdo->commit();
} catch (Throwable $exception) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    error_log($exception->getMessage());
    http_response_code(500);
    exit('订单创建失败,请稍后重试。');
}

$gatewayUrl = 'https://thirdparty.com/payment';

// 仅发送支付网关明确要求的字段。
$gatewayFields = [
    'invoice_number' => $invoiceNumber,
    'customer_name'  => $name,
    'customer_email' => $email,
    'customer_phone' => $phone,
    'amount'         => number_format($amount, 2, '.', ''),
    'currency'       => 'CNY',
    'return_url'     => 'https://shop.example.com/payment/return.php',
    'notify_url'     => 'https://shop.example.com/payment/notify.php',
];

// 如果网关要求签名,应严格使用其规定的字段顺序和算法。
// $gatewayFields['signature'] = createGatewaySignature($gatewayFields);

header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');

function escapeHtml(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="robots" content="noindex,nofollow">
    <title>正在进入支付页面</title>
</head>
<body>
    <p>订单已创建,正在进入支付页面……</p>

    <form
        id="gateway-form"
        method="post"
        action="<?= escapeHtml($gatewayUrl) ?>"
    >
        <?php foreach ($gatewayFields as $fieldName => $fieldValue): ?>
            <input
                type="hidden"
                name="<?= escapeHtml((string) $fieldName) ?>"
                value="<?= escapeHtml((string) $fieldValue) ?>"
            >
        <?php endforeach; ?>

        <noscript>
            <p>浏览器未启用 JavaScript,请点击下面的按钮继续付款。</p>
            <button type="submit">继续付款</button>
        </noscript>
    </form>

    <script>
        document.getElementById('gateway-form').submit();
    </script>
</body>
</html>

这里的 calculateCartTotal() 只是示意函数。业务系统需要根据服务器端保存的购物车重新计算金额,不能直接使用浏览器提交的金额。

如何减少无效 invoice number

只要支付网关要求在进入支付页面前提供 invoice number,就无法保证每个号码最终都能成功付款。客户可能关闭页面,也可能付款失败或主动取消。

订单状态可以设计为:

pending_payment → paid
                → failed
                → cancelled
                → expired

invoice number 创建后不要重复使用,也不要删除。订单长时间未付款时,可以将其标记为 expired,不应回收号码。

如果业务中的“发票号”具有正式财务含义,可以在付款前使用单独的 merchant_order_id 或 payment_reference,等付款成功后再生成正式发票号。能否这样处理,取决于支付网关是否允许用订单号代替 invoice number。

还要防止客户重复提交 checkout 表单,导致系统创建多个订单。一种常见做法是在 Page A 生成一次性提交令牌:

$_SESSION['checkout_token'] = bin2hex(random_bytes(32));

提交时将令牌一起传给 process.php,并在数据库中为该令牌设置唯一约束。这样即使客户双击按钮或刷新页面,系统也只会创建一个订单。

需要特别注意的安全问题

不要将整个 $_POST 原样转发给支付网关。应使用明确的字段白名单,只发送网关要求的数据。

订单金额、商品价格、折扣和运费都必须在服务器端重新计算。客户仍然可以修改隐藏表单中的值,因此处理支付结果回调时,必须再次核对 invoice number、金额、币种和订单状态。

支付状态应以支付网关的服务器回调或 webhook 为准,不能只依赖客户浏览器访问的 return_url。处理回调时,还要按照网关文档验证签名,并保证重复回调不会触发重复发货。

写入 HTML 属性的字段必须经过 htmlspecialchars() 转义,否则客户姓名、地址等内容可能引发 HTML 注入或 XSS。

如果网站启用了 Content Security Policy,内联 <script> 可能无法执行。此时可以使用带 nonce 的脚本,或者将自动提交代码放在允许加载的外部 JavaScript 文件中。

这个流程需要的不是“携带 POST 数据的重定向”,而是由 process.php 生成并自动提交一个 POST 表单。客户仍然只需在结账页点击一次,之后的提交和页面跳转会自动完成。

备注:内容仅供参考。