import axios from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; import { envConfig } from '@/config/env'; import { authToken } from '@/services/authToken'; declare module 'axios' { export interface AxiosRequestConfig { /** 401 刷新重放标记,防止无限重试 */ _retried?: boolean; } } const apiClient: AxiosInstance = axios.create({ baseURL: envConfig.apiBaseUrl, timeout: 30000, // 不设全局 Content-Type:axios 1.x 遇到「默认 JSON 头 + FormData」会把表单转成 JSON 发送, // 导致素材上传等表单接口解析失败。对象请求体 axios 会自动补 JSON 头。 }); // access token 仅在内存中短暂持有(docs/api/conventions.md §1),每次请求动态附加 Bearer 头 apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = authToken.get(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 认证接口自身的失败不进入刷新流程 const AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout']; let refreshTask: Promise | null = null; /** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */ function refreshAccessToken(): Promise { refreshTask ??= apiClient .post('/auth/refresh') .then((res) => { const token = unwrapEnvelope<{ access_token: string }>(res).access_token; authToken.set(token); return token; }) .catch(() => { authToken.clear(); return null; }) .finally(() => { refreshTask = null; }); return refreshTask; } apiClient.interceptors.response.use( (response) => response, async (error) => { if (envConfig.debug) { console.error('请求失败:', error.response?.data || error.message); } const config = error.config as AxiosRequestConfig | undefined; const url = config?.url ?? ''; const isAuthPath = AUTH_PATHS.some((path) => url.includes(path)); // access token 过期时用 refresh cookie 换新 token 并重放一次; // 刷新失败说明会话已失效,整页跳转登录页(后续可换成路由内跳转并保留来源) if (error.response?.status === 401 && config && !config._retried && !isAuthPath) { config._retried = true; const token = await refreshAccessToken(); if (token) { return apiClient.request(config); } window.location.assign('/login'); } return Promise.reject(error); } ); /** * 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体 * (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身; * 列表接口使用 getList 保留分页 meta。 */ function unwrapEnvelope(response: AxiosResponse): T { const body = response.data as { data?: unknown } | undefined; if (body === null || body === undefined) { return undefined as T; } return body.data as T; } /** 列表响应信封为 { data: T[], meta: PaginationMeta }(docs/api/conventions.md §3),meta 需要原样保留 */ function unwrapListEnvelope(response: AxiosResponse): { data: T[]; meta: M } { const body = response.data as { data?: unknown; meta?: unknown } | undefined; return { data: Array.isArray(body?.data) ? (body.data as T[]) : [], meta: (body?.meta ?? {}) as M, }; } export const api = { get: (url: string, config?: AxiosRequestConfig) => apiClient.get(url, config).then((res) => unwrapEnvelope(res)), getList: (url: string, config?: AxiosRequestConfig) => apiClient.get(url, config).then((res) => unwrapListEnvelope(res)), post: (url: string, data?: unknown, config?: AxiosRequestConfig) => apiClient.post(url, data, config).then((res) => unwrapEnvelope(res)), put: (url: string, data?: unknown, config?: AxiosRequestConfig) => apiClient.put(url, data, config).then((res) => unwrapEnvelope(res)), patch: (url: string, data?: unknown, config?: AxiosRequestConfig) => apiClient.patch(url, data, config).then((res) => unwrapEnvelope(res)), delete: (url: string, config?: AxiosRequestConfig) => apiClient.delete(url, config).then((res) => unwrapEnvelope(res)), }; /** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */ export function apiErrorMessage(error: unknown): string { if (axios.isAxiosError(error)) { const data = error.response?.data as { error?: { message?: unknown }; message?: unknown } | undefined; if (data?.error?.message) return String(data.error.message); if (data?.message) return String(data.message); if (error.response) return `请求失败(HTTP ${error.response.status})`; return '网络连接失败,请稍后重试'; } return error instanceof Error ? error.message : String(error); } /** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */ export function apiErrorCode(error: unknown): string | null { if (!axios.isAxiosError(error)) return null; const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error?.code; return typeof code === 'string' ? code : null; } export interface ApiErrorDetail { field: string; code: string; message: string; } /** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */ export function apiErrorDetails(error: unknown): ApiErrorDetail[] { if (!axios.isAxiosError(error)) return []; const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error ?.details; if (!Array.isArray(details)) return []; return details.filter( (item): item is ApiErrorDetail => !!item && typeof item === 'object' && typeof (item as ApiErrorDetail).field === 'string' && typeof (item as ApiErrorDetail).message === 'string' ); } export default apiClient;