Files
wecom_it_smart_desk/frontend-agent/src/api/index.ts
T

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