PAYATHON 2026

如何在 PHP 中发送 POST 请求并重定向到外部网站?

支付小周

结论

可以用 PHP 向支付网关发送 POST 请求,但由服务器发出的请求不会让用户的浏览器自动跳转。

file_get_contents() 或 cURL 发起的是“你的服务器 → 支付网关”的请求。即使网关返回 302 Location: URL_OK,跟随重定向的也是 PHP 进程,而不是用户浏览器。因此,最终得到 URL_OK 页面的内容,并不代表浏览器已经跳转。

确认退款结果时,应优先采用支付网关提供的以下机制:

  1. 退款接口的同步响应;
  2. 服务器异步通知(webhook/callback);
  3. 主动查询退款状态的接口。

URL_OK 和 URL_NO_OK 通常是提供给浏览器的返回地址,未必能作为可靠的退款结果通知地址。它们究竟用于什么,应以支付网关文档为准。

为什么只能得到网页内容

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

用户浏览器 → 你的网站
                 ↓
          PHP → 支付网关
                 ↓
          PHP ← HTTP 响应或重定向

第二个请求由 PHP 发起,与用户浏览器无关。

如果 PHP 的 HTTP 包装器自动跟随重定向,实际过程可能是:

PHP → 支付网关
PHP ← 302 Location: URL_OK
PHP → URL_OK
PHP ← URL_OK 的网页内容

因此,$result 中得到的是重定向目标页面的内容。

即使 URL_OK 指向你自己的网站,这个请求也可能来自 PHP 的服务器端 HTTP 客户端,而不是用户浏览器。退款结果不能依靠浏览器跳转来确认。

正确处理方式

1. 先解析退款接口的直接响应

退款接口通常会通过响应正文、HTTP 状态码或响应头返回受理结果。受理成功未必表示退款已经完成,因此需要区分不同状态:

  • submitted:退款请求已提交;
  • processing:处理中;
  • succeeded:退款完成;
  • failed:退款失败。

建议使用 cURL,分别获取 HTTP 状态码、响应头和响应正文:

function enviarDevolucion(array $data, string $url): array
{
    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($data),
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/x-www-form-urlencoded',
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HEADER => true,

        // 先不要自动跟随重定向,以便检查网关的原始响应。
        CURLOPT_FOLLOWLOCATION => false,

        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 30,
    ]);

    $response = curl_exec($ch);

    if ($response === false) {
        $error = curl_error($ch);
        curl_close($ch);

        throw new RuntimeException('Refund request failed: ' . $error);
    }

    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

    $headers = substr($response, 0, $headerSize);
    $body = substr($response, $headerSize);

    curl_close($ch);

    return [
        'status_code' => $statusCode,
        'headers' => $headers,
        'body' => $body,
    ];
}

$data = [
    'Ds_SignatureVersion' => VERSION_FIRMA,
    'Ds_MerchantParameters' => $parametros,
    'Ds_Signature' => $firma,
];

$response = enviarDevolucion($data, $urlDelEntorno);

registrarLogDePago(
    'Refund response: ' . json_encode($response, JSON_UNESCAPED_UNICODE)
);

接下来应根据网关文档解析 $response['body']。不能只凭 HTTP 200 判断退款成功,因为 200 可能只是表示网关正常接收并处理了这次 HTTP 请求。

2. 使用独立的服务器通知地址

如果支付网关支持异步通知,应配置专用的 callback/webhook 地址,例如:

https://example.com/payment/refund-notification.php

该地址用于接收网关主动发送的退款结果。收到通知后,先验证签名,再更新数据库:

<?php

$payload = $_POST;

if (!$payload) {
    $rawBody = file_get_contents('php://input');
    $payload = json_decode($rawBody, true);

    if (!is_array($payload)) {
        http_response_code(400);
        exit('Invalid payload');
    }
}

// 下面的字段名和验签函数只是结构示例。
// 实际字段及签名算法必须使用支付网关文档规定的值。
$merchantParameters = $payload['Ds_MerchantParameters'] ?? null;
$signature = $payload['Ds_Signature'] ?? null;

if ($merchantParameters === null || $signature === null) {
    http_response_code(400);
    exit('Missing parameters');
}

if (!verifyGatewaySignature($merchantParameters, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$refund = parseRefundParameters($merchantParameters);

$refundId = $refund['refund_id'] ?? null;
$status = $refund['status'] ?? null;

if ($refundId === null || $status === null) {
    http_response_code(400);
    exit('Invalid refund data');
}

// 应使用事务和参数化查询。
// 同一个通知可能被网关重复发送,因此更新操作必须具备幂等性。
updateRefundStatus($refundId, $status, $refund);

http_response_code(200);
echo 'OK';

通知接口至少需要完成以下工作:

  • 验证网关签名,不接受未经验证的 POST 参数;
  • 使用商户订单号或退款编号关联本地记录;
  • 核对金额、币种和商户编号;
  • 以幂等方式处理重复通知;
  • 保存原始通知,以便审计和排查;
  • 只有验证通过后,才能将退款标记为成功;
  • 尽快返回网关要求的确认内容,耗时操作交给队列处理。

3. 没有异步通知时主动查询

如果退款接口只返回“已受理”,而且网关不提供 webhook,就需要调用退款状态查询接口:

function refreshRefundStatus(string $refundId): void
{
    $result = queryRefundStatusFromGateway($refundId);

    if (!verifyQueryResponse($result)) {
        throw new RuntimeException('Invalid gateway response');
    }

    updateRefundStatus(
        $refundId,
        $result['status'],
        $result
    );
}

可以通过定时任务查询状态仍为 submitted 或 processing 的退款。查询频率、终止条件和状态字段应遵循网关的接口规范。

URL_OK 和 URL_NO_OK 不应当作唯一依据

浏览器返回地址主要用于向用户显示处理结果,例如:

<?php

$orderId = $_GET['order_id'] ?? null;

// 只读取服务器已经确认的状态,不根据当前请求直接认定退款成功。
$refund = findRefundByOrderId($orderId);

if ($refund && $refund['status'] === 'succeeded') {
    echo '退款已完成';
} elseif ($refund && $refund['status'] === 'failed') {
    echo '退款失败,请联系客服';
} else {
    echo '退款正在处理中,请稍后查询';
}

用户可能关闭页面或遇到网络中断,也可能有人手动访问 URL_OK。因此,仅仅访问了 URL_OK,不能证明退款已经完成。

某些网关会分别配置 URL_OK、URL_NO_OK 和服务器通知 URL,另一些网关在退款操作中可能根本不使用这两个地址。需要查看当前网关的文档,确认它是否明确支持“退款结果通知”,以及通知采用 GET、表单 POST 还是 JSON。

如果确实需要让浏览器向网关发送 POST

普通的 PHP 重定向只能发送 Location:

header('Location: https://gateway.example.com');
exit;

它无法为浏览器接下来的请求附加自定义 POST 数据。

不使用 JavaScript 时,可以显示一个 HTML 表单,让用户手动提交:

<form method="post" action="https://gateway.example.com/refund">
    <input type="hidden" name="Ds_SignatureVersion"
           value="<?= htmlspecialchars(VERSION_FIRMA, ENT_QUOTES, 'UTF-8') ?>">

    <input type="hidden" name="Ds_MerchantParameters"
           value="<?= htmlspecialchars($parametros, ENT_QUOTES, 'UTF-8') ?>">

    <input type="hidden" name="Ds_Signature"
           value="<?= htmlspecialchars($firma, ENT_QUOTES, 'UTF-8') ?>">

    <button type="submit">继续处理退款</button>
</form>

没有 JavaScript 时,标准 HTML 无法自动提交该表单。307 或 308 重定向可以要求客户端保留原请求的方法和正文,但它只能转发浏览器当前收到的 POST。支付流程、客户端兼容性以及网关是否接受这种方式也有限制,因此不适合用来替代正式的退款 API。

退款是服务器端业务操作,通常不应依赖用户浏览器参与。

建议的最终流程

创建本地退款记录(submitted)
        ↓
服务器向网关发送退款请求
        ↓
解析并保存网关的直接响应
        ↓
等待网关异步通知,或主动查询退款状态
        ↓
验证签名、订单、金额和币种
        ↓
幂等更新为 succeeded 或 failed
        ↓
URL_OK 页面只展示数据库中的可信状态

需要将“浏览器跳转”和“退款结果确认”分开处理。退款是否成功,应根据经过验签的服务器通知、明确的同步响应或状态查询结果来判断,不能取决于 URL_OK 是否被访问。

备注:内容仅供参考。