514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
/**
|
|
* HTTP 请求封装模块
|
|
* 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理
|
|
*
|
|
* ## 主要功能
|
|
*
|
|
* - 请求/响应拦截器(自动添加 Token、统一错误处理)
|
|
* - 401 未授权自动刷新 Token(并发安全,刷新失败后登出)
|
|
* - 请求失败自动重试(可配置)
|
|
* - 统一的成功/错误消息提示
|
|
* - 支持 GET/POST/PUT/DELETE 等常用方法
|
|
*
|
|
* @module utils/http
|
|
* @author Art Design Pro Team
|
|
*/
|
|
|
|
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
|
import { useUserStore } from '@/store/modules/user'
|
|
import { ApiStatus } from './status'
|
|
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
|
|
const LOGOUT_DELAY = 500
|
|
const RETRY_DELAY = 1000
|
|
/** 幂等请求在网络抖动、唤醒后首包失败时自动重试次数 */
|
|
const SAFE_METHOD_MAX_RETRIES = 2
|
|
const UNAUTHORIZED_DEBOUNCE_TIME = 3000
|
|
|
|
/** 401防抖状态 */
|
|
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
|
|
const API_ENCRYPT_ENABLED = String(import.meta.env.VITE_API_ENCRYPT_ENABLED || 'false') === 'true'
|
|
const API_ENCRYPT_FAIL_OPEN =
|
|
String(import.meta.env.VITE_API_ENCRYPT_FAIL_OPEN || 'true') === 'true'
|
|
const API_ENCRYPT_FIXED_SECRET = String(import.meta.env.VITE_API_ENCRYPT_FIXED_SECRET || '')
|
|
const API_ENCRYPT_EXCLUDE_PATHS = String(import.meta.env.VITE_API_ENCRYPT_EXCLUDE_PATHS || '')
|
|
.split(',')
|
|
.map((item: string) => item.trim())
|
|
.filter(Boolean)
|
|
const IS_DEV = import.meta.env.DEV
|
|
|
|
/** Axios实例 */
|
|
const axiosInstance = axios.create({
|
|
timeout: REQUEST_TIMEOUT,
|
|
baseURL: VITE_API_URL,
|
|
withCredentials: VITE_WITH_CREDENTIALS === 'true',
|
|
validateStatus: (status) => status >= 200 && status < 300,
|
|
transformResponse: [
|
|
(data, headers) => {
|
|
const contentType = headers['content-type']
|
|
if (contentType?.includes('application/json')) {
|
|
try {
|
|
return JSON.parse(data)
|
|
} catch {
|
|
return data
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
]
|
|
})
|
|
|
|
/** 请求拦截器 */
|
|
axiosInstance.interceptors.request.use(
|
|
async (request: InternalAxiosRequestConfig) => {
|
|
const userStore = useUserStore()
|
|
const { accessToken } = userStore
|
|
if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`)
|
|
|
|
const skipReason = getEncryptSkipReason(request, userStore)
|
|
const shouldEncrypt = !skipReason
|
|
if (IS_DEV) {
|
|
request.headers.set('X-Api-Crypto', shouldEncrypt ? 'on' : `off:${skipReason}`)
|
|
}
|
|
|
|
if (shouldEncrypt) {
|
|
const originalMethod = (request.method || 'GET').toUpperCase()
|
|
const originalData = request.data
|
|
const originalParams = request.params
|
|
try {
|
|
// Web 端统一“GET 走 POST 传输”,后端通过 method override 还原语义。
|
|
if (originalMethod === 'GET') {
|
|
request.method = 'POST'
|
|
request.headers.set('X-HTTP-Method-Override', 'GET')
|
|
request.data = request.params || {}
|
|
request.params = undefined
|
|
}
|
|
const ts = Date.now()
|
|
const nonce = randomNonce()
|
|
const userId = resolveUserId(userStore)
|
|
const keyHex = await deriveKey(API_ENCRYPT_FIXED_SECRET, userId, ts, nonce)
|
|
const plainData = normalizeRequestData(request.data)
|
|
const edata = await encryptJson({ data: plainData }, keyHex)
|
|
request.headers.set('Content-Type', 'application/json')
|
|
request.data = JSON.stringify({
|
|
enc: 1,
|
|
ts,
|
|
nonce,
|
|
edata
|
|
})
|
|
request.params = undefined
|
|
return request
|
|
} catch (error) {
|
|
if (!API_ENCRYPT_FAIL_OPEN) {
|
|
return Promise.reject(error)
|
|
}
|
|
console.warn('[api-crypto] request encrypt failed, fallback plaintext', {
|
|
url: request.url,
|
|
method: originalMethod,
|
|
error
|
|
})
|
|
request.method = originalMethod
|
|
request.data = originalData
|
|
request.params = originalParams
|
|
}
|
|
}
|
|
|
|
if (request.data && !(request.data instanceof FormData) && !request.headers['Content-Type']) {
|
|
request.headers.set('Content-Type', 'application/json')
|
|
request.data = JSON.stringify(request.data)
|
|
}
|
|
|
|
return request
|
|
},
|
|
(error) => {
|
|
showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error))
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
/** 登录/公钥/登出等认证接口:业务 401 不得走 refresh/再登出(主动退出时尤其勿弹「未授权」) */
|
|
function isPublicAuthApiUrl(url?: string): boolean {
|
|
if (!url) return false
|
|
return /\/web\/auth\/(login|login-rsa-public-key|logout)(?:\?|$)/.test(url)
|
|
}
|
|
|
|
function isBlobResponseType(config?: AxiosRequestConfig): boolean {
|
|
return config?.responseType === 'blob'
|
|
}
|
|
|
|
function isJsonLikeBlob(blob: Blob, contentType: string): boolean {
|
|
const ct = contentType.toLowerCase()
|
|
if (ct.includes('application/json')) return true
|
|
if (blob.type && blob.type.toLowerCase().includes('json')) return true
|
|
return false
|
|
}
|
|
|
|
/** blob 下载:二进制直出;若后端仍返回 JSON 错误体则解析后走统一 code 校验 */
|
|
async function normalizeResponsePayload(
|
|
response: AxiosResponse
|
|
): Promise<{ data: unknown; isRawBlob: boolean }> {
|
|
if (!isBlobResponseType(response.config)) {
|
|
return { data: await decryptResponseIfNeeded(response.data), isRawBlob: false }
|
|
}
|
|
|
|
const payload = response.data
|
|
if (!(payload instanceof Blob)) {
|
|
return { data: await decryptResponseIfNeeded(payload), isRawBlob: false }
|
|
}
|
|
|
|
const contentType = String(response.headers['content-type'] || payload.type || '')
|
|
if (!isJsonLikeBlob(payload, contentType)) {
|
|
return { data: payload, isRawBlob: true }
|
|
}
|
|
|
|
const text = await payload.text()
|
|
const trimmed = text.trim()
|
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed)
|
|
return { data: await decryptResponseIfNeeded(parsed), isRawBlob: false }
|
|
} catch {
|
|
// 非 JSON,按二进制文件处理
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: new Blob([text], { type: payload.type || contentType || 'application/octet-stream' }),
|
|
isRawBlob: true
|
|
}
|
|
}
|
|
|
|
/** 响应拦截器 */
|
|
axiosInstance.interceptors.response.use(
|
|
async (response: AxiosResponse<BaseResponse>) => {
|
|
const { data: resData, isRawBlob } = await normalizeResponsePayload(response)
|
|
if (isRawBlob) {
|
|
response.data = resData as BaseResponse
|
|
return response
|
|
}
|
|
|
|
response.data = resData as BaseResponse
|
|
const code = (resData as BaseResponse)?.code
|
|
const msg = (resData as BaseResponse)?.msg || (resData as BaseResponse)?.message
|
|
if (code === ApiStatus.success) return response
|
|
if (code === ApiStatus.unauthorized && !isPublicAuthApiUrl(response.config?.url)) {
|
|
return handleUnauthorizedResponse(response.config, msg)
|
|
}
|
|
throw createHttpError(msg || $t('httpMsg.requestFailed'), code)
|
|
},
|
|
async (error) => {
|
|
if (error.response?.status === ApiStatus.unauthorized) {
|
|
return handleUnauthorizedResponse(error.response.config, undefined)
|
|
}
|
|
return Promise.reject(handleError(error))
|
|
}
|
|
)
|
|
|
|
/** 统一创建HttpError */
|
|
function createHttpError(message: string, code: number) {
|
|
return new HttpError(message, code)
|
|
}
|
|
|
|
/**
|
|
* 处理 401 响应:尝试刷新 token,成功则重试原请求,失败则登出
|
|
*/
|
|
async function handleUnauthorizedResponse(
|
|
config: InternalAxiosRequestConfig,
|
|
message?: string
|
|
): Promise<AxiosResponse> {
|
|
const userStore = useUserStore()
|
|
const storedRefreshToken = userStore.refreshToken
|
|
|
|
if (!storedRefreshToken) {
|
|
triggerLogoutWithDebounce(message)
|
|
throw createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
|
}
|
|
|
|
// refresh-token 接口本身返回 401,说明 refreshToken 已失效,直接登出,避免死锁
|
|
if (config.url && /\/auth\/refresh-token/.test(config.url)) {
|
|
triggerLogoutWithDebounce(message)
|
|
throw createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
|
|
}
|
|
|
|
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防抖状态 */
|
|
function resetUnauthorizedError() {
|
|
isUnauthorizedErrorShown = false
|
|
if (unauthorizedTimer) clearTimeout(unauthorizedTimer)
|
|
unauthorizedTimer = null
|
|
}
|
|
|
|
/** 退出登录函数 */
|
|
function logOut() {
|
|
setTimeout(() => {
|
|
useUserStore().logOut()
|
|
}, LOGOUT_DELAY)
|
|
}
|
|
|
|
function resolveUserId(userStore: ReturnType<typeof useUserStore>): string {
|
|
const info = userStore.info || ({} as any)
|
|
const fromInfo = String(info.id || info.userId || '')
|
|
if (fromInfo) return fromInfo
|
|
return parseUserIdFromToken(userStore.accessToken)
|
|
}
|
|
|
|
function parseUserIdFromToken(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 ''
|
|
}
|
|
}
|
|
|
|
function isPathExcluded(url?: string): boolean {
|
|
if (!url) return false
|
|
return API_ENCRYPT_EXCLUDE_PATHS.some((prefix) => url.startsWith(prefix))
|
|
}
|
|
|
|
function getEncryptSkipReason(
|
|
request: InternalAxiosRequestConfig,
|
|
userStore: ReturnType<typeof useUserStore>
|
|
): string {
|
|
const headerOverride =
|
|
(request.headers as any)?.['X-Api-Crypto'] ?? (request.headers as any)?.['x-api-crypto']
|
|
if (typeof headerOverride === 'string') {
|
|
// per-request override: off => 强制不加密;on => 强制加密
|
|
if (headerOverride === 'on') return ''
|
|
if (headerOverride.startsWith('off')) return headerOverride
|
|
}
|
|
if (!API_ENCRYPT_ENABLED || !API_ENCRYPT_FIXED_SECRET) return 'encrypt-disabled-or-no-secret'
|
|
if (request.data instanceof FormData) return 'form-data'
|
|
if (isPathExcluded(request.url)) return 'exclude-path'
|
|
if (!(request.url || '').startsWith('/web/')) return 'non-web-path'
|
|
if (!resolveUserId(userStore)) return 'missing-user-id'
|
|
return ''
|
|
}
|
|
|
|
function normalizeRequestData(data: any): Record<string, any> {
|
|
if (!data) return {}
|
|
if (typeof data === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(data)
|
|
return typeof parsed === 'object' && parsed ? parsed : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
return typeof data === 'object' ? data : {}
|
|
}
|
|
|
|
async function decryptResponseIfNeeded(data: any): Promise<any> {
|
|
if (!data || !data.encrypted || !data.edata) return data
|
|
if (!API_ENCRYPT_FIXED_SECRET) return data
|
|
const userStore = useUserStore()
|
|
const userId = resolveUserId(userStore)
|
|
if (!userId) return data
|
|
try {
|
|
const keyHex = await deriveKey(
|
|
API_ENCRYPT_FIXED_SECRET,
|
|
userId,
|
|
Number(data.ts),
|
|
String(data.nonce)
|
|
)
|
|
return decryptJson(data.edata, keyHex)
|
|
} catch (error) {
|
|
if (!API_ENCRYPT_FAIL_OPEN) {
|
|
throw error
|
|
}
|
|
console.warn('[api-crypto] response decrypt failed, fallback raw envelope', error)
|
|
return data
|
|
}
|
|
}
|
|
|
|
/** 是否需要重试 */
|
|
function shouldRetry(statusCode: number) {
|
|
return [
|
|
ApiStatus.networkError,
|
|
ApiStatus.requestTimeout,
|
|
ApiStatus.internalServerError,
|
|
ApiStatus.badGateway,
|
|
ApiStatus.serviceUnavailable,
|
|
ApiStatus.gatewayTimeout
|
|
].includes(statusCode)
|
|
}
|
|
|
|
function getMaxRetriesForConfig(config: ExtendedAxiosRequestConfig): number {
|
|
const method = (config.method || 'GET').toUpperCase()
|
|
return ['GET', 'HEAD'].includes(method) ? SAFE_METHOD_MAX_RETRIES : 0
|
|
}
|
|
|
|
/** 请求重试逻辑 */
|
|
async function retryRequest<T>(
|
|
config: ExtendedAxiosRequestConfig,
|
|
retriesLeft?: number
|
|
): Promise<T> {
|
|
const max = retriesLeft ?? getMaxRetriesForConfig(config)
|
|
try {
|
|
return await request<T>(config)
|
|
} catch (error) {
|
|
if (max > 0 && error instanceof HttpError && shouldRetry(error.code)) {
|
|
await delay(RETRY_DELAY)
|
|
return retryRequest<T>(config, max - 1)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** 延迟函数 */
|
|
function delay(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
/** 请求函数 */
|
|
async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T> {
|
|
// POST | PUT 参数自动填充
|
|
if (
|
|
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
|
|
config.params &&
|
|
!config.data
|
|
) {
|
|
config.data = config.params
|
|
config.params = undefined
|
|
}
|
|
|
|
try {
|
|
const res = await axiosInstance.request<BaseResponse<T>>(config)
|
|
|
|
if (config.responseType === 'blob') {
|
|
return res.data as T
|
|
}
|
|
|
|
// 显示成功消息
|
|
const successMsg = (res.data as any)?.msg || (res.data as any)?.message
|
|
if (config.showSuccessMessage && successMsg) {
|
|
showSuccess(successMsg)
|
|
}
|
|
|
|
return res.data.data as T
|
|
} catch (error) {
|
|
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
|
|
const showMsg = config.showErrorMessage !== false
|
|
showError(error, showMsg)
|
|
}
|
|
return Promise.reject(error)
|
|
}
|
|
}
|
|
|
|
/** API方法集合 */
|
|
const api = {
|
|
get<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>({ ...config, method: 'GET' })
|
|
},
|
|
post<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>({ ...config, method: 'POST' })
|
|
},
|
|
put<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>({ ...config, method: 'PUT' })
|
|
},
|
|
del<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>({ ...config, method: 'DELETE' })
|
|
},
|
|
delete<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>({ ...config, method: 'DELETE' })
|
|
},
|
|
request<T = any>(config: ExtendedAxiosRequestConfig) {
|
|
return retryRequest<T>(config)
|
|
}
|
|
}
|
|
|
|
export default api
|