2026-05-20-web-token-auto-refresh.md 8.4 KB

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

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: 创建模块骨架

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 使用)

在同一文件导出:

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

interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
  showErrorMessage?: boolean
  showSuccessMessage?: boolean
  _skipTokenRefresh?: boolean
  _retriedAfterRefresh?: boolean
}
  • Step 2: 抽取 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)
}
  • 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

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 段补充

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