PAYATHON 2026

支付后 PHP 网站偶发退出,无法进入支付成功页面

支付阿杰

结论

不要把“支付成功确认”绑定到“用户登录 Session”上。支付平台回跳 pay_response.php 时,浏览器不一定会携带原来的 PHP Session Cookie。到了生产环境,负载均衡、Cookie 域名、HTTPS 和 SameSite 配置也可能造成 Session 丢失。

建议按以下流程处理:

  1. 发起支付前,先创建本地订单并写入数据库。
  2. 使用不可预测的订单号关联支付请求。
  3. 通过支付平台的服务端通知确认支付结果。
  4. pay_response.php 根据订单号查询数据库并显示结果,不要只依赖 $_SESSION。
  5. 检查生产环境中的 Session Cookie、共享 Session 存储和域名跳转配置。

用户回跳时即使暂时丢失了登录状态,也不能把已经完成的支付判定为失败,更不能因此丢失订单结果。

为什么只在生产环境偶发

支付时,用户通常会离开当前网站,进入银行或支付平台页面,然后由第三方跳回本站。这个过程不同于 localhost 上的站内页面跳转,以下问题更容易出现。

如果支付平台通过跨站 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 可以改善页面体验,但不能作为支付状态的唯一存储位置。

排查顺序

可以先用浏览器开发者工具对比成功和失败的请求:

  1. 确认 pay_response.php 使用的是 GET 还是 POST。
  2. 查看失败请求是否携带 PHP Session Cookie。
  3. 对比发起支付和回跳时的协议、主机名,以及 Cookie 的 Domain、Path、Secure 和 SameSite 属性。
  4. 记录回跳请求对应的 Session ID,但日志中只能保留脱敏值或哈希值。
  5. 查看负载均衡日志,确认两次请求是否落在不同节点。
  6. 检查 PHP Session 存储错误、Cookie 过期时间和服务器时间。
  7. 检查应用是否在支付前后调用了 session_destroy(),或者在登录检查失败时立即重定向到首页。
  8. 检查支付页和返回页是否在产生输出后才调用 session_start() 或 header()。

需要保证的不是浏览器永远不丢 Session,而是支付流程在 Session 丢失、通知重复、回跳中断或用户关闭页面时,仍能根据持久化的订单记录得出正确结果。

备注:内容仅供参考。