108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|||
|
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||
|
|
import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||
|
|
|
||
|
|
const transport = axios.create({
|
||
|
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||
|
|
timeout: 15_000,
|
||
|
|
})
|
||
|
|
|
||
|
|
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||
|
|
_retriedAfterRefresh?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||
|
|
if (envelope.code !== '0') {
|
||
|
|
throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId)
|
||
|
|
}
|
||
|
|
return envelope.data
|
||
|
|
}
|
||
|
|
|
||
|
|
function mapError(error: unknown) {
|
||
|
|
if (error instanceof ApiError) return error
|
||
|
|
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
||
|
|
|
||
|
|
const body = error.response?.data
|
||
|
|
if (isApiEnvelope(body)) {
|
||
|
|
return new ApiError(
|
||
|
|
body.message || '请求失败',
|
||
|
|
body.code,
|
||
|
|
error.response?.status,
|
||
|
|
asFieldErrors(body.data),
|
||
|
|
typeof body.traceId === 'string' ? body.traceId : null,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
|
||
|
|
if (error.code === AxiosError.ECONNABORTED) return new ApiError('请求超时,请稍后重试', 'TIMEOUT')
|
||
|
|
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
|
||
|
|
}
|
||
|
|
|
||
|
|
let refreshPromise: Promise<TokenPair> | null = null
|
||
|
|
|
||
|
|
async function refreshSession() {
|
||
|
|
const refreshToken = getRefreshToken()
|
||
|
|
if (!refreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
||
|
|
if (!refreshPromise) {
|
||
|
|
refreshPromise = transport
|
||
|
|
.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken })
|
||
|
|
.then((response) => unwrap(response.data))
|
||
|
|
.then((tokens) => {
|
||
|
|
persistTokenPair(tokens)
|
||
|
|
return tokens
|
||
|
|
})
|
||
|
|
.finally(() => {
|
||
|
|
refreshPromise = null
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return refreshPromise
|
||
|
|
}
|
||
|
|
|
||
|
|
transport.interceptors.request.use((config) => {
|
||
|
|
const token = getAccessToken()
|
||
|
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||
|
|
return config
|
||
|
|
})
|
||
|
|
|
||
|
|
transport.interceptors.response.use(
|
||
|
|
(response) => response,
|
||
|
|
async (error: AxiosError) => {
|
||
|
|
const request = error.config as RetryableRequest | undefined
|
||
|
|
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh']
|
||
|
|
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
|
||
|
|
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
|
||
|
|
throw mapError(error)
|
||
|
|
}
|
||
|
|
|
||
|
|
request._retriedAfterRefresh = true
|
||
|
|
try {
|
||
|
|
const tokens = await refreshSession()
|
||
|
|
request.headers.Authorization = `Bearer ${tokens.accessToken}`
|
||
|
|
return await transport.request(request)
|
||
|
|
} catch (refreshError) {
|
||
|
|
clearSessionTokens()
|
||
|
|
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
||
|
|
throw mapError(refreshError)
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
export async function apiRequest<T>(config: AxiosRequestConfig) {
|
||
|
|
try {
|
||
|
|
const response = await transport.request<ApiEnvelope<T>>(config)
|
||
|
|
return unwrap(response.data)
|
||
|
|
} catch (error) {
|
||
|
|
throw mapError(error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function apiBlobRequest(config: AxiosRequestConfig) {
|
||
|
|
try {
|
||
|
|
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||
|
|
return response.data
|
||
|
|
} catch (error) {
|
||
|
|
throw mapError(error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export { ApiError }
|