PAYATHON 2026

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: ...") 会丢失参数

当前代码的执行流程如下:

  1. 用户浏览器向 create.php 提交表单。
  2. PHP 将数据保存到数据库。
  3. PHP 服务器通过 cURL 向 Gateway 发出一次 POST 请求。
  4. Gateway 返回的内容保存在 $server_output 中。
  5. PHP 没有处理 $server_output,而是向浏览器返回 Location 响应头。
  6. 浏览器重新访问 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

这种接入方式通常按以下流程处理:

  1. PHP 使用 cURL 调用“创建支付”接口。
  2. Gateway 返回交易编号、支付令牌或专用付款 URL。
  3. PHP 校验返回结果。
  4. 浏览器跳转到 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 唯一,并让回调处理具备幂等性,防止重复通知造成重复入账。

备注:内容仅供参考。