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
195 lines
6.5 KiB
TypeScript
195 lines
6.5 KiB
TypeScript
/**
|
||
* 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
|