Files
client-web/src/api/http.ts
T

189 lines
7.0 KiB
TypeScript
Raw Normal View History

2026-07-30 17:59:42 +08:00
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
2026-07-30 21:23:06 +08:00
import { parseApiJsonResponse } from '@chongchong/shared'
2026-07-30 17:59:42 +08:00
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
2026-08-06 18:02:02 +08:00
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
2026-08-27 10:44:14 +08:00
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
2026-07-30 17:59:42 +08:00
const transport = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15_000,
2026-07-30 21:23:06 +08:00
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
2026-07-30 17:59:42 +08:00
})
interface RetryableRequest extends InternalAxiosRequestConfig {
_retriedAfterRefresh?: boolean
}
function unwrap<T>(envelope: ApiEnvelope<T>) {
if (envelope.code !== '200') {
2026-08-06 18:02:02 +08:00
const fieldErrors = asFieldErrors(envelope.data)
2026-08-28 16:54:16 +08:00
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
2026-07-30 17:59:42 +08:00
}
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)) {
2026-08-06 18:02:02 +08:00
const fieldErrors = asFieldErrors(body.data)
2026-07-30 17:59:42 +08:00
return new ApiError(
2026-08-06 18:02:02 +08:00
apiErrorMessage(body.message, fieldErrors),
2026-07-30 17:59:42 +08:00
body.code,
error.response?.status,
2026-08-06 18:02:02 +08:00
fieldErrors,
2026-07-30 17:59:42 +08:00
typeof body.traceId === 'string' ? body.traceId : null,
2026-08-28 16:54:16 +08:00
typeof body.requestId === 'string' ? body.requestId : null,
2026-07-30 17:59:42 +08:00
)
}
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)
}
2026-08-07 14:47:00 +08:00
let refreshPromise: Promise<string> | null = null
let redirectingToLogin = false
2026-09-03 18:11:07 +08:00
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
function isPublicAuthRequest(url?: string) {
return publicAuthPaths.some((path) => url?.endsWith(path))
}
function redirectToLoginAfterSessionExpiry() {
if (redirectingToLogin || typeof window === 'undefined') return
const { pathname, search, hash } = window.location
if (pathname === '/login' || pathname === '/register') return
redirectingToLogin = true
const redirect = `${pathname}${search}${hash}`
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
}
2026-07-30 17:59:42 +08:00
2026-08-21 17:20:51 +08:00
export async function refreshAuthenticatedSession() {
2026-08-07 14:47:00 +08:00
const observedRefreshToken = getRefreshToken()
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
2026-07-30 17:59:42 +08:00
if (!refreshPromise) {
2026-08-07 14:47:00 +08:00
refreshPromise = coordinateCrossTabRefresh({
lockName: 'cc-client-auth-refresh',
observedRefreshToken,
currentRefreshToken: getRefreshToken,
reuseCurrentSession: async () => {
const currentAccessToken = getAccessToken()
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
return currentAccessToken
},
refresh: async (currentRefreshToken) => {
const response = await transport.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken: currentRefreshToken })
const tokens = unwrap(response.data)
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
2026-07-30 17:59:42 +08:00
persistTokenPair(tokens)
2026-08-07 14:47:00 +08:00
return tokens.accessToken
},
})
2026-08-27 10:44:14 +08:00
.catch((error) => {
if (error instanceof CrossTabRefreshTimeoutError) {
2026-08-27 10:44:59 +08:00
const expiredError = new ApiError(error.message, 'A0401', 401)
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
throw expiredError
2026-08-27 10:44:14 +08:00
}
throw error
})
2026-07-30 17:59:42 +08:00
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
transport.interceptors.request.use((config) => {
const token = getAccessToken()
2026-09-03 18:11:07 +08:00
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
2026-07-30 17:59:42 +08:00
return config
})
transport.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const request = error.config as RetryableRequest | undefined
2026-09-03 18:11:07 +08:00
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
2026-07-30 17:59:42 +08:00
throw mapError(error)
}
request._retriedAfterRefresh = true
try {
2026-08-21 17:20:51 +08:00
const accessToken = await refreshAuthenticatedSession()
2026-08-07 14:47:00 +08:00
request.headers.Authorization = `Bearer ${accessToken}`
2026-07-30 17:59:42 +08:00
return await transport.request(request)
} catch (refreshError) {
2026-07-30 21:23:06 +08:00
const mappedError = mapError(refreshError)
if (mappedError.status === 401 || mappedError.code === 'A0401') {
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
2026-07-30 21:23:06 +08:00
}
throw mappedError
2026-07-30 17:59:42 +08:00
}
},
)
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' })
2026-09-18 11:40:41 +08:00
await throwBlobApiError(response.data, response.status)
2026-07-30 17:59:42 +08:00
return response.data
} catch (error) {
2026-08-28 16:54:16 +08:00
await throwBlobApiError(error)
2026-07-30 17:59:42 +08:00
throw mapError(error)
}
}
2026-08-13 17:12:05 +08:00
export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
2026-09-18 11:40:41 +08:00
await throwBlobApiError(response.data, response.status)
2026-08-13 17:12:05 +08:00
return { blob: response.data, headers: response.headers }
} catch (error) {
2026-08-28 16:54:16 +08:00
await throwBlobApiError(error)
2026-08-13 17:12:05 +08:00
throw mapError(error)
}
}
2026-09-18 11:40:41 +08:00
async function throwBlobApiError(error: unknown, responseStatus?: number) {
const blob = error instanceof Blob
? error
: axios.isAxiosError(error) && error.response?.data instanceof Blob ? error.response.data : null
if (!blob || !blob.type.toLowerCase().includes('json')) return
2026-08-28 16:54:16 +08:00
try {
2026-09-18 11:40:41 +08:00
const body = JSON.parse(await blob.text()) as unknown
2026-08-28 16:54:16 +08:00
if (!isApiEnvelope(body)) return
const fieldErrors = asFieldErrors(body.data)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
2026-09-18 11:40:41 +08:00
responseStatus ?? (axios.isAxiosError(error) ? error.response?.status : undefined),
2026-08-28 16:54:16 +08:00
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
} catch (parsedError) {
if (parsedError instanceof ApiError) throw parsedError
}
}
2026-07-30 17:59:42 +08:00
export { ApiError }