// HTTP 请求引擎 — 统一 fetch 封装:超时/取消、token 注入、结构化错误、 // 401 单飞刷新 + 队列重放(见开发标准 03 章)。组件一律通过 api 层调用,禁止直接 fetch。 import type { ApiResponse } from '@/types'; import { getAccessToken, getRefreshToken, saveTokenPair, clearAuth, } from '@/lib/auth-storage'; const API_BASE = import.meta.env.VITE_API_URL || ''; const DEFAULT_TIMEOUT = 30000; // 30 秒默认超时 const REFRESH_TIMEOUT = 10000; // 刷新 token 接口独立超时 /** 结构化 API 错误:status 为 HTTP 状态码(网络/超时等非 HTTP 错误为 0),code 为后端业务码 */ export class ApiError extends Error { readonly status: number; readonly code?: number; constructor(message: string, status = 0, code?: number) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; } } export interface RequestOptions extends RequestInit { timeout?: number; /** 静默模式:401 失效不触发清凭证/跳登录的全局副作用,仅抛错(登录、轮询接口用) */ silent?: boolean; /** 内部标记:刷新 token 后的重放请求,防止 401 无限循环 */ retried?: boolean; } // —— 401 单飞刷新状态 —— // 并发 N 个 401 只触发一次刷新;刷新期间的请求进入队列,拿到新 token 后统一重放。 let isRefreshing = false; let refreshQueue: Array<(token: string | null) => void> = []; // 这些端点自身的 401 不触发登出跳转(错误密码、refresh 失效等场景) const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh']; async function doRefresh(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) return null; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT); const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), signal: controller.signal, }); clearTimeout(timer); if (!response.ok) return null; const body = (await response.json()) as ApiResponse<{ accessToken: string; refreshToken: string; expiresAt: number; }>; if (body.code !== 0 || !body.data?.accessToken) return null; saveTokenPair(body.data); return body.data.accessToken; } catch { return null; } } let redirecting = false; /** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */ function redirectToLogin(): void { clearAuth(); // 已经在登录页就**不要再跳**:给 location.href 赋同值也会触发整页重载, // 而重载会重新挂载组件、再发一次需要鉴权的请求 → 又 401 → 再重载。 // 这正是登录页"一直加载 + 闪屏"的根本原因(实测 10 分钟 1566 次 401)。 if (window.location.pathname.startsWith('/login')) { return; } if (redirecting) return; redirecting = true; const current = window.location.pathname + window.location.search; window.location.href = `/login?redirect=${encodeURIComponent(current)}`; } /** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */ async function doRequest(url: string, options: RequestOptions): Promise { const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options; const token = getAccessToken(); const headers: Record = { 'Content-Type': 'application/json', ...((fetchOptions.headers as Record) || {}), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const fullUrl = `${API_BASE}/api/v1${url}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const onExternalAbort = () => controller.abort(); if (signal) { signal.addEventListener('abort', onExternalAbort); } try { const response = await fetch(fullUrl, { ...fetchOptions, headers, signal: controller.signal, }); if (response.status === 401) { throw new ApiError('未授权或登录已过期', 401); } const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { const text = await response.text(); throw new ApiError( `服务端返回了非 JSON 响应(${response.status}):${text.substring(0, 200)}`, response.status ); } const body: unknown = await response.json(); // 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON if ( body !== null && typeof body === 'object' && 'code' in body && typeof (body as ApiResponse).code === 'number' ) { const data = body as ApiResponse; if (data.code !== 0) { throw new ApiError(data.message || '请求失败', response.status, data.code); } return data.data; } return body as T; } catch (err) { if (err instanceof ApiError) throw err; if (err instanceof Error && err.name === 'AbortError') { if (controller.signal.aborted && !signal?.aborted) { throw new ApiError(`请求超时(${timeout}ms)`); } throw new ApiError('请求已取消'); } if (err instanceof TypeError) { throw new ApiError('无法连接到后端服务,请确认 API 服务已启动'); } throw err instanceof Error ? new ApiError(err.message) : new ApiError(String(err)); } finally { clearTimeout(timeoutId); if (signal) { signal.removeEventListener('abort', onExternalAbort); } } } /** * 核心请求函数:在 doRequest 之上处理 401 —— 有 refreshToken 时单飞刷新并重放; * 刷新失败或无 refreshToken 时清凭证跳登录(silent 请求除外)。 */ export async function request( url: string, options: RequestOptions = {} ): Promise { try { return await doRequest(url, options); } catch (err) { if (!(err instanceof ApiError) || err.status !== 401) throw err; // 静默请求 / 鉴权端点自身 / 已重放过:不再刷新、不再跳转,直接失败 if (options.silent || options.retried || AUTH_ENDPOINTS.some((p) => url.startsWith(p))) { throw err; } if (!getRefreshToken()) { redirectToLogin(); throw new ApiError('登录已过期,请重新登录', 401); } // 已有刷新在进行中:挂入队列,等新 token 下发后重放本请求 if (isRefreshing) { return new Promise((resolve, reject) => { refreshQueue.push((token) => { if (!token) { reject(new ApiError('登录已过期,请重新登录', 401)); return; } request(url, { ...options, retried: true }).then(resolve, reject); }); }); } isRefreshing = true; let newToken: string | null = null; try { newToken = await doRefresh(); } finally { isRefreshing = false; const queue = refreshQueue; refreshQueue = []; queue.forEach((cb) => cb(newToken)); } if (!newToken) { redirectToLogin(); throw new ApiError('登录已过期,请重新登录', 401); } return request(url, { ...options, retried: true }); } } /** * 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。 * @example * http.get('/clusters') * http.post('/clusters', { name, ... }) * http.put(`/clusters/${id}`, updates) * http.delete(`/clusters/${id}`) */ export const http = { get: (url: string, options?: RequestOptions) => request(url, { ...options, method: 'GET' }), post: (url: string, body?: unknown, options?: RequestOptions) => request(url, { ...options, method: 'POST', body: body === undefined ? undefined : JSON.stringify(body), }), put: (url: string, body?: unknown, options?: RequestOptions) => request(url, { ...options, method: 'PUT', body: body === undefined ? undefined : JSON.stringify(body), }), delete: (url: string, options?: RequestOptions) => request(url, { ...options, method: 'DELETE' }), }; /** 列表查询参数(分页) */ export interface ListParams { page?: number; pageSize?: number; } /** 列表查询参数:统一编码拼接,避免模板字符串拼 query 无转义 */ export function buildQuery(params: ListParams): string { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== '') { search.set(key, String(value)); } } const qs = search.toString(); return qs ? `?${qs}` : ''; }