feat: 双token
This commit is contained in:
@@ -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<LoginResponse>('/account/refresh', req)
|
||||
return requestClient.post<LoginResponse>('/account/refresh', req, {
|
||||
skipAuth: true,
|
||||
__skipRefresh: true
|
||||
})
|
||||
}
|
||||
|
||||
124
src/api/request/refreshInterceptor.ts
Normal file
124
src/api/request/refreshInterceptor.ts
Normal 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 }
|
||||
}
|
||||
@@ -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<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>> {
|
||||
return this.instance.request(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
* @param url - 请求地址
|
||||
* @param params - 查询参数
|
||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
||||
*/
|
||||
/** GET 请求 */
|
||||
get<T = unknown>(
|
||||
url: string,
|
||||
params?: Record<string, unknown>,
|
||||
@@ -187,12 +172,7 @@ class RequestClient {
|
||||
return this.instance.get(url, { params, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求体
|
||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
||||
*/
|
||||
/** POST 请求 */
|
||||
post<T = unknown>(
|
||||
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<T = unknown>(
|
||||
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<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.instance.delete(url, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求体
|
||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
||||
*/
|
||||
/** PATCH 请求 */
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface InternalRequestConfig extends AxiosRequestConfig {
|
||||
pathParams?: Record<string, string | number>
|
||||
/** 跳过鉴权注入(不携带 saToken 头) */
|
||||
skipAuth?: boolean
|
||||
/** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */
|
||||
__isRetryRequest?: boolean
|
||||
/** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */
|
||||
__skipRefresh?: boolean
|
||||
}
|
||||
|
||||
/** 请求错误事件载荷 */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<string> => {
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<n-input
|
||||
v-model:value="loginModel.password"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
show-password-on="click"
|
||||
placeholder="密码"
|
||||
:maxlength="20"
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<n-input
|
||||
v-model:value="registerModel.password"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
show-password-on="click"
|
||||
placeholder="密码"
|
||||
:maxlength="20"
|
||||
/>
|
||||
@@ -66,7 +66,7 @@
|
||||
<n-input
|
||||
v-model:value="registerModel.confirmPassword"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
show-password-on="click"
|
||||
placeholder="确认密码"
|
||||
:maxlength="20"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user