From cb9687e4725bb55d430df4c2d253b31c5ff3cd09 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 16 Jun 2026 18:19:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8Ctoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/modules/account/login.ts | 8 +- src/api/request/refreshInterceptor.ts | 124 +++++++++++++++++++++ src/api/request/requestClient.ts | 154 ++++++++++---------------- src/api/request/types.ts | 4 + src/router/guard.ts | 2 +- src/stores/modules/account.ts | 42 +++---- src/views/auth/Login.vue | 2 +- src/views/auth/Register.vue | 4 +- 8 files changed, 221 insertions(+), 119 deletions(-) create mode 100644 src/api/request/refreshInterceptor.ts diff --git a/src/api/modules/account/login.ts b/src/api/modules/account/login.ts index 0d25a83..f8b71a4 100644 --- a/src/api/modules/account/login.ts +++ b/src/api/modules/account/login.ts @@ -81,9 +81,15 @@ export function register(req: RegisterRequest) { * Refresh Token 保持不变,仅在满 30 天或重新登录时更新。 * 该接口无需登录即可调用。 * + * 注意:该接口设置 __skipRefresh=true,避免刷新失败时自身触发 401 刷新逻辑 + * 造成死循环。 + * * @param req - 刷新令牌请求参数 * @returns 包含新 accessToken 和原 refreshToken 的响应 */ export function refresh(req: RefreshTokenRequest) { - return requestClient.post('/account/refresh', req) + return requestClient.post('/account/refresh', req, { + skipAuth: true, + __skipRefresh: true + }) } diff --git a/src/api/request/refreshInterceptor.ts b/src/api/request/refreshInterceptor.ts new file mode 100644 index 0000000..1a64b52 --- /dev/null +++ b/src/api/request/refreshInterceptor.ts @@ -0,0 +1,124 @@ +import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios' +import type { ApiResponse, InternalRequestConfig } from './types' + +/** + * 刷新拦截器配置 + */ +interface RefreshInterceptorConfig { + /** axios 实例,用于重试请求 */ + client: AxiosInstance + /** 刷新失败后的重新认证(清除状态 + 跳转登录页) */ + doReAuthenticate: () => Promise + /** 刷新 token,返回新的 Access Token */ + doRefreshToken: () => Promise + /** 格式化请求头中的 token 值 */ + formatToken: (token: string) => string + /** 是否正在刷新中(由 RequestClient 维护) */ + isRefreshing: { value: boolean } + /** 刷新期间等待队列 */ + refreshTokenQueue: Array<(token: string) => void> +} + +/** + * 创建 Token 自动刷新的响应拦截器 + * + * 防竞态机制(队列模式): + * 1. 收到 401 → 检查 isRefreshing + * 2. 未在刷新 → 标记 isRefreshing=true,发起刷新 + * 3. 已在刷新 → 将请求加入 refreshTokenQueue 等待 + * 4. 刷新成功 → 用新 token 执行队列中所有等待请求 + 重试当前请求 + * 5. 刷新失败 → 清空队列 + doReAuthenticate + */ +export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig) { + const { client, doReAuthenticate, doRefreshToken, formatToken, isRefreshing, refreshTokenQueue } = + config + + /** + * 将 401 业务错误转换为类 AxiosError 结构,统一由 rejected 处理 + */ + async function onFulfilled(response: AxiosResponse) { + const data = response.data + if (data.code !== 401) { + return response + } + + const reqConfig = response.config as InternalRequestConfig + // 跳过刷新或已重试过 → 终端 401,直接重新认证 + if (reqConfig.__isRetryRequest || reqConfig.__skipRefresh) { + await doReAuthenticate() + return Promise.reject(data) + } + // 构造类 AxiosError,统一由 rejected 走刷新流程 + const fakeError = { + config: response.config, + response: { status: 401, data } + } as AxiosError + return onRejected(fakeError) + } + + /** + * 处理 HTTP 401(以及业务 401 转换后的类 AxiosError) + */ + async function onRejected(error: AxiosError) { + const { config: reqConfig, response } = error + const internalConfig = reqConfig as InternalRequestConfig | undefined + + // 非 401 错误,直接抛出 + if (response?.status !== 401) { + throw error + } + + // 未启用刷新 或 已是重试请求 或 跳过刷新 → 直接重新认证 + if (internalConfig?.__isRetryRequest || internalConfig?.__skipRefresh) { + await doReAuthenticate() + throw error + } + + console.log('[401] 刷新 token 中...', isRefreshing.value) + // 正在刷新中 → 将请求加入队列,等待刷新完成后重试 + if (isRefreshing.value) { + return new Promise((resolve) => { + refreshTokenQueue.push((newToken: string) => { + reqConfig!.headers = reqConfig!.headers || {} + reqConfig!.headers.saToken = formatToken(newToken) + resolve(client.request(reqConfig!)) + }) + }) + } + + // === 开始刷新 === + isRefreshing.value = true + // 标记当前请求为已重试,防止无限循环 + internalConfig!.__isRetryRequest = true + + try { + const newToken = await doRefreshToken() + console.log('[401] 刷新 token 成功', newToken) + if (!newToken) { + throw new Error('刷新 token 失败') + } + + // 用新 token 重试当前请求 + const retryPromise = client.request({ + ...reqConfig, + headers: { ...reqConfig?.headers, saToken: formatToken(newToken) } + }) + + // 处理队列中等待的请求 + refreshTokenQueue.forEach((callback) => callback(newToken)) + refreshTokenQueue.length = 0 + + return retryPromise + } catch (refreshError) { + // 刷新失败 → 清空队列 + 重新认证 + refreshTokenQueue.forEach((callback) => callback('')) + refreshTokenQueue.length = 0 + await doReAuthenticate() + throw refreshError + } finally { + isRefreshing.value = false + } + } + + return { onFulfilled, onRejected } +} diff --git a/src/api/request/requestClient.ts b/src/api/request/requestClient.ts index 43ebe57..8949594 100644 --- a/src/api/request/requestClient.ts +++ b/src/api/request/requestClient.ts @@ -1,7 +1,8 @@ -import axios, { type AxiosInstance, type AxiosRequestConfig, type CreateAxiosDefaults } from 'axios' +import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios' import { useAccountStore } from '@/stores' import { eventBus, AppEvents } from '@/utils' import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types' +import { createRefreshTokenInterceptor } from './refreshInterceptor' /** * 替换 URL 中的路径参数占位符 @@ -38,6 +39,12 @@ class RequestClient { /** axios 实例 */ private instance: AxiosInstance + /** 是否正在刷新 token */ + isRefreshing = false + + /** 刷新期间等待队列(401 请求在刷新完成前暂存,刷新后用新 token 重试) */ + refreshTokenQueue: Array<(token: string) => void> = [] + constructor(config?: CreateAxiosDefaults) { this.instance = axios.create({ baseURL: '/api', @@ -63,7 +70,7 @@ class RequestClient { config.url = resolvePathParams(config.url, reqConfig.pathParams) } - // 跳过鉴权时不注入 saToken(Access Token) + // 跳过鉴权时不注入 saToken if (!reqConfig?.skipAuth) { const accountStore = useAccountStore() if (accountStore.accessToken) { @@ -78,107 +85,85 @@ class RequestClient { } ) - // === 响应拦截器 === - // 拦截器内部将 axiosResponse 解包为 ApiResponse, - // 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型。 + // === 通用响应拦截器 === + // 职责:解包 ApiResponse + 发布非 401 的错误事件。 + // 401 全部透传,由 refreshInterceptor 统一处理。 this.instance.interceptors.response.use( // @ts-expect-error 拦截器解包 axiosResponse → ApiResponse (response) => { const data = response.data as ApiResponse - // 业务成功 if (data.code === 0) { return data } - // 业务错误事件载荷 - const errorEvent: RequestErrorEvent = { + // 401 透传给刷新拦截器,此处不处理 + if (data.code === 401) { + return response + } + + eventBus.emit(AppEvents.REQUEST_ERROR, { type: 'business', code: data.code, message: data.message || '请求失败' - } - - // 401 → 额外发布未授权事件,解耦路由 - if (data.code === 401) { - eventBus.emit(AppEvents.UNAUTHORIZED) - } - - // 发布请求错误事件,由 errorUI 模块订阅展示 - eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent) + } satisfies RequestErrorEvent) return Promise.reject(data) }, (error) => { - let errorEvent: RequestErrorEvent - + // HTTP 401 透传给刷新拦截器 if (error.response?.status === 401) { - eventBus.emit(AppEvents.UNAUTHORIZED) - errorEvent = { - type: 'http', - code: 401, - message: '登录已过期,请重新登录' - } - } else if (!error.response) { - errorEvent = { - type: 'network', - message: '网络异常,请检查网络连接' - } - } else { - errorEvent = { - type: 'http', - code: error.response.status, - message: `请求错误 (${error.response.status})` - } + throw error } - eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent) + eventBus.emit(AppEvents.REQUEST_ERROR, { + type: error.response ? 'http' : 'network', + code: error.response?.status, + message: error.response + ? `请求错误 (${error.response.status})` + : '网络异常,请检查网络连接' + } satisfies RequestErrorEvent) return Promise.reject(error) } ) + + // === Token 刷新拦截器 === + // 通用拦截器透传 401 → 此处统一处理刷新/重试/重新认证 + const { onFulfilled, onRejected } = createRefreshTokenInterceptor({ + client: this.instance, + doReAuthenticate: async () => { + const accountStore = useAccountStore() + accountStore.logout() + eventBus.emit(AppEvents.UNAUTHORIZED) + }, + doRefreshToken: () => { + const accountStore = useAccountStore() + return accountStore.refreshAccessToken() + }, + formatToken: (token: string) => token, + isRefreshing: { + get value() { + return requestClient.isRefreshing + }, + set value(v: boolean) { + requestClient.isRefreshing = v + } + }, + refreshTokenQueue: this.refreshTokenQueue + }) + // @ts-expect-error 拦截器解包 axiosResponse → ApiResponse + this.instance.interceptors.response.use(onFulfilled, onRejected) } /** * 通用请求方法 * * 适用于路径参数、混合参数等复杂场景。 - * - * @example - * ```ts - * // 路径参数 + 查询参数 - * requestClient.request({ - * method: 'GET', - * url: '/user/:id/posts', - * params: { page: 1 }, - * pathParams: { id: 1 } - * }) - * - * // 路径参数 + 请求体 - * requestClient.request({ - * method: 'POST', - * url: '/user/:id/post', - * data: body, - * pathParams: { id: 1 } - * }) - * - * // 自定义请求头 + 跳过鉴权 - * requestClient.request({ - * method: 'POST', - * url: '/public/upload', - * data: formData, - * headers: { 'Content-Type': 'multipart/form-data' }, - * requestOptions: { skipAuth: true } - * }) - * ``` */ request(config: InternalRequestConfig): Promise> { return this.instance.request(config) } - /** - * GET 请求 - * @param url - 请求地址 - * @param params - 查询参数 - * @param config - 额外配置(pathParams、requestOptions 等) - */ + /** GET 请求 */ get( url: string, params?: Record, @@ -187,12 +172,7 @@ class RequestClient { return this.instance.get(url, { params, ...config }) } - /** - * POST 请求 - * @param url - 请求地址 - * @param data - 请求体 - * @param config - 额外配置(pathParams、requestOptions 等) - */ + /** POST 请求 */ post( url: string, data?: unknown, @@ -201,12 +181,7 @@ class RequestClient { return this.instance.post(url, data, config) } - /** - * PUT 请求 - * @param url - 请求地址 - * @param data - 请求体 - * @param config - 额外配置(pathParams、requestOptions 等) - */ + /** PUT 请求 */ put( url: string, data?: unknown, @@ -215,21 +190,12 @@ class RequestClient { return this.instance.put(url, data, config) } - /** - * DELETE 请求 - * @param url - 请求地址 - * @param config - 额外配置(pathParams、requestOptions 等) - */ + /** DELETE 请求 */ delete(url: string, config?: InternalRequestConfig): Promise> { return this.instance.delete(url, config) } - /** - * PATCH 请求 - * @param url - 请求地址 - * @param data - 请求体 - * @param config - 额外配置(pathParams、requestOptions 等) - */ + /** PATCH 请求 */ patch( url: string, data?: unknown, diff --git a/src/api/request/types.ts b/src/api/request/types.ts index 69679eb..a3b0f09 100644 --- a/src/api/request/types.ts +++ b/src/api/request/types.ts @@ -16,6 +16,10 @@ export interface InternalRequestConfig extends AxiosRequestConfig { pathParams?: Record /** 跳过鉴权注入(不携带 saToken 头) */ skipAuth?: boolean + /** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */ + __isRetryRequest?: boolean + /** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */ + __skipRefresh?: boolean } /** 请求错误事件载荷 */ diff --git a/src/router/guard.ts b/src/router/guard.ts index c4888a7..6b81550 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -23,7 +23,7 @@ function setupCommonGuard(router: Router) { const accountStore = useAccountStore() // 首次导航时验证 token 有效性(检查持久化的 token 是否过期) - await accountStore.initAuth() + // await accountStore.initAuth() // 已登录用户访问公开路由(如登录页),直接跳转首页 if (accountStore.isLoggedIn() && isPublicPath(to.path)) { diff --git a/src/stores/modules/account.ts b/src/stores/modules/account.ts index 20c8e56..0dafcd6 100644 --- a/src/stores/modules/account.ts +++ b/src/stores/modules/account.ts @@ -53,9 +53,11 @@ export const useAccountStore = defineStore( * * - 无 token → 无操作 * - token 有效 → 拉取用户信息,标记已登录 - * - token 过期 → 尝试用 Refresh Token 刷新,刷新失败则清除 + * - token 过期 → requestClient 拦截器自动调用 refreshAccessToken() 刷新后重试, + * 若刷新也失败则抛出异常,此处 catch 后清除认证状态 */ const initAuth = async () => { + console.log('initAuth', isInitialized.value) // 已验证过,跳过 if (isInitialized.value) return isInitialized.value = true @@ -68,18 +70,8 @@ export const useAccountStore = defineStore( // 成功获取用户信息,token 有效 isTokenValid.value = true } catch { - // Access Token 可能过期,尝试用 Refresh Token 刷新 - if (refreshToken.value) { - try { - await doRefresh() - isTokenValid.value = true - } catch { - // 刷新也失败,清除 - clearAuth() - } - } else { - clearAuth() - } + // 请求失败(包括自动刷新也失败),清除认证状态 + clearAuth() } } @@ -92,7 +84,7 @@ export const useAccountStore = defineStore( const res = await loginApi(params) accessToken.value = res.data.accessToken refreshToken.value = res.data.refreshToken - await fetchUserInfo() + // await fetchUserInfo() isTokenValid.value = true } @@ -105,7 +97,7 @@ export const useAccountStore = defineStore( const res = await registerApi(params) accessToken.value = res.data.accessToken refreshToken.value = res.data.refreshToken - await fetchUserInfo() + // await fetchUserInfo() isTokenValid.value = true } @@ -127,17 +119,26 @@ export const useAccountStore = defineStore( } /** - * 使用 Refresh Token 刷新 Access Token - * 成功后更新 accessToken,Refresh Token 不变(由服务端行为决定) + * 刷新 Access Token + * + * 由刷新拦截器的 doRefreshToken 调用。 + * 返回新的 Access Token 字符串,方便拦截器直接使用。 + * + * @returns 新的 Access Token */ - const doRefresh = async () => { + const refreshAccessToken = async (): Promise => { + if (!refreshToken.value) { + throw new Error('无 Refresh Token,无法刷新') + } + const res = await refreshApi({ refreshToken: refreshToken.value }) accessToken.value = res.data.accessToken - // 如果服务端返回了新的 refreshToken 则更新 if (res.data.refreshToken) { refreshToken.value = res.data.refreshToken } - await fetchUserInfo() + isTokenValid.value = true + + return accessToken.value } /** 清除所有认证状态 */ @@ -158,6 +159,7 @@ export const useAccountStore = defineStore( login, register, logout, + refreshAccessToken, fetchUserInfo } }, diff --git a/src/views/auth/Login.vue b/src/views/auth/Login.vue index 839f416..996cbf8 100644 --- a/src/views/auth/Login.vue +++ b/src/views/auth/Login.vue @@ -20,7 +20,7 @@ diff --git a/src/views/auth/Register.vue b/src/views/auth/Register.vue index 357d5f4..eac7536 100644 --- a/src/views/auth/Register.vue +++ b/src/views/auth/Register.vue @@ -21,7 +21,7 @@ @@ -66,7 +66,7 @@