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 | 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 关系 |
Files:
Modify: web-admin/src/api/auth.ts
[ ] Step 1: 修改 fetchRefreshToken 使用 JSON body
export function fetchRefreshToken(refreshToken: string) {
return request.post<{
token: string
refreshToken: string
expiresIn: number
}>({
url: '/web/auth/refresh-token',
data: { refreshToken },
showErrorMessage: false
})
}
在浏览器 Network 中触发一次 refresh(可用临时按钮或控制台调用),确认 Request Payload 为 {"refreshToken":"..."} 且非 query string。
Files:
Create: web-admin/src/utils/http/token-refresh.ts
[ ] Step 1: 创建模块骨架
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
}
在同一文件导出:
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 协调,避免过度设计。)
Files:
Modify: web-admin/src/utils/http/index.ts
[ ] Step 1: 扩展 ExtendedAxiosRequestConfig
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
showErrorMessage?: boolean
showSuccessMessage?: boolean
_skipTokenRefresh?: boolean
_retriedAfterRefresh?: boolean
}
tryRefreshAndRetry在 handleUnauthorizedError 之前新增:
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)
}
当 code === ApiStatus.unauthorized 时,若可刷新则 return tryRefreshAndRetry(response.config, msg),否则 handleUnauthorizedError。
注意:response.config 需转为 InternalAxiosRequestConfig 并保留原 data。
error.response?.status === 401 时同样调用 tryRefreshAndRetry(error.config, ...)。
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。
本地 ACCESS_TOKEN_EXPIRE_MINUTES=1,登录后等待过期,快速点击多个菜单,Network 中 refresh-token 仅 1 次。
Files:
Modify: web-admin/src/utils/http/index.ts — resolveUserId
[ ] Step 1: accessToken 无 sub 时用 refreshToken 解 sub
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 导入。)
Files:
backend/.env.exampleModify: backend/README.md(可选一句)
[ ] Step 1: 在 .env.example JWT 段补充
# Access Token 有效期(分钟)。过短会导致频繁走 refresh;建议生产 120+。
# Refresh Token / Redis 会话均为 7 天,需前端自动 refresh(web-admin 已实现)。
# ACCESS_TOKEN_EXPIRE_MINUTES=120
| 设计要求 | 对应任务 |
|---|---|
| 401 被动刷新 + 单飞 | Task 2, 3 |
| 修复 body 传参 | Task 1 |
| 防死循环 | Task 3 _skipTokenRefresh / _retriedAfterRefresh |
| 加密 userId | Task 4 |
| 多 Tab 首期不处理 | 设计文档已说明,无任务 |
| 方案 C 后端滑动会话 | 未纳入本期(可选后续) |
Plan complete: docs/superpowers/plans/2026-05-20-web-token-auto-refresh.md
执行方式:
设计文档:docs/superpowers/specs/2026-05-20-web-token-auto-refresh-design.md — 请先审阅设计,再按本计划落地。