notice(后台登录·更正): admin 反复掉登录真因=路由守卫与refresh续期竞态

- 新增更正版 notice: 前端早接了 refresh 且生产已部署、后端 refresh 正常,
  真因是 router/index.js 守卫 clearAllPendingRequests 在 refresh 窗口
  abort 掉守卫在等的 menu/auth 请求 → initDynamicRoutes 把取消当鉴权失败
  → logout 跳登录页(竞态,单设备即发生)。附前端修复办法。
- 作废早前错误结论那篇(冤枉前端没接 refresh + refresh 轮转说法错)。
这个提交包含在:
API Changelog Bot 2026-05-26 14:49:25 +08:00
父节点 fc07470760
当前提交 a77bb39ab8
共有 2 个文件被更改,包括 89 次插入1 次删除

查看文件

@ -1,4 +1,13 @@
# notice(后台登录): admin 登录态约 2 小时就过期 → 需前端接入 refresh 静默续期(根因排查结论)
> # ⛔ 本篇已作废(结论有误,勿按此执行)
> 经浏览器实测复现 + 前后端源码/生产包核对,本篇两处关键结论都错了:
> ① 前端**早就接了 refresh 且生产已部署**(那次「零命中」是在落后 master 160 提交的过期本地代码上搜的);
> ② refresh **不轮转**(后端是滑动续期,串不变)。
> 真正根因是**前端路由守卫与 refresh 续期的竞态**,详见同目录
> **`26_notice_admin_relogin_REAL_root_cause_router_guard_refresh_race.md`**。
>
> ---
# ~~notice(后台登录): admin 登录态约 2 小时就过期 → 需前端接入 refresh 静默续期(根因排查结论)~~【作废,见上】
> **类型**: notice(后端无代码改动;双 token 机制后端早已就绪,缺前端接入)
> **关联 PR**: 无(后端不改);双 token 机制为工单 #2309 既有能力

查看文件

@ -0,0 +1,79 @@
# notice(后台登录·更正): admin 反复掉登录真因 = 前端路由守卫与 refresh 续期的竞态(非「前端没接 refresh」)
> **类型**: notice(根因更正 + 前端修复指引)
> **更正对象**: 作废本目录早前的 `26_notice_admin_login_token_expire_frontend_refresh.md`(那篇结论有误,见下)
> **日期**: 2026-05-26
> **影响**: 正式后台 admin「登录态一天内反复过期 / 弹登录已过期」
> **接收方**: mmg(前端修复)
> **后端**: 无需改代码;refresh 机制后端正常
---
## ⚠️ 先更正早前那篇结论(两处都错)
早前那篇 notice 说「hl-ui 全量检索 refreshToken 零命中,前端没接 refresh,必须前端接入」+「refresh token 会轮换、旧串失效」。**这两条都是错的**:
1. **前端早就接了 refresh,且生产已部署。** 那次「零命中」是在**落后 master 160 个提交的过期本地代码**上搜的。实际:
- 生产包 `admin.1814.love/assets/components-*.js` 实测含 `auth/refresh`(3 处)、`refreshAdminToken`(4 处)、`refreshToken`(11 处)、`accessTokenExpiresIn`
- 源码 `src/api/user.js``refreshAdminToken``src/stores/user.js``setTokens` + pinia 持久化、`src/utils/request.js` 有完整的 401→refresh→重放 + 并发单飞队列(`tryRefreshAndRetry` / `refreshWaitQueue`)。注释里明确写了 PR #2285 / #2312
2. **refresh 不轮转。** 后端是**滑动续期**(`TokenService.refreshAdminAccessToken`:refresh 串保持不变,只重置 Redis TTL,工单 #2309)。前端按「不轮转」实现是对的。
**所以根因不在「前端没接 refresh」。** 前端、后端的 refresh 单看都正常。
---
## 🎯 真正的根因:路由守卫在 refresh 续期窗口里把请求 abort 了 → 被当成鉴权失败 → logout
`src/router/index.js` 的全局前置守卫 `beforeEach`:
1. **每次路由跳转,第一件事就调 `clearAllPendingRequests()`** —— abort 掉所有在飞请求。
2. 已登录但菜单未加载(刷新页面 / 切路由时 store 内存态重置)→ 进入 `initDynamicRoutes()``fetchMenuAndPermissions()` → 请求 `GET /admin/menu/my`
3. 此时若 access 已过期 → `menu/my` 收到 401 → `request.js``tryRefreshAndRetry` 发起 refresh(返回 200,新 token 没问题)→ 重放原请求。
4. **竞态点**:在 refresh / 重放这个异步窗口里,守卫的重导航(`next({...to, replace:true})`)或并发的 bootstrap 流程**又触发了一次 `clearAllPendingRequests()`**,把守卫正在 `await` 的那个 `menu/my`(或并发的鉴权 bootstrap 请求)**abort 掉**。
5. 被 abort 的请求 reject → `fetchMenuAndPermissions()` 抛异常 → `initDynamicRoutes()` 的 catch **不区分「请求被取消」和「真鉴权失败」,一律返回 `status:'error'`** → 守卫走 `await userStore.logout()` + `next({name:'Login', query:{redirect}})`**跳登录页**
**关键:refresh 本身成功了(网络面板能看到 `/admin/auth/refresh` 返 200),但守卫把它在等的请求掐断、当成鉴权失败,于是 logout。** 这就是「前端明明接了 refresh,却还是反复掉登录」的真相,**单设备、单会话就会发生**,不需要多端登录。
触发频率:access 每过期一次(正式默认 2 小时),用户下一次刷新页面 / 切路由就有概率命中这个竞态被踢 → 一天内 access 过期几次,就可能掉几次。
---
## 🧪 实测复现(测试服,把 admin access TTL 临时调成 120s)
同样的「过期后导航 /dashboard」场景,**同条件下跑出两种相反结果,正好证明是竞态**:
- **失败**那次:网络面板 `POST /admin/auth/refresh` 返 **200**,但页面仍跳到 `/login?redirect=/dashboard`(被踢)。
- **成功**那次:`menu/my`(401)→ `auth/refresh`(200)→ `menu/my` 重放(200)→ 留在 /dashboard,无感续期。
两者唯一差别是 abort 与重放的先后时序 → 竞态确诊。
---
## ✅ 解决办法(前端 mmg,任选,建议 1+2 一起)
**1.(必做,最小修复)守卫/续期路径不要把「请求被取消」当鉴权失败。**
`initDynamicRoutes()` / `fetchMenuAndPermissions()` 的 catch 里,识别 Axios 取消错误(`axios.isCancel(err)``err.code === 'ERR_CANCELED'` / `err.name === 'CanceledError'`),**取消 ≠ 鉴权失败**:取消时不要 `logout()`,而是当作可重试(后续那次导航会自然重跑守卫),或直接放行重试。这一条就能斩断「abort → logout」的链路。
**2.(建议)守卫的 bootstrap 请求在续期窗口内免疫 `clearAllPendingRequests()`。**
项目已有「受保护请求」机制(`isProtectedRequest`)。把守卫初始化阶段必需的 `GET /admin/menu/my` 等鉴权 bootstrap 请求标记为受保护,使其不被路由跳转的 `clearAllPendingRequests()` abort;或在 `isRefreshing` 为真(refresh 在飞)期间不执行 abort。
**3.(可叠加)守卫先 await refresh 单飞再判定。**
若检测到 `isRefreshing`(或本次导航触发了 401→refresh),守卫等 refresh 单飞结束、用新 token 重放成功后再决定 `next`,避免在续期未完成时就判 error→logout。
> 反模式:`initDynamicRoutes` 的 catch 目前对**任何** error(含 AbortError/CanceledError)都返回 `status:'error'` → 守卫 logout。这是把「良性的请求取消」误判成「登录失效」的根。
---
## 🔍 前端自测方式(可把 access TTL 调短便于复现)
1. 登录后等 access 过期(测试服现已临时设 120s),期间反复**刷新页面 / 切菜单**多次。
2. 现状:有概率跳登录页,且网络面板能看到该次 `/admin/auth/refresh` 其实返 200(说明续期成功但仍被踢)。
3. 修复后:全程无「登录已过期」,网络面板可见自动 `/admin/auth/refresh` + 原请求重放,稳定留在当前页(多刷几十次不掉)。
---
## 🔧 后端侧说明
- 后端 refresh 机制(`/admin/auth/refresh` + 登录返回 refreshToken + 滑动续期)实测正常,**无需改动**。
- 测试服 admin access TTL 已临时调为 **120s** 便于前端复现(`hl-user-service-test.yml` 里带 `[TEMP]` 注释的块),前端验证完成后后端会恢复为默认 7200s。
- 如前端修复需要时间,后端可临时调大 access TTL 作缓解(治标不治本),按需评估。