Files
xiaoxia-saas/apps/web/src/api/client.ts
T
灵应 6c04bb53ad
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m7s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m8s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m43s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Unit Tests (push) Successful in 11m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 24m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
fix: 主动token刷新-在过期前自动续期避免401错误 (#1385)
2026-08-17 14:28:47 +08:00

195 lines
6.5 KiB
TypeScript
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.
/**
* API 客户端配置
* 封装 Axios 实例,配置拦截器和 Token 管理
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
})
// ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false
let failedQueue: Array<{
resolve: (value?: unknown) => void
reject: (reason?: unknown) => void
}> = []
const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
// 请求拦截器:添加 Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("access_token")
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
},
)
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean
}
// 401 → 尝试刷新 Token
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
const requestUrl = originalRequest?.url || ""
const isAuthEndpoint =
requestUrl.includes("/auth/login") ||
requestUrl.includes("/auth/register") ||
requestUrl.includes("/auth/forgot-password") ||
requestUrl.includes("/auth/reset-password")
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isAuthEndpoint
) {
const refreshToken = useAuthStore.getState().refreshToken
// 无 refresh_token → 直接登出
if (!refreshToken) {
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(error)
}
// 已在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
return apiClient(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const data = await refreshAccessToken(refreshToken)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshToken
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 处理排队的请求
processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
}
return apiClient(originalRequest)
} catch (refreshError) {
// 刷新失败 → 登出
cancelProactiveRefresh()
processQueue(refreshError, null)
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
// 提取后端返回的错误信息(detail / message / msg
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
const data = error.response?.data
const rawServerMsg = data?.detail || data?.message || data?.msg
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
// 嵌套对象:递归提取
if (typeof obj.message === "object" && obj.message !== null)
return safeExtractString(obj.message)
if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg)
return JSON.stringify(val)
}
return ""
}
const serverMsg = safeExtractString(rawServerMsg)
let handled = false
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
message.error("请求超时,请检查网络后重试")
handled = true
} else if (!error.response) {
message.error("网络连接异常,请检查网络设置")
handled = true
} else if (serverMsg) {
message.error(serverMsg)
handled = true
} else {
const status = error.response?.status
if (status === 413) {
message.error("文件过大,请缩小后重试")
handled = true
} else if (status === 415) {
message.error("不支持的文件格式")
handled = true
} else if (status === 503) {
message.error("服务暂不可用,请稍后再试")
handled = true
} else if (status && status >= 500) {
message.error("服务器繁忙,请稍后再试")
handled = true
}
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
}
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
if (handled) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
}
return Promise.reject(error)
},
)
export default apiClient