支付后 PHP 网站偶发退出,无法进入支付成功页面
结论
不要把“支付成功确认”绑定到“用户登录 Session”上。支付平台回跳 pay_response.php 时,浏览器不一定会携带原来的 PHP Session Cookie。到了生产环境,负载均衡、Cookie 域名、HTTPS 和 SameSite 配置也可能造成 Session 丢失。
建议按以下流程处理:
- 发起支付前,先创建本地订单并写入数据库。
- 使用不可预测的订单号关联支付请求。
- 通过支付平台的服务端通知确认支付结果。
pay_response.php根据订单号查询数据库并显示结果,不要只依赖$_SESSION。- 检查生产环境中的 Session Cookie、共享 Session 存储和域名跳转配置。
用户回跳时即使暂时丢失了登录状态,也不能把已经完成的支付判定为失败,更不能因此丢失订单结果。
为什么只在生产环境偶发
支付时,用户通常会离开当前网站,进入银行或支付平台页面,然后由第三方跳回本站。这个过程不同于 localhost 上的站内页面跳转,以下问题更容易出现。
Session Cookie 没有随回跳请求发送
如果支付平台通过跨站 POST 请求提交到 pay_response.php,SameSite=Lax 或浏览器的隐私策略可能阻止 PHP Session Cookie 随请求发送。
例如设置了:
session.cookie_samesite=Lax
跨站顶层 GET 请求通常可以携带 Cookie,跨站 POST 请求通常不行。如果支付网关必须通过跨站 POST 回跳,一般需要设置:
session.cookie_samesite=None
session.cookie_secure=1
SameSite=None 必须与 HTTPS 和 Secure 配合使用。不要为了兼容回跳而关闭 Cookie 的安全属性。
域名不一致
对 Cookie 来说,以下地址可能属于不同的作用域:
https://demosite.tripura.gov.in/
https://www.demosite.tripura.gov.in/
http://demosite.tripura.gov.in/
https://tripura.gov.in/
如果发起支付和回跳使用不同的协议、主机名或子域名,原来的 Session Cookie 可能无法使用。支付平台中配置的返回地址应与网站的规范地址完全一致,并统一强制跳转到 HTTPS。
多台服务器没有共享 Session
生产环境如果使用负载均衡,用户发起支付时可能访问服务器 A,支付后回跳却被分配到服务器 B。PHP Session 如果只保存在各服务器本地,服务器 B 就找不到原来的 Session,并把用户视为未登录。
可以采用以下方案:
- 在负载均衡器上启用会话粘滞。
- 使用 Redis、数据库等共享 Session 存储。
- 让订单确认流程不依赖 Session。
会话粘滞能缓解问题,但共享存储和数据库中的订单状态通常更可靠。
Session 初始化或输出顺序错误
所有读取 $_SESSION 的页面,都必须在输出 HTML、空格或 BOM 之前调用:
session_start();
否则,程序可能无法发送 Session Cookie 或重定向响应头。还应查看 PHP 和 Web 服务器错误日志,确认是否出现以下错误:
headers already sent
session_start(): Failed to read session data
session_start(): open(...) failed
推荐的支付流程
第一步:支付前创建订单
不要只把服务 ID、金额等信息保存在 Session 中。发起支付前,应先在数据库里创建一条待支付订单:
order_no
user_id
amount
status
gateway_transaction_id
created_at
paid_at
订单号应由服务端生成,并保证唯一且不可预测。例如:
<?php
declare(strict_types=1);
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
$orderNo = bin2hex(random_bytes(16));
$userId = (int) $_SESSION['user_id'];
$amount = '500.00';
$stmt = $pdo->prepare(
'INSERT INTO payment_orders
(order_no, user_id, amount, status, created_at)
VALUES
(:order_no, :user_id, :amount, :status, NOW())'
);
$stmt->execute([
':order_no' => $orderNo,
':user_id' => $userId,
':amount' => $amount,
':status' => 'pending',
]);
随后,把本地订单号放入支付网关允许使用的商户订单字段:
$returnUrl = 'https://demosite.tripura.gov.in/pay_response.php';
$notifyUrl = 'https://demosite.tripura.gov.in/payment_notify.php';
具体字段名称由支付网关决定,可能是 order_id、merchant_order_id、reference_no 或 merchant_param,应以该网关的接口文档为准。
金额、用户 ID 和服务内容必须从数据库读取,不能信任浏览器回传的值。
第二步:使用服务端通知确认支付
浏览器返回页并不可靠,可能遇到以下情况:
- 用户付款后直接关闭页面。
- 网络在跳转过程中中断。
- 浏览器阻止第三方 Cookie。
- 用户刷新或重复提交返回页。
- 返回参数被人为修改。
如果支付平台提供 webhook、callback 或 server-to-server notification,应单独建立服务端通知入口:
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$payload = $_POST;
// 伪代码:必须替换成支付网关规定的验签算法。
if (!verifyGatewaySignature($payload)) {
http_response_code(400);
exit('invalid signature');
}
$orderNo = (string) ($payload['order_id'] ?? '');
$transactionId = (string) ($payload['transaction_id'] ?? '');
$paidAmount = (string) ($payload['amount'] ?? '');
$gatewayStatus = (string) ($payload['status'] ?? '');
$order = findOrderForUpdate($pdo, $orderNo);
if ($order === null) {
http_response_code(404);
exit('order not found');
}
if (!hash_equals((string) $order['amount'], $paidAmount)) {
http_response_code(400);
exit('amount mismatch');
}
if ($gatewayStatus === 'SUCCESS') {
markOrderPaidIdempotently(
$pdo,
$orderNo,
$transactionId
);
}
echo 'OK';
这里的 verifyGatewaySignature()、成功状态值和响应格式只是流程示意,不能直接用于实际项目。验签必须严格遵循支付网关文档,必要时还要调用网关的订单查询 API 再次确认。
通知处理还必须支持幂等。支付平台多次发送同一条通知时,订单只能更新一次,不能重复发放服务或重复记账。
pay_response.php 不应强制依赖 Session
返回页面可以尝试恢复用户的登录状态,但订单结果应以数据库记录和网关验证结果为准。
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/bootstrap.php';
$orderNo = (string) ($_POST['order_id'] ?? $_GET['order_id'] ?? '');
if ($orderNo === '' || !preg_match('/^[a-f0-9]{32}$/', $orderNo)) {
http_response_code(400);
exit('无效的订单号');
}
// 如果网关在浏览器回跳中附带签名,应先验签。
// 不要因为页面参数写着 SUCCESS 就直接更新订单状态。
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyGatewaySignature($_POST)) {
http_response_code(400);
exit('支付结果验签失败');
}
}
$stmt = $pdo->prepare(
'SELECT order_no, user_id, amount, status
FROM payment_orders
WHERE order_no = :order_no'
);
$stmt->execute([':order_no' => $orderNo]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
http_response_code(404);
exit('未找到对应订单');
}
$currentUserId = isset($_SESSION['user_id'])
? (int) $_SESSION['user_id']
: null;
if ($currentUserId !== null && $currentUserId !== (int) $order['user_id']) {
http_response_code(403);
exit('无权查看该订单');
}
if ($currentUserId === null) {
// Session 丢失时不要把支付改成失败。
// 可展示不含个人敏感信息的结果,或要求重新登录后查看订单。
if ($order['status'] === 'paid') {
echo '支付已确认。请重新登录后查看业务办理结果。';
} else {
echo '支付结果正在确认,请稍后重新登录查询订单。';
}
exit;
}
if ($order['status'] === 'paid') {
echo '支付成功';
} elseif ($order['status'] === 'pending') {
echo '支付结果正在确认';
} else {
echo '订单状态:' . htmlspecialchars(
(string) $order['status'],
ENT_QUOTES,
'UTF-8'
);
}
如果订单号可能泄露敏感信息,返回地址还可以携带一次性查看令牌。令牌应通过 random_bytes() 生成,数据库中只保存它的哈希值,并设置较短的有效期。不要把 user_id、金额或“支付成功”状态作为可信的 URL 参数。
生产环境的 Session 配置
Session Cookie 应在应用输出任何内容之前统一设置。PHP 7.3 及以上版本可以使用数组形式:
<?php
declare(strict_types=1);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => 'demosite.tripura.gov.in',
'secure' => true,
'httponly' => true,
'samesite' => 'None',
]);
session_start();
通常只有支付平台通过跨站 POST 回跳时,才需要设置 SameSite=None。如果使用顶层 GET 回跳,应先评估能否保留限制更严格的 Lax。修改配置后,还要确认服务器没有通过 php.ini、框架中间件或反向代理再次覆盖这些属性。
如果 Session 确实需要在所有子域名之间共享,可以设置:
'domain' => '.tripura.gov.in',
这样做会扩大 Cookie 的可访问范围。只有确实需要跨子域共享,而且所有相关子域都可信时,才应使用该配置。其他情况下应使用精确的主机名。
站点位于反向代理之后时,还要确保 PHP 能正确识别原始请求使用的是 HTTPS。代理应传递类似 X-Forwarded-Proto: https 的信息,应用则只能信任已经配置的代理地址,防止有人伪造请求头。
如果使用负载均衡
可以将 PHP Session 存入 Redis。以下是常见的配置形式,具体地址、扩展和认证方式应根据部署环境调整:
session.save_handler = redis
session.save_path = "tcp://redis.internal:6379?database=2"
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = None
还需要检查以下配置:
- 所有节点使用相同的
session.name。 - 所有节点采用一致的 Session 配置。
- Session 存储可写,且不会过早过期。
- PHP 的垃圾回收时间能够覆盖可能持续较久的支付过程。
- 负载均衡器没有缓存
pay_response.php。 - CDN 或反向代理没有缓存包含重定向或 Session Cookie 的响应。
不建议采用的处理方式
不要为了“避免退出”而关闭登录校验,也不能只根据回跳参数执行发放服务等重要操作。下面的逻辑并不安全:
if ($_POST['status'] === 'SUCCESS') {
// 直接认为付款成功
}
浏览器提交的数据可以伪造。处理支付结果时,应验证签名,校验订单号和金额,并以服务端通知或支付网关查询结果为准。
也不要把整个订单对象序列化后存入 $_SESSION。Session 可以改善页面体验,但不能作为支付状态的唯一存储位置。
排查顺序
可以先用浏览器开发者工具对比成功和失败的请求:
- 确认
pay_response.php使用的是GET还是POST。 - 查看失败请求是否携带 PHP Session Cookie。
- 对比发起支付和回跳时的协议、主机名,以及 Cookie 的
Domain、Path、Secure和SameSite属性。 - 记录回跳请求对应的 Session ID,但日志中只能保留脱敏值或哈希值。
- 查看负载均衡日志,确认两次请求是否落在不同节点。
- 检查 PHP Session 存储错误、Cookie 过期时间和服务器时间。
- 检查应用是否在支付前后调用了
session_destroy(),或者在登录检查失败时立即重定向到首页。 - 检查支付页和返回页是否在产生输出后才调用
session_start()或header()。
需要保证的不是浏览器永远不丢 Session,而是支付流程在 Session 丢失、通知重复、回跳中断或用户关闭页面时,仍能根据持久化的订单记录得出正确结果。
备注:内容仅供参考。
