notice(后台登录·更正): admin 反复掉登录真因=路由守卫与refresh续期竞态
- 新增更正版 notice: 前端早接了 refresh 且生产已部署、后端 refresh 正常, 真因是 router/index.js 守卫 clearAllPendingRequests 在 refresh 窗口 abort 掉守卫在等的 menu/auth 请求 → initDynamicRoutes 把取消当鉴权失败 → logout 跳登录页(竞态,单设备即发生)。附前端修复办法。 - 作废早前错误结论那篇(冤枉前端没接 refresh + refresh 轮转说法错)。
这个提交包含在:
父节点
fc07470760
当前提交
a77bb39ab8
@ -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 作缓解(治标不治本),按需评估。
|
||||
正在加载...
x
在新工单中引用
屏蔽一个用户