import axios from 'axios'; import { refreshAccessToken, clearTokens, redirectToAuthorize, isAuthHandoff } from '@/utils/authClient'; // 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环 let ssoRedirecting = false; // 获取API基础URL,添加调试信息 const apiBaseURL = import.meta.env.VITE_API_BASE_URL; // 创建axios实例(普通接口 5min;大文件上传在 api/file.js 单独设置更长 timeout) const service = axios.create({ baseURL: apiBaseURL, timeout: 300000, withCredentials: false // JWT 不需要 Cookie }); // 构造鉴权失败错误:带上 status,调用方(如菜单 store)可直接按 401 判断, // 不必依赖中文文案;否则文案一改,登录态判断就静默失效了。 function authError(message) { const err = new Error(message || '登录状态已失效,请重新登录'); err.status = 401; err.isAuthError = true; return err; } // 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转) function redirectToLoginOnce() { // 交接页里的 401 来自旧 token,不代表刚换到的新登录态失效。 // 在这里清 token / 跳认证中心会造成「登录成功后又回登录页」。 if (isAuthHandoff()) return; clearTokens(); localStorage.removeItem('userInfo'); if (ssoRedirecting) return; ssoRedirecting = true; redirectToAuthorize().catch((err) => { console.error('跳转统一认证失败:', err); ssoRedirecting = false; }); } // 401 统一处理: // 阶段一:并发场景下,本请求带的是旧令牌,而别的请求已换成新令牌 → 直接用最新令牌重放; // 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次; // 阶段三:续期失败 / 续期后仍 401 → 清登录态回认证中心。 // 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次; // 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。 async function recoverAuth(config, bizMsg) { // 交接页的 401 来自旧 token,不能用旧 refresh_token 续期(会触发重放吊销), // 也不能重放或自动跳转;换码完成后整页重载即可恢复正常请求。 if (isAuthHandoff()) return Promise.reject(authError('正在完成登录,请稍候')); const latestToken = localStorage.getItem('token') || ''; const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) { console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', config?.url); config.headers['Authorization'] = `Bearer ${latestToken}`; config._replayedWithLatest = true; return service.request(config); } if (config?._refreshed) { // 续期拿到的令牌仍被判定过期/无效:继续留在页面只会一路报错,直接回认证中心 redirectToLoginOnce(); return Promise.reject(authError(bizMsg)); } try { const newToken = await refreshAccessToken(); config.headers['Authorization'] = `Bearer ${newToken}`; config._refreshed = true; return service.request(config); } catch (e) { redirectToLoginOnce(); return Promise.reject(authError('登录状态已失效,请重新登录')); } } // 令牌类错误提示。后端部分接口(全局鉴权中间件为 warn 模式时控制器自行鉴权) // 会在 HTTP 200 的 body 里返回 code:401,需要按提示词识别。 // 不能把所有 body code:401 都当令牌问题:个别接口(如新增用户时校验原密码) // 也用 401 表示业务失败,那类响应重放没有意义,更不能因此把用户登出。 const TOKEN_ERROR_HINTS = ['token已过期', '无效的token', '未提供认证信息', '认证信息格式错误']; function isTokenFailure(body) { if (!body || Number(body.code) !== 401) return false; const msg = String(body.msg || body.message || ''); return TOKEN_ERROR_HINTS.some((hint) => msg.includes(hint)); } // 请求拦截器 service.interceptors.request.use( config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } // 对于有 body 的请求(POST、PUT、PATCH),默认 JSON; // FormData 必须由浏览器自行设置 Content-Type,否则缺少 multipart boundary,服务端无法解析 if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) { if (typeof FormData !== 'undefined' && config.data instanceof FormData) { delete config.headers['Content-Type']; delete config.headers['content-type']; } else if (!config.headers['Content-Type'] && !config.headers['content-type']) { config.headers['Content-Type'] = 'application/json'; } } return config; }, error => { return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response => { const data = response.data; // 兜住 HTTP 200 + body code:401 的令牌失效响应,让它走与真实 401 相同的续期流程 if (isTokenFailure(data)) { return recoverAuth(response.config, data.msg || data.message); } return data; }, async error => { if (error.response) { // 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作), // 避免页面只显示 "Request failed with status code xxx" const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { case 401: return recoverAuth(error.config, bizMsg); case 404: console.error('请求的资源不存在'); break; default: console.error('请求失败,请稍后再试'); } if (bizMsg) { return Promise.reject(new Error(bizMsg)); } } else if (error.request) { if (error.code === 'ECONNABORTED') { console.error('请求超时,请稍后重试'); } else { console.error('请求失败,请检查网络连接'); } } else { console.error('请求配置错误'); } return Promise.reject(error); } ); export default service;