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 需要完成两件事:
- 在服务器端创建订单并生成 invoice number。
- 输出一个
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 表单。客户仍然只需在结账页点击一次,之后的提交和页面跳转会自动完成。
备注:内容仅供参考。
