Files
zkxw/docs/superpowers/plans/2026-05-20-web-token-auto-refresh.md
T
2026-05-20 14:32:07 +08:00

279 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<void> | 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<void> {
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<unknown>
const pendingRetries: RetryFn[] = []
export function enqueueAfterRefresh(fn: RetryFn): Promise<unknown> {
return new Promise((resolve, reject) => {
pendingRetries.push(async () => {
try {
resolve(await fn())
} catch (e) {
reject(e)
}
})
})
}
export async function flushPendingRetries(): Promise<void> {
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<AxiosResponse | never> {
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<typeof useUserStore>): 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` — 请先审阅设计,再按本计划落地。