提交
This commit is contained in:
1 parent
ee73f69759
commit
74f751d0d1
70 files changed
+9340
-6930
No files matched your search
@@ -5,7 +5,7 @@
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 请求/响应拦截器(自动添加 Token、统一错误处理)
|
||||
* - 401 未授权自动登出(带防抖机制)
|
||||
* - 401 未授权自动刷新 Token(并发安全,刷新失败后登出)
|
||||
* - 请求失败自动重试(可配置)
|
||||
* - 统一的成功/错误消息提示
|
||||
* - 支持 GET/POST/PUT/DELETE 等常用方法
|
||||
@@ -21,6 +21,7 @@ import { HttpError, handleError, showError, showSuccess } from './error'
|
||||
import { $t } from '@/locales'
|
||||
import { BaseResponse } from '@/types'
|
||||
import { decryptJson, deriveKey, encryptJson, randomNonce } from './crypto'
|
||||
import { fetchRefreshToken } from '@/api/auth'
|
||||
|
||||
/** 请求配置常量 */
|
||||
const REQUEST_TIMEOUT = 60000
|
||||
@@ -34,10 +35,32 @@ const UNAUTHORIZED_DEBOUNCE_TIME = 3000
|
||||
let isUnauthorizedErrorShown = false
|
||||
let unauthorizedTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** Token 刷新状态(并发安全) */
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
|
||||
function subscribeTokenRefresh(resolve: (token: string) => void, reject: (error: unknown) => void) {
|
||||
pendingRequests.push({ resolve, reject })
|
||||
}
|
||||
|
||||
function onRefreshed(newToken: string) {
|
||||
pendingRequests.forEach(({ resolve }) => resolve(newToken))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
function onRefreshFailed(error: unknown) {
|
||||
pendingRequests.forEach(({ reject }) => reject(error))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
/** 扩展 AxiosRequestConfig */
|
||||
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
|
||||
showErrorMessage?: boolean
|
||||
showSuccessMessage?: boolean
|
||||
_retry?: boolean
|
||||
}
|
||||
|
||||
const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env
|
||||
@@ -148,11 +171,15 @@ axiosInstance.interceptors.response.use(
|
||||
const code = resData?.code
|
||||
const msg = resData?.msg || resData?.message
|
||||
if (code === ApiStatus.success) return response
|
||||
if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg)
|
||||
if (code === ApiStatus.unauthorized) {
|
||||
return handleUnauthorizedResponse(response.config, msg)
|
||||
}
|
||||
throw createHttpError(msg || $t('httpMsg.requestFailed'), code)
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === ApiStatus.unauthorized) handleUnauthorizedError()
|
||||
async (error) => {
|
||||
if (error.response?.status === ApiStatus.unauthorized) {
|
||||
return handleUnauthorizedResponse(error.response.config, undefined)
|
||||
}
|
||||
return Promise.reject(handleError(error))
|
||||
}
|
||||
)
|
||||
@@ -162,21 +189,71 @@ function createHttpError(message: string, code: number) {
|
||||
return new HttpError(message, code)
|
||||
}
|
||||
|
||||
/** 处理401错误(带防抖) */
|
||||
function handleUnauthorizedError(message?: string): never {
|
||||
const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
||||
/**
|
||||
* 处理 401 响应:尝试刷新 token,成功则重试原请求,失败则登出
|
||||
*/
|
||||
async function handleUnauthorizedResponse(
|
||||
config: InternalAxiosRequestConfig,
|
||||
message?: string
|
||||
): Promise<AxiosResponse> {
|
||||
const userStore = useUserStore()
|
||||
const storedRefreshToken = userStore.refreshToken
|
||||
|
||||
if (!isUnauthorizedErrorShown) {
|
||||
isUnauthorizedErrorShown = true
|
||||
logOut()
|
||||
|
||||
unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME)
|
||||
|
||||
showError(error, true)
|
||||
throw error
|
||||
if (!storedRefreshToken) {
|
||||
triggerLogoutWithDebounce(message)
|
||||
throw createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
||||
}
|
||||
|
||||
throw error
|
||||
if ((config as ExtendedAxiosRequestConfig)._retry) {
|
||||
triggerLogoutWithDebounce(message)
|
||||
throw createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise<AxiosResponse>((resolve, reject) => {
|
||||
subscribeTokenRefresh(
|
||||
(newToken: string) => {
|
||||
config.headers.set('Authorization', `Bearer ${newToken}`)
|
||||
;(config as ExtendedAxiosRequestConfig)._retry = true
|
||||
resolve(axiosInstance.request(config))
|
||||
},
|
||||
(error: unknown) => {
|
||||
reject(createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
;(config as ExtendedAxiosRequestConfig)._retry = true
|
||||
|
||||
try {
|
||||
const result = await fetchRefreshToken(storedRefreshToken)
|
||||
const newAccessToken = result.token
|
||||
const newRefreshToken = result.refreshToken
|
||||
userStore.setToken(newAccessToken, newRefreshToken)
|
||||
|
||||
onRefreshed(newAccessToken)
|
||||
|
||||
config.headers.set('Authorization', `Bearer ${newAccessToken}`)
|
||||
return await axiosInstance.request(config)
|
||||
} catch (refreshError) {
|
||||
onRefreshFailed(refreshError)
|
||||
triggerLogoutWithDebounce(message)
|
||||
throw createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 带 3 秒防抖的登出提示 */
|
||||
function triggerLogoutWithDebounce(message?: string) {
|
||||
if (isUnauthorizedErrorShown) return
|
||||
isUnauthorizedErrorShown = true
|
||||
const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
||||
logOut()
|
||||
unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME)
|
||||
showError(error, true)
|
||||
}
|
||||
|
||||
/** 重置401防抖状态 */
|
||||
|
||||
Reference in new issue
Block a user