feat: 双token

This commit is contained in:
2026-06-16 18:19:48 +08:00
parent 172c83ea73
commit cb9687e472
8 changed files with 221 additions and 119 deletions

View File

@@ -81,9 +81,15 @@ export function register(req: RegisterRequest) {
* Refresh Token 保持不变,仅在满 30 天或重新登录时更新。 * Refresh Token 保持不变,仅在满 30 天或重新登录时更新。
* 该接口无需登录即可调用。 * 该接口无需登录即可调用。
* *
* 注意:该接口设置 __skipRefresh=true避免刷新失败时自身触发 401 刷新逻辑
* 造成死循环。
*
* @param req - 刷新令牌请求参数 * @param req - 刷新令牌请求参数
* @returns 包含新 accessToken 和原 refreshToken 的响应 * @returns 包含新 accessToken 和原 refreshToken 的响应
*/ */
export function refresh(req: RefreshTokenRequest) { export function refresh(req: RefreshTokenRequest) {
return requestClient.post<LoginResponse>('/account/refresh', req) return requestClient.post<LoginResponse>('/account/refresh', req, {
skipAuth: true,
__skipRefresh: true
})
} }

View File

@@ -0,0 +1,124 @@
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
import type { ApiResponse, InternalRequestConfig } from './types'
/**
* 刷新拦截器配置
*/
interface RefreshInterceptorConfig {
/** axios 实例,用于重试请求 */
client: AxiosInstance
/** 刷新失败后的重新认证(清除状态 + 跳转登录页) */
doReAuthenticate: () => Promise<void>
/** 刷新 token返回新的 Access Token */
doRefreshToken: () => Promise<string>
/** 格式化请求头中的 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<ApiResponse>) {
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 }
}

View File

@@ -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 { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils' import { eventBus, AppEvents } from '@/utils'
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types' import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
import { createRefreshTokenInterceptor } from './refreshInterceptor'
/** /**
* 替换 URL 中的路径参数占位符 * 替换 URL 中的路径参数占位符
@@ -38,6 +39,12 @@ class RequestClient {
/** axios 实例 */ /** axios 实例 */
private instance: AxiosInstance private instance: AxiosInstance
/** 是否正在刷新 token */
isRefreshing = false
/** 刷新期间等待队列401 请求在刷新完成前暂存,刷新后用新 token 重试) */
refreshTokenQueue: Array<(token: string) => void> = []
constructor(config?: CreateAxiosDefaults) { constructor(config?: CreateAxiosDefaults) {
this.instance = axios.create({ this.instance = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -63,7 +70,7 @@ class RequestClient {
config.url = resolvePathParams(config.url, reqConfig.pathParams) config.url = resolvePathParams(config.url, reqConfig.pathParams)
} }
// 跳过鉴权时不注入 saTokenAccess Token // 跳过鉴权时不注入 saToken
if (!reqConfig?.skipAuth) { if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore() const accountStore = useAccountStore()
if (accountStore.accessToken) { if (accountStore.accessToken) {
@@ -78,107 +85,85 @@ class RequestClient {
} }
) )
// === 响应拦截器 === // === 通用响应拦截器 ===
// 拦截器内部将 axiosResponse 解包 ApiResponse // 职责:解包 ApiResponse + 发布非 401 的错误事件。
// 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型 // 401 全部透传,由 refreshInterceptor 统一处理
this.instance.interceptors.response.use( this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse // @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => { (response) => {
const data = response.data as ApiResponse const data = response.data as ApiResponse
// 业务成功
if (data.code === 0) { if (data.code === 0) {
return data return data
} }
// 业务错误事件载荷 // 401 透传给刷新拦截器,此处不处理
const errorEvent: RequestErrorEvent = { if (data.code === 401) {
return response
}
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business', type: 'business',
code: data.code, code: data.code,
message: data.message || '请求失败' message: data.message || '请求失败'
} } satisfies RequestErrorEvent)
// 401 → 额外发布未授权事件,解耦路由
if (data.code === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED)
}
// 发布请求错误事件,由 errorUI 模块订阅展示
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
return Promise.reject(data) return Promise.reject(data)
}, },
(error) => { (error) => {
let errorEvent: RequestErrorEvent // HTTP 401 透传给刷新拦截器
if (error.response?.status === 401) { if (error.response?.status === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED) throw error
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})`
}
} }
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) 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<PostList>({
* method: 'GET',
* url: '/user/:id/posts',
* params: { page: 1 },
* pathParams: { id: 1 }
* })
*
* // 路径参数 + 请求体
* requestClient.request<Result>({
* method: 'POST',
* url: '/user/:id/post',
* data: body,
* pathParams: { id: 1 }
* })
*
* // 自定义请求头 + 跳过鉴权
* requestClient.request<Result>({
* method: 'POST',
* url: '/public/upload',
* data: formData,
* headers: { 'Content-Type': 'multipart/form-data' },
* requestOptions: { skipAuth: true }
* })
* ```
*/ */
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> { request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.request(config) return this.instance.request(config)
} }
/** /** GET 请求 */
* GET 请求
* @param url - 请求地址
* @param params - 查询参数
* @param config - 额外配置pathParams、requestOptions 等)
*/
get<T = unknown>( get<T = unknown>(
url: string, url: string,
params?: Record<string, unknown>, params?: Record<string, unknown>,
@@ -187,12 +172,7 @@ class RequestClient {
return this.instance.get(url, { params, ...config }) return this.instance.get(url, { params, ...config })
} }
/** /** POST 请求 */
* POST 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
post<T = unknown>( post<T = unknown>(
url: string, url: string,
data?: unknown, data?: unknown,
@@ -201,12 +181,7 @@ class RequestClient {
return this.instance.post(url, data, config) return this.instance.post(url, data, config)
} }
/** /** PUT 请求 */
* PUT 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
put<T = unknown>( put<T = unknown>(
url: string, url: string,
data?: unknown, data?: unknown,
@@ -215,21 +190,12 @@ class RequestClient {
return this.instance.put(url, data, config) return this.instance.put(url, data, config)
} }
/** /** DELETE 请求 */
* DELETE 请求
* @param url - 请求地址
* @param config - 额外配置pathParams、requestOptions 等)
*/
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> { delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.delete(url, config) return this.instance.delete(url, config)
} }
/** /** PATCH 请求 */
* PATCH 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
patch<T = unknown>( patch<T = unknown>(
url: string, url: string,
data?: unknown, data?: unknown,

View File

@@ -16,6 +16,10 @@ export interface InternalRequestConfig extends AxiosRequestConfig {
pathParams?: Record<string, string | number> pathParams?: Record<string, string | number>
/** 跳过鉴权注入(不携带 saToken 头) */ /** 跳过鉴权注入(不携带 saToken 头) */
skipAuth?: boolean skipAuth?: boolean
/** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */
__isRetryRequest?: boolean
/** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */
__skipRefresh?: boolean
} }
/** 请求错误事件载荷 */ /** 请求错误事件载荷 */

View File

@@ -23,7 +23,7 @@ function setupCommonGuard(router: Router) {
const accountStore = useAccountStore() const accountStore = useAccountStore()
// 首次导航时验证 token 有效性(检查持久化的 token 是否过期) // 首次导航时验证 token 有效性(检查持久化的 token 是否过期)
await accountStore.initAuth() // await accountStore.initAuth()
// 已登录用户访问公开路由(如登录页),直接跳转首页 // 已登录用户访问公开路由(如登录页),直接跳转首页
if (accountStore.isLoggedIn() && isPublicPath(to.path)) { if (accountStore.isLoggedIn() && isPublicPath(to.path)) {

View File

@@ -53,9 +53,11 @@ export const useAccountStore = defineStore(
* *
* - 无 token → 无操作 * - 无 token → 无操作
* - token 有效 → 拉取用户信息,标记已登录 * - token 有效 → 拉取用户信息,标记已登录
* - token 过期 → 尝试Refresh Token 刷新,刷新失败则清除 * - token 过期 → requestClient 拦截器自动调refreshAccessToken() 刷新后重试
* 若刷新也失败则抛出异常,此处 catch 后清除认证状态
*/ */
const initAuth = async () => { const initAuth = async () => {
console.log('initAuth', isInitialized.value)
// 已验证过,跳过 // 已验证过,跳过
if (isInitialized.value) return if (isInitialized.value) return
isInitialized.value = true isInitialized.value = true
@@ -68,18 +70,8 @@ export const useAccountStore = defineStore(
// 成功获取用户信息token 有效 // 成功获取用户信息token 有效
isTokenValid.value = true isTokenValid.value = true
} catch { } catch {
// Access Token 可能过期,尝试用 Refresh Token 刷新 // 请求失败(包括自动刷新也失败),清除认证状态
if (refreshToken.value) { clearAuth()
try {
await doRefresh()
isTokenValid.value = true
} catch {
// 刷新也失败,清除
clearAuth()
}
} else {
clearAuth()
}
} }
} }
@@ -92,7 +84,7 @@ export const useAccountStore = defineStore(
const res = await loginApi(params) const res = await loginApi(params)
accessToken.value = res.data.accessToken accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken refreshToken.value = res.data.refreshToken
await fetchUserInfo() // await fetchUserInfo()
isTokenValid.value = true isTokenValid.value = true
} }
@@ -105,7 +97,7 @@ export const useAccountStore = defineStore(
const res = await registerApi(params) const res = await registerApi(params)
accessToken.value = res.data.accessToken accessToken.value = res.data.accessToken
refreshToken.value = res.data.refreshToken refreshToken.value = res.data.refreshToken
await fetchUserInfo() // await fetchUserInfo()
isTokenValid.value = true isTokenValid.value = true
} }
@@ -127,17 +119,26 @@ export const useAccountStore = defineStore(
} }
/** /**
* 使用 Refresh Token 刷新 Access Token * 刷新 Access Token
* 成功后更新 accessTokenRefresh Token 不变(由服务端行为决定) *
* 由刷新拦截器的 doRefreshToken 调用。
* 返回新的 Access Token 字符串,方便拦截器直接使用。
*
* @returns 新的 Access Token
*/ */
const doRefresh = async () => { const refreshAccessToken = async (): Promise<string> => {
if (!refreshToken.value) {
throw new Error('无 Refresh Token无法刷新')
}
const res = await refreshApi({ refreshToken: refreshToken.value }) const res = await refreshApi({ refreshToken: refreshToken.value })
accessToken.value = res.data.accessToken accessToken.value = res.data.accessToken
// 如果服务端返回了新的 refreshToken 则更新
if (res.data.refreshToken) { if (res.data.refreshToken) {
refreshToken.value = 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, login,
register, register,
logout, logout,
refreshAccessToken,
fetchUserInfo fetchUserInfo
} }
}, },

View File

@@ -20,7 +20,7 @@
<n-input <n-input
v-model:value="loginModel.password" v-model:value="loginModel.password"
type="password" type="password"
show-password-on="mousedown" show-password-on="click"
placeholder="密码" placeholder="密码"
:maxlength="20" :maxlength="20"
/> />

View File

@@ -21,7 +21,7 @@
<n-input <n-input
v-model:value="registerModel.password" v-model:value="registerModel.password"
type="password" type="password"
show-password-on="mousedown" show-password-on="click"
placeholder="密码" placeholder="密码"
:maxlength="20" :maxlength="20"
/> />
@@ -66,7 +66,7 @@
<n-input <n-input
v-model:value="registerModel.confirmPassword" v-model:value="registerModel.confirmPassword"
type="password" type="password"
show-password-on="mousedown" show-password-on="click"
placeholder="确认密码" placeholder="确认密码"
:maxlength="20" :maxlength="20"
/> />