279 lines
8.4 KiB
Markdown
279 lines
8.4 KiB
Markdown
# 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` — 请先审阅设计,再按本计划落地。
|