/** * HTTP 请求封装模块 * 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理 * * ## 主要功能 * * - 请求/响应拦截器(自动添加 Token、统一错误处理) * - 401 未授权自动登出(带防抖机制) * - 请求失败自动重试(可配置) * - 统一的成功/错误消息提示 * - 支持 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' /** 请求配置常量 */ 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 /** 扩展 AxiosRequestConfig */ interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { showErrorMessage?: boolean showSuccessMessage?: 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) } ) /** 响应拦截器 */ axiosInstance.interceptors.response.use( async (response: AxiosResponse) => { const resData = await decryptResponseIfNeeded(response.data) response.data = resData as BaseResponse const code = resData?.code const msg = resData?.msg || resData?.message if (code === ApiStatus.success) return response if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg) throw createHttpError(msg || $t('httpMsg.requestFailed'), code) }, (error) => { if (error.response?.status === ApiStatus.unauthorized) handleUnauthorizedError() return Promise.reject(handleError(error)) } ) /** 统一创建HttpError */ 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) if (!isUnauthorizedErrorShown) { isUnauthorizedErrorShown = true logOut() unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME) showError(error, true) throw error } throw error } /** 重置401防抖状态 */ function resetUnauthorizedError() { isUnauthorizedErrorShown = false if (unauthorizedTimer) clearTimeout(unauthorizedTimer) unauthorizedTimer = null } /** 退出登录函数 */ function logOut() { setTimeout(() => { useUserStore().logOut() }, LOGOUT_DELAY) } function resolveUserId(userStore: ReturnType): 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 ): 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 { 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 { 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( config: ExtendedAxiosRequestConfig, retriesLeft?: number ): Promise { const max = retriesLeft ?? getMaxRetriesForConfig(config) try { return await request(config) } catch (error) { if (max > 0 && error instanceof HttpError && shouldRetry(error.code)) { await delay(RETRY_DELAY) return retryRequest(config, max - 1) } throw error } } /** 延迟函数 */ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } /** 请求函数 */ async function request(config: ExtendedAxiosRequestConfig): Promise { // 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>(config) // 显示成功消息 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(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'GET' }) }, post(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'POST' }) }, put(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'PUT' }) }, del(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'DELETE' }) }, delete(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'DELETE' }) }, request(config: ExtendedAxiosRequestConfig) { return retryRequest(config) } } export default api