Files
yunzerwebsiteallinone/backend/src/utils/request.js
T

155 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;