将 web-admin 从子模块改为仓库内普通目录,便于单仓克隆与维护。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2e9d020d50
commit
c87732fcd5
515 files changed
+74372
-1
No files matched your search
@@ -0,0 +1,97 @@
|
||||
export interface EncEnvelope {
|
||||
enc: 1
|
||||
ts: number
|
||||
nonce: string
|
||||
edata: string
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b)
|
||||
})
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64)
|
||||
const out = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
out[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function fromBase64Url(base64url: string): Uint8Array {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
return base64ToBytes(base64 + padding)
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(text)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
const bytes = new Uint8Array(digest)
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function randomNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(12))
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export async function deriveKey(
|
||||
fixed: string,
|
||||
userId: string,
|
||||
ts: number,
|
||||
nonce: string
|
||||
): Promise<string> {
|
||||
return sha256Hex(`${fixed}:${userId}:${ts}:${nonce}`)
|
||||
}
|
||||
|
||||
export async function encryptJson(payload: Record<string, any>, keyHex: string): Promise<string> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const key = await crypto.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, [
|
||||
'encrypt'
|
||||
])
|
||||
const plain = new TextEncoder().encode(JSON.stringify(payload))
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain)
|
||||
const merged = new Uint8Array(iv.length + encrypted.byteLength)
|
||||
merged.set(iv, 0)
|
||||
merged.set(new Uint8Array(encrypted), iv.length)
|
||||
return toBase64Url(merged)
|
||||
}
|
||||
|
||||
export async function decryptJson<T = any>(edata: string, keyHex: string): Promise<T> {
|
||||
const raw = fromBase64Url(edata)
|
||||
if (raw.length < 13) {
|
||||
throw new Error('响应密文非法')
|
||||
}
|
||||
const iv = raw.slice(0, 12)
|
||||
const cipher = raw.slice(12)
|
||||
const key = await crypto.subtle.importKey('raw', hexToBytes(keyHex), 'AES-GCM', false, [
|
||||
'decrypt'
|
||||
])
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
|
||||
const text = new TextDecoder().decode(new Uint8Array(decrypted))
|
||||
if (!text) {
|
||||
throw new Error('响应解密失败')
|
||||
}
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* HTTP 错误处理模块
|
||||
*
|
||||
* 提供统一的 HTTP 请求错误处理机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
|
||||
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
|
||||
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
|
||||
* - 错误日志记录,便于问题追踪和调试
|
||||
* - 错误和成功消息的统一展示
|
||||
* - 类型守卫函数,用于判断错误类型
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - HTTP 请求拦截器中统一处理错误
|
||||
* - 业务代码中捕获和处理特定错误
|
||||
* - 错误日志收集和上报
|
||||
*
|
||||
* @module utils/http/error
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import { AxiosError } from 'axios'
|
||||
import { ApiStatus } from './status'
|
||||
import { $t } from '@/locales'
|
||||
|
||||
// 错误响应接口
|
||||
export interface ErrorResponse {
|
||||
/** 错误状态码 */
|
||||
code: number
|
||||
/** 错误消息 */
|
||||
msg: string
|
||||
/** 错误附加数据 */
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
// 错误日志数据接口
|
||||
export interface ErrorLogData {
|
||||
/** 错误状态码 */
|
||||
code: number
|
||||
/** 错误消息 */
|
||||
message: string
|
||||
/** 错误附加数据 */
|
||||
data?: unknown
|
||||
/** 错误发生时间戳 */
|
||||
timestamp: string
|
||||
/** 请求 URL */
|
||||
url?: string
|
||||
/** 请求方法 */
|
||||
method?: string
|
||||
/** 错误堆栈信息 */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
// 自定义 HttpError 类
|
||||
export class HttpError extends Error {
|
||||
public readonly code: number
|
||||
public readonly data?: unknown
|
||||
public readonly timestamp: string
|
||||
public readonly url?: string
|
||||
public readonly method?: string
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: number,
|
||||
options?: {
|
||||
data?: unknown
|
||||
url?: string
|
||||
method?: string
|
||||
}
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpError'
|
||||
this.code = code
|
||||
this.data = options?.data
|
||||
this.timestamp = new Date().toISOString()
|
||||
this.url = options?.url
|
||||
this.method = options?.method
|
||||
}
|
||||
|
||||
public toLogData(): ErrorLogData {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
timestamp: this.timestamp,
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
stack: this.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误消息
|
||||
* @param status 错误状态码
|
||||
* @returns 错误消息
|
||||
*/
|
||||
const getErrorMessage = (status: number): string => {
|
||||
const errorMap: Record<number, string> = {
|
||||
[ApiStatus.unauthorized]: 'httpMsg.unauthorized',
|
||||
[ApiStatus.forbidden]: 'httpMsg.forbidden',
|
||||
[ApiStatus.notFound]: 'httpMsg.notFound',
|
||||
[ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed',
|
||||
[ApiStatus.requestTimeout]: 'httpMsg.requestTimeout',
|
||||
[ApiStatus.internalServerError]: 'httpMsg.internalServerError',
|
||||
[ApiStatus.badGateway]: 'httpMsg.badGateway',
|
||||
[ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable',
|
||||
[ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout'
|
||||
}
|
||||
|
||||
return $t(errorMap[status] || 'httpMsg.internalServerError')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param error 错误对象
|
||||
* @returns 错误对象
|
||||
*/
|
||||
export function handleError(error: AxiosError<ErrorResponse>): never {
|
||||
// 处理取消的请求
|
||||
if (error.code === 'ERR_CANCELED') {
|
||||
console.warn('Request cancelled:', error.message)
|
||||
throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error)
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status
|
||||
const errorData = error.response?.data as any
|
||||
const errorMessage = errorData?.msg || errorData?.message || error.message
|
||||
const requestConfig = error.config
|
||||
|
||||
// 无 HTTP 响应:断网、后端未启动、连接被拒绝、超时、浏览器休眠后首包失败等
|
||||
if (!error.response) {
|
||||
const axiosCode = error.code
|
||||
if (axiosCode === 'ECONNABORTED') {
|
||||
throw new HttpError($t('httpMsg.requestTimeout'), ApiStatus.requestTimeout, {
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase()
|
||||
})
|
||||
}
|
||||
throw new HttpError($t('httpMsg.networkError'), ApiStatus.networkError, {
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase()
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 HTTP 状态码错误
|
||||
const message = statusCode
|
||||
? getErrorMessage(statusCode)
|
||||
: errorMessage || $t('httpMsg.requestFailed')
|
||||
throw new HttpError(message, statusCode || ApiStatus.error, {
|
||||
data: error.response.data,
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误消息
|
||||
* @param error 错误对象
|
||||
* @param showMessage 是否显示错误消息
|
||||
*/
|
||||
export function showError(error: HttpError, showMessage: boolean = true): void {
|
||||
if (showMessage) {
|
||||
ElMessage.error(error.message)
|
||||
}
|
||||
// 记录错误日志
|
||||
console.error('[HTTP Error]', error.toLogData())
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示成功消息
|
||||
* @param message 成功消息
|
||||
* @param showMessage 是否显示消息
|
||||
*/
|
||||
export function showSuccess(message: string, showMessage: boolean = true): void {
|
||||
if (showMessage) {
|
||||
ElMessage.success(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HttpError 类型
|
||||
* @param error 错误对象
|
||||
* @returns 是否为 HttpError 类型
|
||||
*/
|
||||
export const isHttpError = (error: unknown): error is HttpError => {
|
||||
return error instanceof HttpError
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 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 = 15000
|
||||
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<BaseResponse>) => {
|
||||
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<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)
|
||||
|
||||
// 显示成功消息
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 接口状态码
|
||||
*/
|
||||
export enum ApiStatus {
|
||||
success = 200, // 成功
|
||||
/** 无 HTTP 响应(后端不可达、断网、连接被拒绝等),勿与 HTTP 400 混淆 */
|
||||
networkError = 0,
|
||||
error = 400, // 错误
|
||||
unauthorized = 401, // 未授权
|
||||
forbidden = 403, // 禁止访问
|
||||
notFound = 404, // 未找到
|
||||
methodNotAllowed = 405, // 方法不允许
|
||||
requestTimeout = 408, // 请求超时
|
||||
internalServerError = 500, // 服务器错误
|
||||
notImplemented = 501, // 未实现
|
||||
badGateway = 502, // 网关错误
|
||||
serviceUnavailable = 503, // 服务不可用
|
||||
gatewayTimeout = 504, // 网关超时
|
||||
httpVersionNotSupported = 505 // HTTP版本不支持
|
||||
}
|
||||
Reference in new issue
Block a user