# Web 管理端 Token 自动续期 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 Access Token 过期时通过 Refresh Token 静默续期并重试请求,避免用户操作中被误踢登录。 **Architecture:** 新建 `token-refresh.ts` 负责单飞刷新与请求队列;`http/index.ts` 在 401 时先刷新再重试;修复 `fetchRefreshToken` 为 JSON body。首期不做多 Tab 广播与定时主动刷新。 **Tech Stack:** Vue 3 / Pinia / Axios / FastAPI JWT + Redis 会话 **Design Doc:** `docs/superpowers/specs/2026-05-20-web-token-auto-refresh-design.md` --- ## File Structure Map | File | Responsibility | |------|----------------| | `web-admin/src/utils/http/token-refresh.ts` | **新建** — 单飞 refresh、队列、URL 白名单、从 refreshToken 解析 userId | | `web-admin/src/utils/http/index.ts` | **修改** — 401 走刷新队列;重试原请求;刷新失败再 logOut | | `web-admin/src/api/auth.ts` | **修改** — `fetchRefreshToken` body 传参;静默错误提示选项 | | `web-admin/src/store/modules/user.ts` | **复用** — `setToken` / `refreshToken` / `logOut` | | `backend/.env.example` | **可选** — 注释说明 ACCESS_TOKEN 与 refresh 关系 | --- ### Task 1: 修复 refresh API 契约 **Files:** - Modify: `web-admin/src/api/auth.ts` - [ ] **Step 1: 修改 `fetchRefreshToken` 使用 JSON body** ```typescript export function fetchRefreshToken(refreshToken: string) { return request.post<{ token: string refreshToken: string expiresIn: number }>({ url: '/web/auth/refresh-token', data: { refreshToken }, showErrorMessage: false }) } ``` - [ ] **Step 2: 手动验证** 在浏览器 Network 中触发一次 refresh(可用临时按钮或控制台调用),确认 Request Payload 为 `{"refreshToken":"..."}` 且非 query string。 --- ### Task 2: 实现 token-refresh 模块 **Files:** - Create: `web-admin/src/utils/http/token-refresh.ts` - [ ] **Step 1: 创建模块骨架** ```typescript import type { InternalAxiosRequestConfig } from 'axios' import { fetchRefreshToken } from '@/api/auth' import { useUserStore } from '@/store/modules/user' const AUTH_SKIP_REFRESH_URLS = [ '/web/auth/login', '/web/auth/refresh-token', '/web/auth/logout' ] let refreshPromise: Promise | null = null export function shouldSkipTokenRefresh(url?: string): boolean { if (!url) return false return AUTH_SKIP_REFRESH_URLS.some((p) => url.includes(p)) } export function parseSubFromJwt(token: string): string { if (!token || token.split('.').length < 2) return '' try { const payload = token.split('.')[1] const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') const pad = '='.repeat((4 - (b64.length % 4)) % 4) const json = atob(b64 + pad) const obj = JSON.parse(json) return String(obj?.sub || '') } catch { return '' } } export async function refreshAccessTokenOnce(): Promise { if (refreshPromise) return refreshPromise refreshPromise = (async () => { const userStore = useUserStore() const rt = userStore.refreshToken if (!rt) throw new Error('NO_REFRESH_TOKEN') const res = await fetchRefreshToken(rt) userStore.setToken(res.token, res.refreshToken) })().finally(() => { refreshPromise = null }) return refreshPromise } ``` - [ ] **Step 2: 添加请求队列类型(供 index.ts 使用)** 在同一文件导出: ```typescript type RetryFn = () => Promise const pendingRetries: RetryFn[] = [] export function enqueueAfterRefresh(fn: RetryFn): Promise { return new Promise((resolve, reject) => { pendingRetries.push(async () => { try { resolve(await fn()) } catch (e) { reject(e) } }) }) } export async function flushPendingRetries(): Promise { const fns = pendingRetries.splice(0, pendingRetries.length) for (const fn of fns) { await fn() } } ``` (实现时可改为「刷新成功后逐个 resolve 队列」,与 `index.ts` 协调,避免过度设计。) --- ### Task 3: 接入 HTTP 拦截器 **Files:** - Modify: `web-admin/src/utils/http/index.ts` - [ ] **Step 1: 扩展 `ExtendedAxiosRequestConfig`** ```typescript interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { showErrorMessage?: boolean showSuccessMessage?: boolean _skipTokenRefresh?: boolean _retriedAfterRefresh?: boolean } ``` - [ ] **Step 2: 抽取 `tryRefreshAndRetry`** 在 `handleUnauthorizedError` 之前新增: ```typescript async function tryRefreshAndRetry( config: ExtendedAxiosRequestConfig, message?: string ): Promise { const userStore = useUserStore() if (config._skipTokenRefresh || shouldSkipTokenRefresh(config.url)) { return handleUnauthorizedError(message) } if (!userStore.refreshToken) { return handleUnauthorizedError(message) } if (config._retriedAfterRefresh) { return handleUnauthorizedError(message) } await refreshAccessTokenOnce() config._retriedAfterRefresh = true return axiosInstance.request(config) } ``` - [ ] **Step 3: 修改响应成功拦截器** 当 `code === ApiStatus.unauthorized` 时,若可刷新则 `return tryRefreshAndRetry(response.config, msg)`,否则 `handleUnauthorizedError`。 注意:`response.config` 需转为 `InternalAxiosRequestConfig` 并保留原 `data`。 - [ ] **Step 4: 修改响应错误拦截器** `error.response?.status === 401` 时同样调用 `tryRefreshAndRetry(error.config, ...)`。 - [ ] **Step 5: refresh 专用调用** `fetchRefreshToken` 内部 request 走同一 axios 时,为 refresh 请求设置 `_skipTokenRefresh: true`(在 auth.ts 的 request 配置传入,需 api 层支持透传;或在 `token-refresh.ts` 用裸 axios 调 `/web/auth/refresh-token` 并手动带加密逻辑)。 **推荐最小实现:** 在 `getEncryptSkipReason` 或请求 config 上,对 `refresh-token` URL 设置 `_skipTokenRefresh`,且 refresh 失败不进入 `tryRefreshAndRetry`。 - [ ] **Step 6: 单飞并发验证** 本地 `ACCESS_TOKEN_EXPIRE_MINUTES=1`,登录后等待过期,快速点击多个菜单,Network 中 `refresh-token` 仅 1 次。 --- ### Task 4: 加密场景 userId 后备 **Files:** - Modify: `web-admin/src/utils/http/index.ts` — `resolveUserId` - [ ] **Step 1: accessToken 无 sub 时用 refreshToken 解 sub** ```typescript function resolveUserId(userStore: ReturnType): string { const info = userStore.info || ({} as any) const fromInfo = String(info.id || info.userId || '') if (fromInfo) return fromInfo const fromAccess = parseUserIdFromToken(userStore.accessToken) if (fromAccess) return fromAccess return parseSubFromJwt(userStore.refreshToken) } ``` (`parseSubFromJwt` 从 `token-refresh.ts` 导入。) --- ### Task 5: 文档与配置说明 **Files:** - Modify: `backend/.env.example` - Modify: `backend/README.md`(可选一句) - [ ] **Step 1: 在 `.env.example` JWT 段补充** ```env # Access Token 有效期(分钟)。过短会导致频繁走 refresh;建议生产 120+。 # Refresh Token / Redis 会话均为 7 天,需前端自动 refresh(web-admin 已实现)。 # ACCESS_TOKEN_EXPIRE_MINUTES=120 ``` --- ### Task 6: 回归测试清单 - [ ] 正常登录 → 列表加载 - [ ] Access 过期 + Refresh 有效 → 自动续期 - [ ] Refresh 无效 → 登出 + 提示 - [ ] 登录页 / 登出接口不触发 refresh - [ ] 生产加密开关 ON 时 refresh 成功 - [ ] 修复前「操作中突然 token 无效」场景复测 --- ## Self-Review(计划 vs 设计) | 设计要求 | 对应任务 | |----------|----------| | 401 被动刷新 + 单飞 | Task 2, 3 | | 修复 body 传参 | Task 1 | | 防死循环 | Task 3 `_skipTokenRefresh` / `_retriedAfterRefresh` | | 加密 userId | Task 4 | | 多 Tab 首期不处理 | 设计文档已说明,无任务 | | 方案 C 后端滑动会话 | 未纳入本期(可选后续) | --- ## Execution Handoff Plan complete: `docs/superpowers/plans/2026-05-20-web-token-auto-refresh.md` **执行方式:** 1. **Subagent-Driven** — 每 Task 独立子代理 + 步骤间审查(推荐) 2. **Inline Execution** — 本会话按 Task 顺序实现,检查点人工确认 设计文档:`docs/superpowers/specs/2026-05-20-web-token-auto-refresh-design.md` — 请先审阅设计,再按本计划落地。