如何在 PHP 中发送 POST 请求并重定向到外部网站?
结论
可以用 PHP 向支付网关发送 POST 请求,但由服务器发出的请求不会让用户的浏览器自动跳转。
file_get_contents() 或 cURL 发起的是“你的服务器 → 支付网关”的请求。即使网关返回 302 Location: URL_OK,跟随重定向的也是 PHP 进程,而不是用户浏览器。因此,最终得到 URL_OK 页面的内容,并不代表浏览器已经跳转。
确认退款结果时,应优先采用支付网关提供的以下机制:
- 退款接口的同步响应;
- 服务器异步通知(webhook/callback);
- 主动查询退款状态的接口。
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 是否被访问。
备注:内容仅供参考。
