PAYATHON 2026

支付成功回调通知导致 session 失效并退出登录,如何解决?

支付老李

结论

支付平台的异步回调通常发生在服务器之间,不会携带用户浏览器里的 Session Cookie,所以正常情况下,它不会让用户退出登录。

如果支付后 Session 失效,问题通常出在支付回调、支付完成后的跳转页或登录中间件,常见情况包括:

  • 回调代码执行了 session_destroy()、退出登录或 Session 重置逻辑。
  • 系统用 Session 保存订单归属,并在回调中读取或修改 Session。
  • 回调接口和前台页面共用了不合适的认证、Session 或异常处理中间件。
  • 支付完成后跳转到了不同的域名、协议或路径,导致浏览器没有发送原来的 Session Cookie。
  • Session 存储、Cookie 配置或多实例部署有问题。
  • 同一 Session 的并发请求覆盖了登录状态。

支付回调应该只根据商户订单号处理订单,不依赖用户 Session。用户登录状态则由浏览器 Cookie 和服务端 Session 维护。

先区分两类请求

支付完成后,通常会产生两个不同的请求。

异步回调

支付平台的服务器主动请求商户的回调地址,例如:

POST https://example.com/payment/notify

这个请求一般不包含用户浏览器的 Session Cookie,只负责验签、查询订单、更新支付状态并返回确认结果。

前台跳转

支付完成后,用户的浏览器跳回商户页面,例如:

GET https://example.com/payment/result?order_no=202609130001

用户能否保持登录,取决于浏览器是否继续携带原来的 Session Cookie。

排查时需要分别记录这两个请求,不要把异步通知和浏览器跳转当成同一个过程。

常见原因

回调代码错误地操作了 Session

先检查支付相关代码中是否有以下操作:

session_destroy();
session_regenerate_id(true);
unset($_SESSION);
$_SESSION = [];

框架的退出登录方法、认证状态清理、全局异常处理和请求结束钩子也要一并检查。

有些代码为了“清理支付临时数据”,会直接清空整个 Session:

// 错误示例
unset($_SESSION);
session_destroy();

如果只需要删除支付过程中的临时字段,应精确删除对应字段:

unset($_SESSION['payment_pending_order']);

更稳妥的做法是让异步回调完全不启动 Session。

使用 Session 识别待支付订单

下面这种设计并不可靠:

session_start();

$orderNo = $_SESSION['pending_order_no'] ?? null;

支付平台发起异步通知时,不会携带用户的 Session Cookie,因此回调无法从用户 Session 中取得订单号。即使这段代码偶尔能正常运行,也容易受到并发、超时或 Cookie 变化的影响。

订单号应该从验签通过的回调参数中取得,再用它查询数据库:

$orderNo = $verifiedPayload['out_trade_no'];

$order = findOrderByOrderNo($orderNo);

订单记录本身应保存用户标识,回调处理时不需要依赖登录状态:

orders
├── order_no
├── user_id
├── amount
├── status
└── paid_at

检查支付前后的访问地址是否发生变化,例如:

https://www.example.com/pay
https://example.com/payment/result

或者:

https://example.com/pay
http://example.com/payment/result

域名、子域名、协议或 Cookie 路径发生变化,都可能导致浏览器不再发送原来的 Session Cookie。

需要检查:

  • Cookie 的 Domain
  • Cookie 的 Path
  • Secure
  • HttpOnly
  • SameSite
  • 支付前后是否始终使用 HTTPS
  • www.example.com 与 example.com 是否统一
  • Session Cookie 名称是否一致

PHP 可以根据实际部署方式进行配置:

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '.example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

这里的 domain 只适用于需要在多个子域之间共享 Session 的情况。如果系统只使用单一域名,不要直接照搬。

第三方支付完成后的顶层页面跳转通常可以携带设置为 SameSite=Lax 的 Cookie。如果业务使用第三方 iframe 或跨站 POST,则可能需要设置 SameSite=None; Secure。最终配置要结合实际跳转方式,并在目标浏览器中验证。

回调接口套用了登录中间件

异步回调不能要求用户登录,因为支付平台没有用户 Session。可以把它配置为独立的公开接口:

POST /payment/notify

公开接口仍然必须校验以下内容:

  • 支付平台签名
  • 商户号、应用 ID 等身份信息
  • 订单号
  • 支付金额和币种
  • 支付状态
  • 重复通知的幂等处理

不要因为回调请求没有登录状态,就让它进入统一的“登录失效”流程,更不能在这个流程中修改用户 Session。

推荐的回调处理结构

下面是一个不依赖具体支付平台的 PHP 示例。verifyPaymentSignature() 等函数需要按照实际支付平台的 SDK 实现:

<?php

$payload = getPaymentCallbackPayload();

if (!verifyPaymentSignature($payload)) {
    http_response_code(400);
    echo 'invalid signature';
    exit;
}

$orderNo = $payload['out_trade_no'] ?? null;
$transactionId = $payload['transaction_id'] ?? null;
$paidAmount = $payload['amount'] ?? null;
$paymentStatus = $payload['status'] ?? null;

if (!$orderNo || !$transactionId) {
    http_response_code(400);
    echo 'invalid payload';
    exit;
}

$order = findOrderByOrderNo($orderNo);

if (!$order) {
    http_response_code(404);
    echo 'order not found';
    exit;
}

if (!amountEquals($order['amount'], $paidAmount)) {
    http_response_code(400);
    echo 'amount mismatch';
    exit;
}

if ($paymentStatus !== 'SUCCESS') {
    echo 'success';
    exit;
}

beginTransaction();

try {
    $lockedOrder = findOrderForUpdate($orderNo);

    if ($lockedOrder['status'] !== 'PAID') {
        markOrderAsPaid(
            $orderNo,
            $transactionId,
            new DateTimeImmutable()
        );
    }

    commitTransaction();
    echo 'success';
} catch (Throwable $e) {
    rollbackTransaction();

    error_log(sprintf(
        'Payment callback failed: order_no=%s, transaction_id=%s, error=%s',
        $orderNo,
        $transactionId,
        $e->getMessage()
    ));

    http_response_code(500);
    echo 'failed';
}

整个流程既不读取 Session,也不修改 Session。即使支付平台重复发送通知,订单也只会被更新为已支付一次。

比较金额时不要直接使用浮点数,可以先统一转换成最小货币单位:

function amountEquals(string $expected, string $actual): bool
{
    return toCents($expected) === toCents($actual);
}

function toCents(string $amount): int
{
    return (int) bcmul($amount, '100', 0);
}

这段代码依赖 BCMath 扩展。如果没有安装 BCMath,应直接使用支付平台返回的整数最小货币单位,或者采用可靠的十进制定点数方案。

前台结果页的正确处理方式

浏览器返回结果页时,不能只看 URL 参数就认定支付成功,因为这些参数可能被修改。最终支付状态应以数据库记录或主动查询支付平台得到的结果为准:

<?php

session_start();

if (empty($_SESSION['user_id'])) {
    header('Location: /login');
    exit;
}

$orderNo = $_GET['order_no'] ?? '';
$order = findOrderByOrderNoAndUserId(
    $orderNo,
    $_SESSION['user_id']
);

if (!$order) {
    http_response_code(404);
    exit('订单不存在');
}

if ($order['status'] === 'PAID') {
    echo '支付成功';
} else {
    echo '支付结果确认中,请稍后刷新';
}

异步通知和前台跳转的到达顺序并不固定。用户进入结果页时,回调可能还没有处理完,因此显示“支付结果确认中”通常比直接报错更合适。

Session 并发覆盖问题

部分 Session 实现会在请求期间加锁,或者在请求结束时整体写回数据。如果支付页面同时发起轮询、跳转和其他 AJAX 请求,某个较早读取 Session 的请求可能反而最后完成,从而覆盖较新的登录状态。

PHP 使用文件 Session 时,读取登录信息后可以尽早释放锁:

session_start();

$userId = $_SESSION['user_id'] ?? null;

session_write_close();

// 后续执行耗时操作,但不再修改 Session
processRequest($userId);

还要检查是否有代码把旧的 Session 快照整体写回 Redis、数据库或其他共享存储。

多实例部署需要检查 Session 存储

如果应用部署在多台服务器上,而每台服务器都把 Session 保存在本地磁盘,就可能出现这种情况:

登录请求 → 服务器 A
支付返回 → 服务器 B

服务器 B 无法读取服务器 A 上的 Session,用户看到的现象就是突然退出登录。

常见的处理方式包括:

  • 使用 Redis、数据库等共享 Session 存储。
  • 配置负载均衡会话保持,但共享存储通常更稳妥。
  • 确保所有实例使用相同的 Session Cookie 名称、加密密钥和序列化配置。
  • 检查 Redis 中 Session 的过期时间是否被意外缩短。
  • 检查部署、重启或缓存清理过程是否删除了 Session。

建议的排查步骤

  1. 在浏览器开发者工具中查看支付前后的 Session Cookie,确认名称、值、域名和路径是否发生变化。
  2. 分别记录异步回调与前台跳转请求的 URL、请求方法、Cookie 请求头,以及响应中的 Set-Cookie。
  3. 搜索支付模块和全局中间件中的 Session 清理、退出登录及 session_regenerate_id() 调用。
  4. 确认回调接口不依赖用户登录状态,也不使用 Session 查询订单。
  5. 检查回调响应是否意外设置了同名 Session Cookie。
  6. 如果使用多实例部署,确认所有实例共用 Session 存储。
  7. 检查 Session 过期时间、Redis TTL、Cookie 生命周期和服务器时间。
  8. 在支付回调日志中记录订单号、平台交易号、请求来源、验签结果和处理状态,但不要记录密钥、完整 Cookie 或敏感支付信息。

如果异步回调日志里根本没有用户的 Session Cookie,回调却仍然会让用户退出登录,应优先检查回调代码是否根据 user_id 删除了登录状态或全局 Session,或者触发了账号互斥登录逻辑。正常情况下,服务器之间的支付通知无法直接销毁某个浏览器的 Session。

备注:内容仅供参考。