import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import { ADMIN_API_PREFIX, API_BASE_URL, LOGIN_PATH, REQUEST_TIMEOUT } from '@/config' import { clearToken, getAccessToken, refreshToken as refreshTokenRef, setToken } from '@/utils/auth' import type { CommonResult } from '@/types' /** * axios 封装(对齐后端 parking-cloud 契约) * * 1. 自动附加 Authorization: Bearer * 2. 自动解包 CommonResult:code === 0 时 resolve 出 data;否则 reject * 3. 401 → 先用刷新令牌续期并**重放原请求**;刷新也失败才清 token 跳登录 * 4. 403 → 提示无权限 * * 注意:后端对「未登录 / 无权限」返回的是 HTTP 200 + 响应体 code=401/403 * (与参考工程 urbanops 一致,协议以**响应体 code** 为准),所以判定逻辑放在 * 成功分支里,而不是 axios 的 error 分支。 * * ⚠️ 为什么必须有第 3 条的续期:访问令牌只有 30 分钟(`system_oauth2_client` * 的 `access_token_validity_seconds`),而刷新令牌有 30 天。若 401 直接跳登录页, * 用户每半小时就被无理由踢出去一次,同时那个 30 天的刷新令牌永远是死数据。 */ /** 401 只提示一次 */ let isReloginShowing = false /** 带「是否已重试过」标记的请求配置,用于防止刷新后仍 401 时无限重放 */ type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean } /** 刷新中的共享 Promise:401 往往并发出现(页面同时发多个请求),只允许发一次刷新 */ let refreshPromise: Promise | null = null /** * 用刷新令牌换新的访问令牌。 * * 刻意用**裸 axios**而不是本文件的 service 实例,两个原因: * 1. service 的响应拦截器在 401 时又会走到本函数 → 无限递归; * 2. 避免 request.ts → api/login → request.ts 的循环 import。 */ async function doRefreshToken(): Promise { const rt = refreshTokenRef.value if (!rt) { return false } try { const { data } = await axios.post( `${API_BASE_URL}${ADMIN_API_PREFIX}/system/auth/refresh-token`, { refreshToken: rt }, { timeout: REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json;charset=utf-8' } } ) const token = data?.data if (data && Number(data.code) === 0 && token?.accessToken) { setToken(token.accessToken, token.refreshToken, token.expiresTime) return true } return false } catch (e) { // 刷新令牌本身过期/被撤销:交给上层跳登录页,这里不弹错(避免一次过期弹两条提示) return false } } function refreshOnce(): Promise { if (!refreshPromise) { refreshPromise = doRefreshToken().finally(() => { refreshPromise = null }) } return refreshPromise } const service: AxiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json;charset=utf-8' }, // 数组参数序列化成 `createTime=2026-01-01&createTime=2026-01-02`, // 而不是 axios 默认的 `createTime[]=...`。后端用 // `@DateTimeFormat(pattern="yyyy-MM-dd") List createTime` 接收, // 带方括号的 key 绑不上(列表页的日期范围查询就靠这一条)。 paramsSerializer: { indexes: null } }) service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = getAccessToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) ) service.interceptors.response.use( (response) => { // 文件下载等非 JSON 响应原样返回 if (response.request?.responseType === 'blob' || response.config?.responseType === 'blob') { return response } const res = response.data as CommonResult // 兼容非 CommonResult 结构(如第三方回调),原样返回 if (res == null || typeof res.code === 'undefined') { return response.data } const code = Number(res.code) if (code === 0) { return res.data } if (code === 401) { return handleUnauthorized(response.config, res.msg) } if (code === 403) { ElMessage.error(res.msg || '没有访问权限') return Promise.reject(new Error(res.msg || '没有访问权限')) } ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg || '请求失败')) }, (error) => { // HTTP 层错误(网络异常 / 超时 / 5xx) const status = error?.response?.status if (status === 401) { // 正常契约下 401 是 HTTP 200 + 响应体 code,走不到这里; // 但 Spring Security 过滤器层直接拒绝时会是真的 401,同样尝试续期 return handleUnauthorized(error.config, error?.message) } let msg = error?.message || '网络异常' if (msg.includes('timeout')) msg = '请求超时,请稍后重试' else if (status === 404) msg = '请求地址不存在' else if (status === 500) msg = '服务器异常,请联系管理员' else if (status === 502 || status === 503) msg = '服务暂时不可用' ElMessage.error(msg) return Promise.reject(error) } ) /** * 401 处理:先尝试用刷新令牌续期并重放原请求;续期不成功才提示重新登录。 * * @param config 原请求配置(重放用) * @param msg 后端返回的提示语 * @returns 重放请求的结果(续期成功时);否则 reject */ async function handleUnauthorized(config?: InternalAxiosRequestConfig, msg?: string): Promise { const original = config as RetryConfig | undefined // 已经重试过的请求不再续期:否则「续期成功但业务接口仍返回 401」会无限循环 if (original && !original._retry) { original._retry = true if (await refreshOnce()) { return service.request(original) } } showRelogin() throw new Error(msg || '账号未登录') } function showRelogin() { if (isReloginShowing) return isReloginShowing = true ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }) .then(() => { clearToken() window.location.href = `${LOGIN_PATH}?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}` }) .catch(() => {}) .finally(() => { isReloginShowing = false }) } /** * 对外暴露的方法包装:返回值类型直接是解包后的 data * * ⚠️ 这里必须是**独立的对象**,不能写成 `const request = service` 之后再挂方法—— * 那样 request 与 service 是同一个实例,`request.post = (...) => service.post(...)` * 会把 service.post 覆盖成自己,调用时无限递归(RangeError: Maximum call stack size exceeded)。 */ const request = { get: (url: string, config?: AxiosRequestConfig) => service.get(url, config) as Promise, post: (url: string, data?: any, config?: AxiosRequestConfig) => service.post(url, data, config) as Promise, put: (url: string, data?: any, config?: AxiosRequestConfig) => service.put(url, data, config) as Promise, delete: (url: string, config?: AxiosRequestConfig) => service.delete(url, config) as Promise, /** 文件下载(Excel 导出等) */ download: async (url: string, params?: any, filename?: string) => { const response = await service.get(url, { params, responseType: 'blob' }) const blob = new Blob([response.data]) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = filename || 'download.xlsx' link.click() URL.revokeObjectURL(link.href) } } export { service } export default request