PHP-Curl 无法 POST 参数并重定向到 Payment Gateway
结论
cURL 发出的 POST 请求,与浏览器随后执行 header("Location: ...") 产生的请求,是两个彼此独立的 HTTP 请求。
第一次请求由 PHP 服务器通过 cURL 发出,$fields 只存在于这次服务器端请求中。第二次请求由用户浏览器发出,通常是一个新的 GET 请求,不会携带之前通过 cURL 提交的 merchantId、amount 等参数。
如果 Payment Gateway 要求用户浏览器携带参数进入付款页面,通常需要生成一个 HTML 表单,再通过 JavaScript 自动提交。只有当 Gateway 明确提供了“服务器端创建支付并返回付款 URL”的 API,才应该先通过 cURL 请求接口,然后跳转到接口返回的 URL。
为什么 header("Location: ...") 会丢失参数
当前代码的执行流程如下:
- 用户浏览器向
create.php提交表单。 - PHP 将数据保存到数据库。
- PHP 服务器通过 cURL 向 Gateway 发出一次 POST 请求。
- Gateway 返回的内容保存在
$server_output中。 - PHP 没有处理
$server_output,而是向浏览器返回Location响应头。 - 浏览器重新访问 Gateway 地址,但这次请求不包含
$fields。
因此,下面的代码不会把 cURL 请求中的 POST 数据传给浏览器:
$server_output = curl_exec($ch);
header('Location: https://test.paydollar.com/b2cDemo/eng/payment/payForm.jsp');
header("Location: ...") 只能通知浏览器访问另一个 URL,无法附带任意 POST 请求体。默认情况下,302 重定向通常会让浏览器向目标地址发起 GET 请求。
改用 307 或 308 也不能直接解决这个问题。浏览器最多只会重新提交它原先发送给 create.php 的请求体,不会提交 PHP 后来构造的 $fields。
另外,示例中的:
'merchantId' => 'sth',
'secureHash' => 'sth'
如果不是脱敏占位符,而是实际提交的值,商户编号或签名校验会直接失败。merchantId、测试环境地址以及 secureHash 的计算方式,都必须以 Gateway 提供的商户资料和当前接口文档为准。
推荐方案:让浏览器自动 POST 到 Gateway
数据库写入完成后,可以输出一个隐藏表单,将 $fields 放入表单字段并自动提交:
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 此处执行输入验证和数据库写入。
$fields = [
'amount' => number_format((float) $amount, 2, '.', ''),
'merchantId' => $merchantId,
'orderRef' => $orderRef,
'currCode' => '344',
'mpsMode' => 'NIL',
'successUrl' => 'https://www.example.com/payment/success',
'failUrl' => 'https://www.example.com/payment/fail',
'cancelUrl' => 'https://www.example.com/payment/cancel',
'payType' => 'N',
'lang' => 'E',
'payMethod' => 'CC',
'secureHash' => $secureHash,
];
$gatewayUrl = 'https://test.paydollar.com/b2cDemo/eng/payment/payForm.jsp';
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>正在转到付款页面</title>
</head>
<body>
<p>正在连接付款平台,请稍候……</p>
<form id="payment-form"
method="post"
action="<?= htmlspecialchars($gatewayUrl, ENT_QUOTES, 'UTF-8') ?>">
<?php foreach ($fields as $name => $value): ?>
<input
type="hidden"
name="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>"
value="<?= htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') ?>"
>
<?php endforeach; ?>
<noscript>
<button type="submit">继续付款</button>
</noscript>
</form>
<script>
document.getElementById('payment-form').submit();
</script>
</body>
</html>
<?php
exit;
}
这样一来,向 Gateway 发出 POST 请求的是用户浏览器。付款页面、浏览器 Cookie、跳转过程和用户会话都处在同一个客户端环境中,这更符合托管式支付页面的常见接入方式。
所有输出都必须在调用 header() 或生成 HTML 响应之前完成。提交表单后应立即执行 exit,防止程序继续输出其他页面内容。
如果 Gateway 支持服务器端支付初始化 API
这种接入方式通常按以下流程处理:
- PHP 使用 cURL 调用“创建支付”接口。
- Gateway 返回交易编号、支付令牌或专用付款 URL。
- PHP 校验返回结果。
- 浏览器跳转到 Gateway 返回的付款 URL。
只有接口文档明确规定了这套流程,才能按这种方式接入。下面是一个通用示例:
<?php
$fields = [
'amount' => number_format((float) $amount, 2, '.', ''),
'merchantId' => $merchantId,
'orderRef' => $orderRef,
'currCode' => '344',
'secureHash' => $secureHash,
];
$ch = curl_init('https://gateway.example.com/api/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
if ($response === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Payment request failed: ' . $error);
}
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException(
'Payment gateway returned HTTP ' . $statusCode
);
}
$data = json_decode($response, true);
if (!is_array($data) || empty($data['paymentUrl'])) {
throw new RuntimeException('Payment gateway response is invalid.');
}
header('Location: ' . $data['paymentUrl'], true, 303);
exit;
其中的接口地址、响应字段 paymentUrl 和 JSON 格式仅用于说明流程,不能直接用于实际项目。接入时必须按照 Gateway 的真实接口文档进行替换。
当前 cURL 写法中需要修正的地方
如果只是测试服务器端 POST 是否正确,可以把 cURL 部分改为:
<?php
$postvars = http_build_query($fields);
$ch = curl_init(
'https://test.paydollar.com/b2cDemo/eng/payment/payForm.jsp'
);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postvars,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
$serverOutput = curl_exec($ch);
if ($serverOutput === false) {
throw new RuntimeException('cURL error: ' . curl_error($ch));
}
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
这里的:
CURLOPT_POST => true
应该使用布尔值。CURLOPT_POST 用于指定是否执行 POST 请求,不接收字段数量。原代码写成:
curl_setopt($ch, CURLOPT_POST, count($fields));
非零整数通常会被当作 true,但这种写法并不准确,也会干扰问题排查。
这段 cURL 代码只能用来查看 Gateway 对服务器端请求的响应,无法让用户浏览器继承该请求的状态。CURLOPT_FOLLOWLOCATION 也只控制 PHP cURL 是否跟随服务端重定向,不能把会话转移到浏览器。
merchantId Incorrect 的排查重点
即使已经改为浏览器自动提交,也需要逐项检查以下内容:
merchantId是否为 Gateway 分配的真实测试商户编号。- 测试商户编号是否提交到了对应的测试环境。
- 参数名称和大小写是否与接口文档完全一致。
amount的格式、小数位数和允许范围是否正确。currCode、mpsMode、payType等值是否属于接口允许值。secureHash是否按照规定的字段、顺序、编码和算法生成。- 回调地址是否要求使用 HTTPS、进行 URL 编码或提前登记。
- 表单的
Content-Type是否应为application/x-www-form-urlencoded。 orderRef是否唯一,是否包含接口不允许的字符。
不要通过关闭 SSL 验证来“修复”连接问题,也不要在页面、日志或异常信息中输出商户密钥。secureHash 应在服务器端生成,签名密钥不能作为隐藏字段发送。
支付状态还应以服务端通知为准
用户跳转到 successUrl,并不能证明付款已经成功。用户可能篡改跳转参数,也可能在付款完成后直接关闭页面。
更稳妥的做法是先把数据库记录保存为 pending,再根据 Gateway 提供的服务端回调、查询接口或签名验证结果,将订单状态更新为 paid、failed 或 cancelled。同时需要保证 orderRef 唯一,并让回调处理具备幂等性,防止重复通知造成重复入账。
备注:内容仅供参考。
