242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
// =============================================================================
|
|
// 企微IT智能服务台 — 坐席工作台 Axios 实例与拦截器
|
|
// =============================================================================
|
|
// 说明:创建 Axios 实例,配置:
|
|
// 1. 请求基础 URL
|
|
// 2. 请求拦截器(添加认证头等)
|
|
// 3. 响应拦截器(统一错误处理 + Token 静默刷新)
|
|
// =============================================================================
|
|
|
|
import axios from 'axios'
|
|
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
|
// ElementPlus 消息提示
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 创建 Axios 实例
|
|
// --------------------------------------------------------------------------
|
|
const apiClient: AxiosInstance = axios.create({
|
|
// 基础 URL:所有请求会自动加上这个前缀
|
|
// 开发环境通过 Vite proxy 转发到后端
|
|
baseURL: '/api',
|
|
// 请求超时时间(20秒,原10秒)
|
|
// 原因:图片/文件上传、AI消息处理等场景后端处理需要更多时间
|
|
// 修复截图发送超时Bug
|
|
timeout: 20000,
|
|
// 默认请求头
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// AUTH-P0-02: Token 静默刷新机制
|
|
// --------------------------------------------------------------------------
|
|
// Token 存储键名
|
|
const TOKEN_KEY = 'agent_token'
|
|
|
|
// OAuth2 重定向计数器 key
|
|
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
|
|
// 最大允许重定向次数
|
|
const OAUTH_MAX_REDIRECT_COUNT = 3
|
|
|
|
// 401 处理锁(防止并发请求同时触发多次认证过期处理)
|
|
let _authExpiredPromise: Promise<void> | null = null
|
|
|
|
// Token 刷新锁(防止多个并发请求同时触发刷新)
|
|
let _refreshPromise: Promise<boolean> | null = null
|
|
|
|
/**
|
|
* 静默刷新 Token
|
|
* 在 Token 过期前 5 分钟自动调用,成功后返回 true
|
|
*/
|
|
async function silentRefreshToken(): Promise<boolean> {
|
|
// 如果已有刷新在进行中,等待同一个 Promise
|
|
if (_refreshPromise) {
|
|
return _refreshPromise
|
|
}
|
|
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
if (!token) {
|
|
return false
|
|
}
|
|
|
|
console.log('[API] 尝试静默刷新 Token...')
|
|
|
|
// 创建刷新 Promise 并缓存
|
|
_refreshPromise = (async () => {
|
|
try {
|
|
const response = await axios.post('/api/auth/refresh', null, {
|
|
params: { token },
|
|
timeout: 5000, // 刷新接口 5 秒超时
|
|
})
|
|
|
|
if (response.data.code === 0) {
|
|
// 刷新成功,Token TTL 已延长
|
|
console.log('[API] Token 静默刷新成功')
|
|
return true
|
|
}
|
|
|
|
console.warn('[API] Token 刷新失败:', response.data.message)
|
|
return false
|
|
} catch (e) {
|
|
console.warn('[API] Token 刷新异常:', e)
|
|
return false
|
|
} finally {
|
|
_refreshPromise = null
|
|
}
|
|
})()
|
|
|
|
return _refreshPromise
|
|
}
|
|
|
|
/**
|
|
* 处理认证过期/未授权
|
|
* AUTH-P0-02: 增强 401 处理,先尝试刷新 Token,成功则重放请求
|
|
*/
|
|
async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
|
const label = source === 'http401' ? 'HTTP 401' : '业务码 1002'
|
|
|
|
// 如果已有 401 正在处理中,复用同一个 Promise,避免多次处理
|
|
if (_authExpiredPromise) {
|
|
console.warn(`[API] ${label} 未授权 — 已有处理进行中,等待完成`)
|
|
return _authExpiredPromise
|
|
}
|
|
|
|
console.warn(`[API] ${label} 未授权,尝试刷新 Token...`)
|
|
|
|
// 创建处理 Promise 并缓存(去重用)
|
|
_authExpiredPromise = (async () => {
|
|
try {
|
|
// 第一步:尝试静默刷新 Token
|
|
const refreshSuccess = await silentRefreshToken()
|
|
|
|
if (refreshSuccess) {
|
|
console.log('[API] Token 刷新成功,不需要跳转')
|
|
_authExpiredPromise = null
|
|
return
|
|
}
|
|
|
|
// 第二步:刷新失败,清除凭证并跳转登录
|
|
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
|
|
|
|
// 清除本地 token
|
|
localStorage.removeItem(TOKEN_KEY)
|
|
localStorage.removeItem('portal_token')
|
|
|
|
// 跳转登录页
|
|
ElMessage.warning('登录已过期,请重新登录')
|
|
|
|
// 防循环检测:超过最大重定向次数时停止跳转
|
|
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
|
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
|
|
console.error('[API] 登录重定向次数超限,疑似无限循环,停止重定向')
|
|
ElMessage.error('登录状态异常,请刷新页面重试')
|
|
return
|
|
}
|
|
|
|
console.warn(`[API] 登录重定向计数: ${currentCount}/${OAUTH_MAX_REDIRECT_COUNT}`)
|
|
|
|
// 动态导入避免循环依赖
|
|
import('@/router').then(router => {
|
|
router.default.push('/login')
|
|
})
|
|
} catch (e) {
|
|
console.warn('[API] 401 处理失败:', e)
|
|
} finally {
|
|
// 处理完成后清除锁,允许未来的 401 重新触发
|
|
_authExpiredPromise = null
|
|
}
|
|
})()
|
|
|
|
return _authExpiredPromise
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 请求拦截器
|
|
// --------------------------------------------------------------------------
|
|
// 在每个请求发送前执行,用于添加认证信息等
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
// 从 localStorage 获取坐席 token,添加到请求头
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
},
|
|
(error) => {
|
|
// 请求配置错误时直接返回
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 响应拦截器
|
|
// --------------------------------------------------------------------------
|
|
// 在每个响应返回后执行,用于统一处理错误
|
|
apiClient.interceptors.response.use(
|
|
(response: AxiosResponse) => {
|
|
// 从响应中提取业务数据
|
|
const res = response.data
|
|
|
|
// 统一响应格式:{code: 0, data: {}, message: "success"}
|
|
// code === 0 表示业务成功
|
|
if (res.code !== 0) {
|
|
// 业务错误:显示错误消息
|
|
ElMessage.error(res.message || '请求失败')
|
|
|
|
// AUTH-P0-02: 特殊错误码处理 - 尝试刷新 Token
|
|
if (res.code === 1002) {
|
|
// 未授权:先尝试刷新 Token
|
|
handleAuthExpired('biz1002')
|
|
}
|
|
|
|
// 返回 rejected Promise,让调用方的 catch 能捕获
|
|
return Promise.reject(new Error(res.message || '请求失败'))
|
|
}
|
|
|
|
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
|
|
return response
|
|
},
|
|
async (error) => {
|
|
// 网络错误或服务器错误(HTTP 状态码非 2xx)
|
|
let message = '网络异常,请稍后重试'
|
|
|
|
if (error.response) {
|
|
// 服务器返回了错误状态码
|
|
switch (error.response.status) {
|
|
case 401:
|
|
// AUTH-P0-02: 先尝试静默刷新 Token,成功则重放请求
|
|
await handleAuthExpired('http401')
|
|
// 不显示通用提示,因为会自动处理
|
|
break
|
|
case 403:
|
|
message = '拒绝访问'
|
|
break
|
|
case 404:
|
|
message = '请求的资源不存在'
|
|
break
|
|
case 500:
|
|
message = '服务器内部错误'
|
|
break
|
|
default:
|
|
message = `请求失败 (${error.response.status})`
|
|
}
|
|
} else if (error.code === 'ECONNABORTED') {
|
|
// 请求超时
|
|
message = '请求超时,请稍后重试'
|
|
}
|
|
|
|
// 显示错误提示(401 时不显示通用提示,因为会自动处理)
|
|
if (!error.response || error.response.status !== 401) {
|
|
ElMessage.error(message)
|
|
}
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
)
|
|
|
|
// 导出 Axios 实例,供 API 模块使用
|
|
export default apiClient
|