155 lines
6.4 KiB
JavaScript
155 lines
6.4 KiB
JavaScript
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;
|