# 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 作缓解(治标不治本),按需评估。
