157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
|
import { parseApiJsonResponse } from '@chongchong/shared'
|
|
|
|
export interface ApiEnvelope<T> {
|
|
code: string
|
|
message: string
|
|
data: T
|
|
requestId?: string | null
|
|
traceId?: string | null
|
|
timestamp?: string
|
|
}
|
|
|
|
export class ApiResponseError extends Error {
|
|
readonly code: string
|
|
readonly status?: number
|
|
readonly details?: unknown
|
|
readonly requestId?: string
|
|
readonly traceId?: string
|
|
|
|
constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) {
|
|
super(message)
|
|
this.name = 'ApiResponseError'
|
|
this.code = code
|
|
this.status = status
|
|
this.details = details
|
|
this.requestId = requestId || undefined
|
|
this.traceId = traceId || undefined
|
|
}
|
|
}
|
|
|
|
export interface ApiErrorDetails {
|
|
message: string
|
|
code?: string
|
|
requestId?: string
|
|
traceId?: string
|
|
}
|
|
|
|
export const http = axios.create({
|
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
|
timeout: 15_000,
|
|
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
|
})
|
|
|
|
type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean }
|
|
type AuthRefreshHandler = () => Promise<string>
|
|
type AuthSessionExpiredHandler = () => void | Promise<void>
|
|
|
|
let authRefreshHandler: AuthRefreshHandler | null = null
|
|
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
|
|
let activeRefresh: Promise<string> | null = null
|
|
|
|
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
|
|
authRefreshHandler = handler
|
|
}
|
|
|
|
export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHandler) {
|
|
authSessionExpiredHandler = handler
|
|
}
|
|
|
|
http.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem('cc-admin-token')
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
})
|
|
|
|
http.interceptors.response.use(
|
|
(response) => response.data,
|
|
async (error) => {
|
|
const config = error.config as RetryableRequestConfig | undefined
|
|
const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/')
|
|
if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) {
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
config._authRetry = true
|
|
try {
|
|
if (!activeRefresh) {
|
|
activeRefresh = authRefreshHandler()
|
|
.catch(async (refreshError) => {
|
|
try {
|
|
await authSessionExpiredHandler?.()
|
|
} catch {
|
|
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
|
}
|
|
throw refreshError
|
|
})
|
|
.finally(() => { activeRefresh = null })
|
|
}
|
|
const nextToken = await activeRefresh
|
|
config.headers.Authorization = `Bearer ${nextToken}`
|
|
return http.request(config)
|
|
} catch {
|
|
return Promise.reject(error)
|
|
}
|
|
},
|
|
)
|
|
|
|
export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
|
|
if (response?.code !== '200') {
|
|
throw new ApiResponseError(
|
|
response?.message || '接口请求失败',
|
|
response?.code || 'UNKNOWN',
|
|
undefined,
|
|
response?.data,
|
|
response?.requestId,
|
|
response?.traceId,
|
|
)
|
|
}
|
|
return response.data
|
|
}
|
|
|
|
export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails {
|
|
if (error instanceof ApiResponseError) {
|
|
const message = error.code === 'A0401'
|
|
? '登录状态已失效,请重新登录'
|
|
: error.code === 'A0403'
|
|
? '当前账号无权访问该数据,请联系管理员确认权限'
|
|
: error.code.startsWith('S05')
|
|
? '服务暂时不可用,请稍后重试'
|
|
: error.message
|
|
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
|
}
|
|
if (axios.isAxiosError(error)) {
|
|
const response = error.response?.data as {
|
|
code?: string
|
|
message?: string
|
|
error?: string
|
|
requestId?: string | null
|
|
traceId?: string | null
|
|
} | undefined
|
|
const status = error.response?.status
|
|
const statusMessage = status === 401
|
|
? '登录状态已失效,请重新登录'
|
|
: status === 403
|
|
? '当前账号无权访问该数据,请联系管理员确认权限'
|
|
: status === 413
|
|
? '上传文件的请求体过大,服务器已拒绝;请压缩后重试,如文件未超过 5MB,请联系管理员检查网关上传限制'
|
|
: status && status >= 500
|
|
? '服务暂时不可用,请稍后重试'
|
|
: undefined
|
|
return {
|
|
message: statusMessage || response?.message || response?.error || fallback,
|
|
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
|
requestId: response?.requestId || undefined,
|
|
traceId: response?.traceId || undefined,
|
|
}
|
|
}
|
|
// Local/runtime errors are not user-facing API messages and may contain stack details.
|
|
return { message: fallback }
|
|
}
|
|
|
|
export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string {
|
|
return getApiErrorDetails(error, fallback).message
|
|
}
|