import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, transformResponse: [(data: unknown) => parseApiJsonResponse(data)], }) interface RetryableRequest extends InternalAxiosRequestConfig { _retriedAfterRefresh?: boolean _sessionIdentity?: string | null _logoutGeneration?: number } function unwrap(envelope: ApiEnvelope) { if (!isApiEnvelope(envelope)) { const invalid: unknown = envelope if (isApiFailure(invalid)) { throw new ApiError(invalid.message, invalid.code, undefined, undefined, invalid.traceId, invalid.requestId) } throw new ApiError('接口响应格式错误', 'INVALID_RESPONSE') } if (envelope.code !== '200') { const fieldErrors = asFieldErrors(envelope.data) throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) } 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) || isApiFailure(body)) { const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined) return new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, error.response?.status, fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, typeof body.requestId === 'string' ? body.requestId : 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 refreshExchange: { identity: string; refreshToken: string; promise: Promise } | null = null let logoutGeneration = 0 let refreshPromise: Promise | null = null let refreshIdentity: string | null = null let redirectingToLogin = false const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/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)}`) } function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) { if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') } export async function refreshAuthenticatedSession() { const observedIdentity = accessTokenIdentity(getAccessToken()) requireCurrentIdentity(observedIdentity) const observedRefreshToken = getRefreshToken() if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) if (!refreshPromise) { refreshIdentity = observedIdentity refreshPromise = coordinateCrossTabRefresh({ lockName: 'cc-client-auth-refresh', observedRefreshToken, currentRefreshToken: getRefreshToken, reuseCurrentSession: async () => { requireCurrentIdentity(observedIdentity) const currentAccessToken = getAccessToken() if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) return currentAccessToken }, refresh: async (currentRefreshToken) => { requireCurrentIdentity(observedIdentity) const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) } refreshExchange = exchange let tokens: TokenPair try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null } requireCurrentIdentity(observedIdentity) requireCurrentIdentity(observedIdentity, tokens.accessToken) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED') persistTokenPair(tokens) if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated')) return tokens.accessToken }, }) .catch((error) => { requireCurrentIdentity(observedIdentity) if (error instanceof CrossTabRefreshTimeoutError) { if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED') const expiredError = new ApiError(error.message, 'A0401', 401) clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) redirectToLoginAfterSessionExpiry() throw expiredError } throw error }) .finally(() => { refreshPromise = null refreshIdentity = null }) } if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') const refreshed = await refreshPromise requireCurrentIdentity(observedIdentity) return refreshed } transport.interceptors.request.use((config) => { const token = getAccessToken() const request = config as RetryableRequest if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token) if (token && !isPublicAuthRequest(config.url)) { if (!request._retriedAfterRefresh) { request._sessionIdentity = accessTokenIdentity(token) request._logoutGeneration = logoutGeneration } config.headers.Authorization = `Bearer ${token}` } return config }) transport.interceptors.response.use( (response) => { const request = response.config as RetryableRequest if (request._sessionIdentity) { if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) } return response }, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request._retriedAfterRefresh = true const refreshTokenBeforeRetry = getRefreshToken() try { const accessToken = await refreshAuthenticatedSession() if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) const mappedError = mapError(refreshError) if (mappedError.status === 401 || mappedError.code === 'A0401') { if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError clearSessionTokens() window.dispatchEvent(new CustomEvent('cc-client-session-expired')) redirectToLoginAfterSessionExpiry() } throw mappedError } }, ) // Captured revocation never persists credentials or restores local login state. export async function revokeCapturedSession(accessToken: string, refreshToken: string) { logoutGeneration++ const identity = accessTokenIdentity(accessToken) if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED') const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null const revoke = (pair: Pick) => apiRequest({ method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken }, headers: { Authorization: `Bearer ${pair.accessToken}` }, }) const validate = (pair: TokenPair) => { if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED') return pair } // An already sent refresh can rotate the pair. Consume its actual result // without issuing a competing refresh or restoring it to local storage. if (pending) { await revoke(validate(await pending)) return } try { await revoke({ accessToken, refreshToken }) } catch (error) { if (!(error instanceof ApiError) || error.status !== 401) throw error const pair = await apiRequest({ method: 'POST', url: '/auth/refresh', data: { refreshToken } }) await revoke(validate(pair)) } } export async function apiRequest(config: AxiosRequestConfig) { try { const response = await transport.request>(config) return unwrap(response.data) } catch (error) { throw mapError(error) } } export async function apiBlobRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) await throwBlobApiError(response.data, response.status) return response.data } catch (error) { await throwBlobApiError(error) throw mapError(error) } } export async function apiBlobResponseRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) await throwBlobApiError(response.data, response.status) return { blob: response.data, headers: response.headers } } catch (error) { await throwBlobApiError(error) throw mapError(error) } } 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 try { const body = JSON.parse(await blob.text()) as unknown if (!isApiEnvelope(body) && !isApiFailure(body)) return const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined) throw new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, responseStatus ?? (axios.isAxiosError(error) ? error.response?.status : undefined), fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, typeof body.requestId === 'string' ? body.requestId : null, ) } catch (parsedError) { if (parsedError instanceof ApiError) throw parsedError } } export { ApiError }