Files
xiaoxia-saas/apps/web/src/api/client.ts
T
xiaoxia 54368c24ff
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m50s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
CI/CD Pipeline / Integration Tests (push) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 7m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m44s
CI/CD Pipeline / Build Staging API Image (push) Failing after 11m19s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 13m51s
CI/CD Pipeline / Validate - Security (push) Successful in 13m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m14s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(cover): 修复封面编辑器多项bug;加长草稿保存/全局timeout (#2032)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 01:02:14 +08:00

213 lines
7.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 { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: "/api/v1",
timeout: 30000, // 全局 30s;智能选片/封面生成/大文件上传接口单独覆盖更长超时
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
/**
* 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。
* 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」,
* 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。
*/
_silentErrorToast?: boolean
}
// 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗
if (originalRequest?._silentErrorToast) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(error as any).__msgShown = true
return Promise.reject(error)
}
// 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 refreshPromise = executeTokenRefresh()
if (!refreshPromise) {
// user 或 refreshToken 为空,无法刷新
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
}
await refreshPromise
// 获取刷新后的新 token
const newAccessToken = useAuthStore.getState().accessToken
if (!newAccessToken) {
return Promise.reject(new Error("Token refresh failed: no new access token"))
}
// 处理排队的请求
processQueue(null, newAccessToken)
// 重试原始请求
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