import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' import type { BizError } from '@/apis/types' import axios from 'axios' import { getToken } from '@/utils/auth' import { message } from '@/utils/naive' /** * 后端响应契约(《03-接口设计》§1.3):`{ code, msg, data }`。 * * 这一层做三件事,做完之后业务代码里几乎不再出现 `code`: * 1. 成功(`code === 0`)时**直接 resolve `data`**(`await` 拿到的就是数据); * 2. 失败时**弹一次提示**并 reject 一个带 `bizCode` 的错误(想按码分支也能拿); * 3. 带上 `Authorization: Bearer` 与平台代管头 `X-Tenant-Id`。 * * ⚠️ 401/403/429 **不走业务码**:后端对它们用真实 HTTP 状态码 —— * 所以下面的错误分支才是它们的归属地。别在业务码里找 40301。 */ /** 未登录 / token 失效(唯一需要在业务码里特殊处理的一个) */ const CODE_UNAUTHORIZED = 40101 const STATUS_TEXT: Record = { 400: '请求参数有误(400)', 401: '登录已失效,请重新登录', 403: '无权限访问(403)', 404: '接口不存在(404)', 408: '请求超时(408)', 429: '操作过于频繁,请稍后再试', 500: '服务器错误(500)', 502: '网关错误(502)', 503: '服务不可用(503)', 504: '网关超时(504)' } const http: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_PREFIX, timeout: 60 * 1000 }) /** * 平台运营的「当前学校」——请求头 `X-Tenant-Id`。 * * 放在模块级变量而不是从 store 读:store 引 http 会成环。由 store 在变更时调 setter * 推过来,方向永远是 store → http,环就断了。 */ let actingTenantId: number | null = null export function setActingTenantHeader(tenantId: number | null): void { actingTenantId = tenantId } /** 未登录时的处理(由 router 注册,避免 http → router → views → http 的循环引用) */ type UnauthorizedHandler = () => void let onUnauthorized: UnauthorizedHandler | null = null export function setUnauthorizedHandler(handler: UnauthorizedHandler): void { onUnauthorized = handler } let redirecting = false function handleUnauthorized(): void { if (redirecting) { return } redirecting = true try { onUnauthorized?.() } finally { setTimeout(() => { redirecting = false }, 1000) } } function makeError(text: string, bizCode?: number, bizData?: unknown, status?: number): BizError { const err = new Error(text) as BizError err.bizCode = bizCode err.bizData = bizData err.status = status return err } http.interceptors.request.use((config) => { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } if (actingTenantId) { config.headers['X-Tenant-Id'] = String(actingTenantId) } return config }) http.interceptors.response.use( (response: AxiosResponse) => { const envelope = response.data // 非信封响应(文件流 / 第三方回调)原样放行 if (!envelope || typeof envelope.code !== 'number') { return response } if (envelope.code === 0) { response.data = envelope.data return response } if (envelope.code === CODE_UNAUTHORIZED) { handleUnauthorized() return Promise.reject(makeError(envelope.msg || '登录已失效,请重新登录', envelope.code, envelope.data)) } const text = envelope.msg || '操作失败' message.error(text) return Promise.reject(makeError(text, envelope.code, envelope.data)) }, (error) => { const status: number | undefined = error?.response?.status if (status === 401) { handleUnauthorized() return Promise.reject(makeError('登录已失效,请重新登录', undefined, undefined, 401)) } const text = STATUS_TEXT[status ?? 0] || '网络异常,请检查网络后重试' message.error(text) return Promise.reject(makeError(text, undefined, undefined, status)) } ) /** * 把 axios 的响应**在运行时**取出 `data`。 * * ⚠️ 这一句是整个请求层最容易写错的地方,这里踩过一次: * 上面的响应拦截器是按 axios 的约定写的 —— 它**只能返回一个"响应对象"**, * 于是 `response.data = envelope.data` 只是把信封里的 `data` 换成了业务数据, * **promise 本身仍然 resolve 成那个响应对象**。 * 如果这里只做类型断言(`as unknown as Promise`)而不真的取 `.data`, * 那么所有 `await getXxx()` 拿到的都是 AxiosResponse —— * 表现是"接口明明返回了数据,页面却什么也没有"(`page.list` 是 undefined), * 而且**不报任何错**。登录页是第一个撞上的:它拿公钥去加密,拿到的是个对象。 * * 所以:拦截器负责"拆信封并弹提示",这里负责"把 data 交出去", * 两件事都必须做,缺一不可。 */ const unwrap = (promise: Promise): Promise => promise.then((response) => response.data as T) const get = (url: string, params?: object, config?: AxiosRequestConfig): Promise => unwrap(http.get(url, { params, ...config })) const post = (url: string, data?: object, config?: AxiosRequestConfig): Promise => unwrap(http.post(url, data, config)) const put = (url: string, data?: object, config?: AxiosRequestConfig): Promise => unwrap(http.put(url, data, config)) const del = (url: string, config?: AxiosRequestConfig): Promise => unwrap(http.delete(url, config)) /** * 任意方法的原样请求(上传 FormData、要自定义 timeout 时用)。 * * ⚠️ 与上面四个一样,返回的是**拆过信封的 data** —— 调用方不要再写 `res.data`。 * 要拿原始响应(文件流)请用下面导出的 `raw`。 */ const request = (config: AxiosRequestConfig): Promise => unwrap(http.request(config)) /** 需要拿原始响应(文件流)时用它,例如导出包下载 */ export const raw = http export default { get, post, put, delete: del, request }