refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios";
|
||||
|
||||
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
|
||||
|
||||
@@ -6,20 +6,28 @@ export type BackendEnvelope<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
msg: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 业务错误的统一载体:HTTP 200 只代表传输完成,code/reason 才决定业务是否成功。
|
||||
* 调用方应依赖机器可读字段做分支,message 仅用于向用户展示,避免把后端文案当协议解析。
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status?: number;
|
||||
readonly code?: number;
|
||||
readonly reason?: string;
|
||||
readonly retryable: boolean;
|
||||
readonly retryAfterMs?: number;
|
||||
readonly cause?: unknown;
|
||||
|
||||
constructor(message: string, options: { status?: number; code?: number; retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {}) {
|
||||
constructor(message: string, options: { status?: number; code?: number; reason?: string; retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
this.code = options.code;
|
||||
this.reason = options.reason;
|
||||
this.retryable = options.retryable ?? isRetryableStatus(options.status ?? options.code);
|
||||
this.retryAfterMs = options.retryAfterMs;
|
||||
this.cause = options.cause;
|
||||
@@ -30,6 +38,11 @@ export class ApiError extends Error {
|
||||
export const apiBaseURL = import.meta.env.VITE_CANVAS_BACKEND_URL || "/api";
|
||||
export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: true });
|
||||
|
||||
/**
|
||||
* 解包后端业务信封的唯一边界。这里把非零业务 code 转成 ApiError,保留 reason、重试语义和 Retry-After;
|
||||
* 取消请求必须继续抛出 AbortError,不能被包装成普通失败,否则页面切换会被误报为错误。
|
||||
*/
|
||||
|
||||
export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; status?: number; headers?: unknown }>) {
|
||||
try {
|
||||
const response = await promise;
|
||||
@@ -37,28 +50,37 @@ export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; st
|
||||
throw new ApiError(response.data.msg || "请求失败", {
|
||||
status: response.status,
|
||||
code: response.data.code,
|
||||
reason: response.data.reason,
|
||||
retryable: isRetryableStatus(response.status) || isRetryableStatus(response.data.code),
|
||||
retryAfterMs: retryAfterMilliseconds(response.headers),
|
||||
});
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error) || (axios.isAxiosError(error) && error.code === axios.AxiosError.ERR_CANCELED)) {
|
||||
throw new DOMException("请求已取消", "AbortError");
|
||||
}
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) {
|
||||
const status = error.response?.status;
|
||||
const code = error.response?.data?.code;
|
||||
throw new ApiError(error.response?.data?.msg || error.message || "请求失败", {
|
||||
status,
|
||||
code,
|
||||
retryable: isRetryableStatus(status) || isRetryableStatus(code),
|
||||
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw unwrapTransportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapTransportError(error: unknown): never {
|
||||
if (error instanceof ApiError || (error instanceof DOMException && error.name === "AbortError")) {
|
||||
throw error;
|
||||
}
|
||||
if (axios.isCancel(error) || (axios.isAxiosError(error) && error.code === axios.AxiosError.ERR_CANCELED)) {
|
||||
throw new DOMException("请求已取消", "AbortError");
|
||||
}
|
||||
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) {
|
||||
const status = error.response?.status;
|
||||
const code = error.response?.data?.code;
|
||||
throw new ApiError(error.response?.data?.msg || error.message || "请求失败", {
|
||||
status,
|
||||
code,
|
||||
reason: error.response?.data?.reason,
|
||||
retryable: isRetryableStatus(status) || isRetryableStatus(code),
|
||||
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function isRetryableStatus(status?: number) {
|
||||
@@ -80,6 +102,31 @@ function retryAfterMilliseconds(headers: unknown) {
|
||||
return Math.max(0, retryAt - Date.now());
|
||||
}
|
||||
|
||||
export type HttpRequestConfig = Omit<AxiosRequestConfig, "method" | "url" | "data" | "baseURL">;
|
||||
|
||||
async function send<T>(method: string, url: string, data?: unknown, config?: HttpRequestConfig) {
|
||||
return request<T>(apiClient.request<BackendEnvelope<T>>({ method, url, data, ...config }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务 JSON 的唯一调用入口。模块不要再写 `request(apiClient.get(...))`,也不要再 `axios.create`。
|
||||
* 拦截器仍挂在 `apiClient` 上;流式 fetch、媒体 blob 和渠道中转走各自边界。
|
||||
*/
|
||||
export const http = {
|
||||
get: <T>(url: string, config?: HttpRequestConfig) => send<T>("get", url, undefined, config),
|
||||
post: <T>(url: string, data?: unknown, config?: HttpRequestConfig) => send<T>("post", url, data, config),
|
||||
put: <T>(url: string, data?: unknown, config?: HttpRequestConfig) => send<T>("put", url, data, config),
|
||||
patch: <T>(url: string, data?: unknown, config?: HttpRequestConfig) => send<T>("patch", url, data, config),
|
||||
delete: <T>(url: string, config?: HttpRequestConfig) => send<T>("delete", url, undefined, config),
|
||||
async raw<T>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
try {
|
||||
return await apiClient.request<T>(config);
|
||||
} catch (error) {
|
||||
throw unwrapTransportError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function compactApiParams(params: ApiParams) {
|
||||
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user