Commit 2207d61b by luoqi

fix(web): 冷启动那次续期绕过了共享单飞 —— 一次性 refresh token 被换两次

「用着用着登录已过期」的根因,不是超时也不是网络,是 10103。

生产 client-diag 实录(2026-08-20,两次):

  ev=start                accessValid=N      ← 隔了 2 小时回到页面,access 到点过期
  ev=refresh-ok                              ← bootstrap 自己换了一次,成功
  ev=runtime-refresh-fail err=code=10103     ← 首屏请求 401 兜底刷,拿的是同一张旧票
  ev=runtime-auth-clear   err=code=10106     ← 清凭据 → 弹「登录已过期」

服务端的 refresh token 是**一次性**的:换票时 `redis.del(旧 jti)` 再发新的
(auth.service.ts:468)。同一张票被两条路各换一次,后到的那条必得 10103。

️ 这个坑**被发现过一次,只修了一半**:本文件末尾"过期前 60s 定时刷"那段
   早就改成走 `tryRefresh` 了,注释把根因写得清清楚楚 —— 唯独 bootstrap 这条漏了。
   而它恰恰是**每次冷启动**都会走的路径,比定时器常见得多。

改法:bootstrap 第 3 步(仅 refresh token 还在 → silent refresh)改走
api-client 的共享单飞 `tryRefresh()`,与请求 401 兜底刷共用同一个"在途 refresh"。

 别改成"把 refresh token 做成可重复使用":一次性是安全设计(泄露的旧票立即失效)。
 也别在 doRefresh 里对 10103 重试:票已经死了,重试只会多一次 401。

顺带核实 doRefresh 的清除边界是对的, 别动:
  5xx        → 不清 store(可能是暂时的)
  网络异常   → 不清
  服务端明确拒绝(10103)→ 才清
所以掉线主因确实只有这一个竞态,不是网络抖动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b7132fae
......@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { exchangeCode, refreshToken } from '@/lib/auth-api';
import { exchangeCode } from '@/lib/auth-api';
import { tryRefresh } from '@/lib/api-client';
import { env } from '@/lib/env';
......@@ -195,20 +195,33 @@ export function useAuthBootstrap() {
}
// ─── 3. 仅 refresh token 还在 → silent refresh ───
//
// 🔴 **必须走 api-client 的共享单飞 `tryRefresh`**,⛔ 不能直接调 `refreshToken()`。
// refresh token 是**一次性**的:服务端换票时 `redis.del(旧 jti)` 再发新的
// (auth.service.ts:468)。所以同一张票被两条路各换一次,后到的那条必得
// `10103 AUTH_INVALID_REFRESH_TOKEN` → 前端据此清会话 → 「登录已过期」。
//
// ── 2026-08-20 生产实录(client-diag)────────────────────────
// ev=start accessValid=N ← access 到点过期
// ev=refresh-ok ← 本函数直接换,成功,旧 jti 已作废
// ev=runtime-refresh-fail err=code=10103 ← 首屏请求 401 兜底刷,拿的是同一张旧票
// ev=runtime-auth-clear err=code=10106 ← 清凭据,弹「登录已过期」
// 主管当时什么都没做错,只是隔了 2 小时回到页面。
//
// ⚠️ 下面那个"过期前 60s 定时刷"**早就改成走单飞了**(见本文件末尾那段注释,
// 同一条根因),⛔ 唯独这里漏了 —— 而这里恰恰是**每次冷启动**都会走的路径,
// 比定时器常见得多。
if (s.refreshToken) {
setStatus('authenticating');
try {
const r = await refreshToken(s.refreshToken);
setTokens(r);
const ok = await tryRefresh(); // 成功即 setTokens、失败即 clear,内部已处理
if (ok) {
reportDiag({ ev: 'refresh-ok', codeTag });
setStatus('ready');
return;
} catch {
} else {
reportDiag({ ev: 'refresh-fail', codeTag, ...storeSnap(), ...envInfo });
clear();
setStatus('error');
return;
}
return;
}
// ─── 4. 全没了 → 弹「登录已过期」───
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment