PAYATHON 2026

智能手机返回 browser 时 PHP Session 丢失

支付阿杰

结论

移动设备重新加载标签页,不会直接导致 PHP Session 丢失。只要浏览器继续携带相同的 session cookie,服务器端的 Session 数据也没有过期,重新请求同一 URL 后就能恢复原来的 Session。

如果 Session 没有恢复,通常是以下原因之一:

  • PHP Session 已被服务器清理,常见原因是 session.gc_maxlifetime 设置得太短。
  • 浏览器没有再次发送 session cookie,例如域名、HTTP/HTTPS、SameSite 或 WebView 环境发生了变化。
  • 应用部署在多台服务器上,但各服务器之间没有共享 Session。
  • 业务进度只保存在 Session 中,Session 一旦失效,整个 checkout 就无法恢复。
  • 用户从支付平台跨站跳转回来时,cookie 策略阻止了 Session cookie。

对于需要保留数小时的 checkout,建议采用以下方式:

  • 用数据库保存 checkout 的真实状态。
  • Session 或安全 cookie 只保存随机的 checkout_id 或恢复令牌。
  • 每次打开 Customer Details、Payment 等页面时,都根据该标识从数据库读取进度。
  • Session 丢失后,允许用户通过恢复令牌继续流程,而不是立即要求重新报价。

不要把客户资料、价格结果、订单金额或支付信息直接保存在普通 cookie 中。

为什么标签页刷新不应该清除 Session

PHP Session 通常由两部分组成:

  1. 浏览器中的 session ID cookie,例如 PHPSESSID;
  2. 服务器端与该 ID 对应的 Session 数据。

标签页被系统回收后再重新加载,本质上只是发起了一次新的 HTTP 请求。正常情况下,浏览器仍会发送 PHPSESSID,PHP 也就能读取原来的 Session。

排查时,先确认是哪一部分出了问题:

<?php

session_start();

error_log(json_encode([
    'session_id' => session_id(),
    'has_session_cookie' => isset($_COOKIE[session_name()]),
    'session_name' => session_name(),
    'host' => $_SERVER['HTTP_HOST'] ?? null,
    'uri' => $_SERVER['REQUEST_URI'] ?? null,
], JSON_UNESCAPED_SLASHES));

不要把完整 cookie、客户资料或支付数据写入日志。这里仅记录 session cookie 是否存在,以及当前的 session ID。生产环境还可以先对 ID 做哈希,再写入日志。

如果刷新前后的 session ID 不同,问题多半出在 cookie 上。如果 session ID 相同,但其中的数据消失了,通常需要检查服务器端的 Session 存储和过期策略。

检查 PHP Session 配置

许多 PHP 环境默认的 Session 保存时间并不适合持续数小时的购买流程。可以在应用启动时明确设置 cookie 参数和过期时间:

<?php

$sessionLifetime = 4 * 60 * 60; // 4 hours

ini_set('session.gc_maxlifetime', (string) $sessionLifetime);

session_set_cookie_params([
    'lifetime' => $sessionLifetime,
    'path' => '/',
    'domain' => '',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

这些设置必须放在 session_start() 之前。

各参数的作用如下:

  • lifetime 控制浏览器保存 session cookie 的时长。
  • session.gc_maxlifetime 表示服务器端 Session 数据在被视为垃圾并清理前可以保留多久。
  • secure 要求网站全程使用 HTTPS。
  • httponly 阻止 JavaScript 直接读取 session cookie。
  • SameSite=Lax 通常适用于普通站内流程,以及用户从外部站点通过顶级导航返回的场景。

session.gc_maxlifetime 并不能保证数据一定保留到设定时间。实际清理行为还会受到 Session 存储方式、系统定时清理任务、共享主机配置,以及多个应用是否共用 Session 目录等因素影响。部分环境的系统清理策略还可能覆盖 PHP 中的设置。

延长 PHP Session 的有效期可以减少问题,但不应把 Session 当作 checkout 的唯一数据存储。

可以重点检查以下几种情况。

域名是否一致

这些地址的 cookie 作用范围可能不同:

example.com
www.example.com
checkout.example.com

如果流程需要跨子域名,应统一域名设计,或者谨慎设置 cookie 的 Domain。不要为了省事,把 cookie 暴露给不需要访问它的子域名。

是否在 HTTP 和 HTTPS 之间切换

checkout 应全程使用 HTTPS。启用 Secure 后,cookie 不会通过 HTTP 发送。

是否嵌入第三方页面

如果 checkout 运行在其他网站的 iframe 中,浏览器可能将其 cookie 视为第三方 cookie 并加以限制。此时最好让 checkout 在自己的顶级页面中运行,不要依赖第三方 cookie。

支付平台如何返回

如果支付服务通过跨站 POST 返回,浏览器不一定会发送 SameSite=Lax cookie。是否需要使用 SameSite=None; Secure,要根据支付提供商的实际返回方式决定。

更可靠的做法是让支付回调根据支付平台提供的订单引用查询数据库记录,而不是完全依赖浏览器 Session。服务器间 webhook 同样不会携带用户的 Session cookie。

是否使用了多台应用服务器

如果请求可能被分配到不同服务器,而每台服务器都用本地文件保存 Session,用户刷新后就可能无法读取之前的数据。可以考虑:

  • 使用 Redis 等共享 Session 存储。
  • 使用数据库 Session handler。
  • 临时启用负载均衡器的 sticky session,但不能用它代替 checkout 的持久化存储。

推荐的数据结构

可以在数据库中为未完成的购买流程创建一条 checkout 记录:

CREATE TABLE checkouts (
    id CHAR(36) PRIMARY KEY,
    resume_token_hash CHAR(64) NOT NULL UNIQUE,
    status VARCHAR(32) NOT NULL,
    quote_data JSON NOT NULL,
    customer_data JSON NULL,
    expires_at DATETIME NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

状态可以按业务需要设计为:

quoted
customer_details
payment_pending
paid
confirmed
expired

数据库记录是业务状态的权威来源,Session 只保存记录标识:

$_SESSION['checkout_id'] = $checkoutId;

这样即使用户刷新页面,应用仍能从数据库重新加载 checkout。如果 Session 确实丢失,也可以通过安全的恢复令牌找回记录。

使用恢复令牌

为游客生成不可预测的随机令牌:

<?php

$resumeToken = bin2hex(random_bytes(32));
$resumeTokenHash = hash('sha256', $resumeToken);

数据库只保存令牌的哈希值:

$stmt = $pdo->prepare(
    'INSERT INTO checkouts (
        id,
        resume_token_hash,
        status,
        quote_data,
        expires_at,
        created_at,
        updated_at
    ) VALUES (
        :id,
        :resume_token_hash,
        :status,
        :quote_data,
        :expires_at,
        NOW(),
        NOW()
    )'
);

$stmt->execute([
    'id' => $checkoutId,
    'resume_token_hash' => $resumeTokenHash,
    'status' => 'quoted',
    'quote_data' => json_encode($quoteData, JSON_THROW_ON_ERROR),
    'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
]);

原始令牌可以保存在受保护的 cookie 中:

setcookie('checkout_resume', $resumeToken, [
    'expires' => time() + 4 * 60 * 60,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

恢复 checkout 时,先尝试使用 Session,再检查恢复 cookie:

<?php

session_start();

$checkoutId = $_SESSION['checkout_id'] ?? null;
$checkout = null;

if ($checkoutId !== null) {
    $stmt = $pdo->prepare(
        'SELECT * FROM checkouts
         WHERE id = :id
           AND expires_at > NOW()'
    );
    $stmt->execute(['id' => $checkoutId]);
    $checkout = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}

if ($checkout === null && isset($_COOKIE['checkout_resume'])) {
    $resumeTokenHash = hash('sha256', $_COOKIE['checkout_resume']);

    $stmt = $pdo->prepare(
        'SELECT * FROM checkouts
         WHERE resume_token_hash = :resume_token_hash
           AND expires_at > NOW()'
    );
    $stmt->execute([
        'resume_token_hash' => $resumeTokenHash,
    ]);

    $checkout = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;

    if ($checkout !== null) {
        $_SESSION['checkout_id'] = $checkout['id'];
    }
}

令牌必须具有足够的随机性。订单完成、取消、过期或系统检测到异常后,应立即让令牌失效。对于登录用户,还应将 checkout 与用户账户绑定,并在恢复时验证用户身份。

页面访问应检查业务状态

不要只检查某个 Session 变量是否存在。应用应读取数据库记录,并确认 checkout 当前状态是否允许用户进入目标阶段:

<?php

$allowedStatuses = [
    'customer-details' => ['quoted', 'customer_details'],
    'payment' => ['customer_details', 'payment_pending'],
    'confirmation' => ['paid', 'confirmed'],
];

$page = 'payment';

if ($checkout === null) {
    header('Location: /quote-expired');
    exit;
}

if (!in_array($checkout['status'], $allowedStatuses[$page], true)) {
    header('Location: /checkout/resume');
    exit;
}

恢复页面可以根据状态把用户送到正确的位置:

$routes = [
    'quoted' => '/checkout/customer-details',
    'customer_details' => '/checkout/payment',
    'payment_pending' => '/checkout/payment',
    'paid' => '/checkout/confirmation',
    'confirmed' => '/checkout/confirmation',
];

header('Location: ' . $routes[$checkout['status']]);
exit;

这样,即使用户重新打开旧 URL,也不会因为缺少某个临时 Session 字段而丢失整个流程。

GET 刷新与表单重复提交

Customer Details 和 Payment 页面应能通过 GET 安全地重新加载。表单提交后,建议使用 Post/Redirect/Get:

<?php

// Validate and persist submitted customer details here.

$stmt = $pdo->prepare(
    'UPDATE checkouts
     SET customer_data = :customer_data,
         status = :status,
         updated_at = NOW()
     WHERE id = :id
       AND expires_at > NOW()'
);

$stmt->execute([
    'customer_data' => json_encode($customerData, JSON_THROW_ON_ERROR),
    'status' => 'customer_details',
    'id' => $checkout['id'],
]);

header('Location: /checkout/payment', true, 303);
exit;

浏览器随后保存的是 Payment 页面的 GET URL。移动设备恢复或刷新该页面时,不会重复提交 Customer Details 表单。

创建支付、扣款和确认订单的操作还必须支持幂等处理。可以为每次支付操作生成唯一的 idempotency key,防止网络重试或页面刷新造成重复扣款。具体实现取决于支付服务商提供的 API。

不要保存以下内容:

  • 完整银行卡号。
  • CVV/CVC。
  • 支付平台明确禁止持久化的认证数据。
  • 客户端修改后会影响订单金额的价格字段。

支付信息应交给符合相应安全要求的支付服务商处理。应用通常只保存支付平台返回的 token、payment ID、状态和必要的审计信息。

价格也必须在服务器端重新验证。即使恢复 cookie 有效,也不能直接信任客户端提交的商品价格、折扣或总金额。

实际落地方案

可以按以下顺序实施:

  1. 确认刷新前后是否收到了相同的 session cookie。
  2. 检查 Session 过期时间、服务器清理策略和多服务器存储方式。
  3. 将 quote、客户资料和 checkout 状态持久化到数据库。
  4. Session 中只保存 checkout_id。
  5. 为游客提供有有效期的随机恢复令牌。
  6. 每个页面都根据数据库中的状态决定继续、跳转还是提示过期。
  7. 使用 Post/Redirect/Get,保证页面 URL 可以安全刷新。
  8. 为订单创建和支付请求实现幂等处理。
  9. 订单完成或取消后,撤销恢复令牌并清理 Session 和 cookie。

Session、cookie 和数据库各自负责不同的事情。数据库可靠保存业务进度,Session 记录当前访问上下文,安全 cookie 则在 Session 不可用时帮助恢复 checkout。这套组合更适合移动设备,也能处理服务器重启、负载均衡和支付平台跳转等情况。

备注:内容仅供参考。