mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
b7e5e811d1
Navigating from login reused a send_email_code token on register. Captcha failure used 401 so the client stored /register as the post-login target and never left the page. Permission denials now return 403, and the API client no longer wipes the session on 401.
378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
import axios, {
|
|
AxiosError,
|
|
AxiosResponse,
|
|
CancelTokenSource,
|
|
InternalAxiosRequestConfig,
|
|
} from 'axios';
|
|
import { toast } from 'sonner';
|
|
import { apiConfig, getApiBaseUrl } from './config';
|
|
import {
|
|
ApiErrorBase,
|
|
ForbiddenError,
|
|
NetworkError,
|
|
NotFoundError,
|
|
ServerError,
|
|
TimeoutError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
} from './errors';
|
|
import { ApiError, ApiResponse } from './types';
|
|
|
|
/**
|
|
* API 客户端实例
|
|
* 统一处理请求配置、响应解析和错误处理
|
|
*/
|
|
const apiClient = axios.create({
|
|
baseURL: apiConfig.baseURL,
|
|
timeout: apiConfig.timeout,
|
|
withCredentials: apiConfig.withCredentials,
|
|
// Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes.
|
|
maxRedirects: 5,
|
|
// Gin QueryArray("hosts") expects hosts=a&hosts=b, not hosts[]=a.
|
|
paramsSerializer: {
|
|
indexes: null,
|
|
},
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
/** Normalize legacy /api/* paths: strip trailing slash to avoid 308/301 loops in dev proxy. */
|
|
function normalizeApiPath(url?: string): string | undefined {
|
|
if (!url || !url.startsWith('/api/')) {
|
|
return url;
|
|
}
|
|
if (url.length > 5 && url.endsWith('/')) {
|
|
return url.replace(/\/+$/, '');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* 请求取消令牌存储
|
|
*/
|
|
const cancelTokens = new Map<string, CancelTokenSource>();
|
|
|
|
/**
|
|
* 请求缓存存储
|
|
* 存储正在进行的请求 Promise,避免重复请求
|
|
*/
|
|
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();
|
|
|
|
/**
|
|
* 生成请求的唯一键
|
|
* 包含方法、URL 和请求数据的哈希,确保不同参数的请求不会被误取消
|
|
*/
|
|
function getRequestKey(config: {
|
|
method?: string;
|
|
url?: string;
|
|
data?: unknown;
|
|
}): string {
|
|
const baseKey = `${config.method?.toUpperCase()}_${config.url}`;
|
|
|
|
/* 序列化加入键中 */
|
|
if (config.data) {
|
|
try {
|
|
const dataHash = JSON.stringify(config.data);
|
|
return `${baseKey}_${dataHash}`;
|
|
} catch {
|
|
// 失败使用基础键
|
|
return baseKey;
|
|
}
|
|
}
|
|
|
|
return baseKey;
|
|
}
|
|
|
|
/**
|
|
* 请求拦截器
|
|
* 添加取消令牌和其他配置
|
|
*/
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
// Browser must use same-origin relative URLs so Session Cookie hits Next rewrite.
|
|
if (typeof window !== 'undefined') {
|
|
config.baseURL = getApiBaseUrl();
|
|
}
|
|
config.url = normalizeApiPath(config.url);
|
|
|
|
const requestKey = getRequestKey(config);
|
|
const source = axios.CancelToken.source();
|
|
config.cancelToken = source.token;
|
|
cancelTokens.set(requestKey, source);
|
|
|
|
return config;
|
|
},
|
|
(error: unknown) => Promise.reject(error),
|
|
);
|
|
|
|
function isPublicAuthRequest(url?: string): boolean {
|
|
if (!url) return false;
|
|
return (
|
|
url.includes('/user/login') ||
|
|
url.includes('/user/register') ||
|
|
url.includes('/user/send-email-code') ||
|
|
url.includes('/cap/') ||
|
|
url.includes('/oauth/login') ||
|
|
url.includes('/oauth/callback') ||
|
|
url.includes('/oauth/sources')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 响应拦截器
|
|
* 处理 API 响应和统一错误处理
|
|
*/
|
|
apiClient.interceptors.response.use(
|
|
(response: AxiosResponse<ApiResponse>) => {
|
|
const requestKey = getRequestKey(response.config);
|
|
cancelTokens.delete(requestKey);
|
|
pendingRequests.delete(requestKey);
|
|
|
|
const resData = response.data as ApiError & ApiResponse;
|
|
if (resData && resData.error_msg) {
|
|
return Promise.reject(new ApiErrorBase(resData.error_msg));
|
|
}
|
|
|
|
return response;
|
|
},
|
|
(error: AxiosError<ApiError>) => {
|
|
if (error.config) {
|
|
const requestKey = getRequestKey(error.config);
|
|
cancelTokens.delete(requestKey);
|
|
pendingRequests.delete(requestKey);
|
|
}
|
|
|
|
/* 请求被取消时静默处理 */
|
|
if (axios.isCancel(error)) {
|
|
const cancelError = new Error(
|
|
error.message || '请求已被取消',
|
|
) as Error & { __CANCEL__?: boolean };
|
|
cancelError.__CANCEL__ = true;
|
|
return Promise.reject(cancelError);
|
|
}
|
|
|
|
/* 401:未登录。公开认证接口只把错误交给页面;已登录态下的误报不得清 cookie 跳登录页。 */
|
|
if (error.response?.status === 401) {
|
|
const message = error.response.data?.error_msg || '未登录';
|
|
if (!isPublicAuthRequest(error.config?.url)) {
|
|
toast.error(message, { id: 'unauthorized-error' });
|
|
}
|
|
return Promise.reject(new UnauthorizedError(message));
|
|
}
|
|
|
|
/* 403:已登录但权限不足,留在当前页。 */
|
|
if (error.response?.status === 403) {
|
|
const message = error.response.data?.error_msg || '权限不足';
|
|
toast.error(message, { id: 'forbidden-error' });
|
|
return Promise.reject(
|
|
new ForbiddenError(
|
|
message,
|
|
error.response.data?.error_code,
|
|
error.response.data?.details,
|
|
),
|
|
);
|
|
}
|
|
|
|
/* 404 资源未找到错误 */
|
|
if (error.response?.status === 404) {
|
|
return Promise.reject(
|
|
new NotFoundError(error.response.data?.error_msg || '请求的资源不存在'),
|
|
);
|
|
}
|
|
|
|
/* 400 验证错误 */
|
|
if (error.response?.status === 400) {
|
|
return Promise.reject(
|
|
new ValidationError(
|
|
error.response.data?.error_msg || '请求参数验证失败',
|
|
error.response.data?.details,
|
|
),
|
|
);
|
|
}
|
|
|
|
/* 429 速率限制错误 */
|
|
if (error.response?.status === 429) {
|
|
const retryAfter = error.response.headers?.['retry-after'];
|
|
const message =
|
|
error.response.data?.error_msg ||
|
|
`请求过于频繁,请 ${retryAfter || '稍后'} 秒后重试`;
|
|
|
|
toast.error('请求频率限制', {
|
|
description: message,
|
|
id: 'rate-limit-error',
|
|
});
|
|
|
|
return Promise.reject(new ApiErrorBase(message, 'RATE_LIMITED', 429));
|
|
}
|
|
|
|
/* 5xx 服务器错误 */
|
|
if (error.response && error.response.status >= 500) {
|
|
return Promise.reject(
|
|
new ServerError(
|
|
error.response.data?.error_msg || '服务器内部错误,请稍后重试',
|
|
error.response.status,
|
|
),
|
|
);
|
|
}
|
|
|
|
/* 网络超时错误 */
|
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
return Promise.reject(new TimeoutError());
|
|
}
|
|
|
|
/* 网络连接错误(ECONNREFUSED, ERR_NETWORK 等) */
|
|
if (
|
|
!error.response ||
|
|
error.code === 'ECONNREFUSED' ||
|
|
error.code === 'ERR_NETWORK' ||
|
|
error.message?.includes('Network Error') ||
|
|
error.message?.includes('Failed to fetch')
|
|
) {
|
|
return Promise.reject(
|
|
new NetworkError('无法连接到服务器,请确认后端服务已启动'),
|
|
);
|
|
}
|
|
|
|
/* 其他后端返回的错误 */
|
|
if (error.response?.data?.error_msg) {
|
|
return Promise.reject(
|
|
new ApiErrorBase(
|
|
error.response.data.error_msg,
|
|
error.response.data.error_code,
|
|
error.response.status,
|
|
error.response.data.details,
|
|
),
|
|
);
|
|
}
|
|
|
|
/* 兜底错误 */
|
|
return Promise.reject(new ApiErrorBase(error.message || '网络请求失败'));
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 取消指定请求
|
|
* @param method - 请求方法
|
|
* @param url - 请求 URL
|
|
*/
|
|
export function cancelRequest(method: string, url: string): void {
|
|
const requestKey = `${method.toUpperCase()}_${url}`;
|
|
const source = cancelTokens.get(requestKey);
|
|
if (source) {
|
|
source.cancel('请求已被手动取消');
|
|
cancelTokens.delete(requestKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 取消所有请求
|
|
*/
|
|
export function cancelAllRequests(): void {
|
|
cancelTokens.forEach((source) => {
|
|
source.cancel('所有请求已被取消');
|
|
});
|
|
cancelTokens.clear();
|
|
}
|
|
|
|
/**
|
|
* 创建带有请求去重功能的请求方法
|
|
* @param method HTTP 方法名
|
|
* @param hasBody 是否包含请求体
|
|
*/
|
|
function createRequestMethod(
|
|
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
|
|
hasBody: boolean,
|
|
) {
|
|
if (hasBody) {
|
|
return <T = ApiResponse>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => {
|
|
const requestKey = getRequestKey({
|
|
method: method.toUpperCase(),
|
|
url,
|
|
data,
|
|
});
|
|
|
|
if (pendingRequests.has(requestKey)) {
|
|
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
|
|
}
|
|
|
|
const promise = apiClient[method]<T>(url, data, config);
|
|
pendingRequests.set(
|
|
requestKey,
|
|
promise as Promise<AxiosResponse<ApiResponse>>,
|
|
);
|
|
|
|
promise.then(
|
|
() => pendingRequests.delete(requestKey),
|
|
() => pendingRequests.delete(requestKey),
|
|
);
|
|
|
|
return promise;
|
|
};
|
|
}
|
|
|
|
return <T = ApiResponse>(
|
|
url: string,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => {
|
|
const requestKey = getRequestKey({
|
|
method: method.toUpperCase(),
|
|
url,
|
|
data: config?.params,
|
|
});
|
|
|
|
if (pendingRequests.has(requestKey)) {
|
|
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
|
|
}
|
|
|
|
const promise = apiClient[method]<T>(url, config);
|
|
pendingRequests.set(
|
|
requestKey,
|
|
promise as Promise<AxiosResponse<ApiResponse>>,
|
|
);
|
|
|
|
promise.then(
|
|
() => pendingRequests.delete(requestKey),
|
|
() => pendingRequests.delete(requestKey),
|
|
);
|
|
|
|
return promise;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 包装的 API 客户端
|
|
* 在原有 axios 实例基础上添加请求缓存功能
|
|
*/
|
|
const wrappedApiClient = {
|
|
get: createRequestMethod('get', false) as <T = ApiResponse>(
|
|
url: string,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => Promise<AxiosResponse<T>>,
|
|
post: createRequestMethod('post', true) as <T = ApiResponse>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => Promise<AxiosResponse<T>>,
|
|
put: createRequestMethod('put', true) as <T = ApiResponse>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => Promise<AxiosResponse<T>>,
|
|
patch: createRequestMethod('patch', true) as <T = ApiResponse>(
|
|
url: string,
|
|
data?: unknown,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => Promise<AxiosResponse<T>>,
|
|
delete: createRequestMethod('delete', false) as <T = ApiResponse>(
|
|
url: string,
|
|
config?: InternalAxiosRequestConfig,
|
|
) => Promise<AxiosResponse<T>>,
|
|
};
|
|
|
|
export default wrappedApiClient;
|