Files
admin-web/src/shared/api/http.ts
T
2026-08-20 09:10:36 +08:00

172 lines
5.6 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 interface ApiErrorDetailsOptions {
preserveAuthenticationMessage?: boolean
}
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
}
export async function refreshAuthenticatedSession(): Promise<string> {
if (!authRefreshHandler) throw new Error('认证刷新处理器尚未初始化')
if (!activeRefresh) {
activeRefresh = authRefreshHandler()
.catch(async (refreshError) => {
try {
await authSessionExpiredHandler?.()
} catch {
// 会话已清理,跳转失败不应覆盖原始认证错误。
}
throw refreshError
})
.finally(() => { activeRefresh = null })
}
return activeRefresh
}
http.interceptors.request.use((config) => {
const token = localStorage.getItem('cc-admin-token')
const isAnonymousAuthEndpoint = typeof config.url === 'string'
&& ['/auth/login', '/auth/refresh', '/auth/register'].includes(config.url)
if (token && !isAnonymousAuthEndpoint) {
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 {
const nextToken = await refreshAuthenticatedSession()
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 = '接口请求失败',
options: ApiErrorDetailsOptions = {},
): ApiErrorDetails {
if (error instanceof ApiResponseError) {
const message = error.code === 'A0401' && !options.preserveAuthenticationMessage
? '登录状态已失效,请重新登录'
: 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
}