2026-05-12 21:41:39 +08:00
|
|
|
|
import type { InternalAxiosRequestConfig } from 'axios';
|
|
|
|
|
|
|
|
|
|
|
|
declare module 'axios' {
|
|
|
|
|
|
interface AxiosRequestConfig {
|
|
|
|
|
|
dedupe?: boolean;
|
2026-05-15 13:38:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 跳过 Authorization 注入。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用于公开接口(refresh-token / login / register 等 PermitAll 路径),
|
|
|
|
|
|
* 避免给它们带上过期 access 头被网关拦截。
|
|
|
|
|
|
*/
|
|
|
|
|
|
skipAuth?: boolean;
|
2026-05-18 08:29:51 +08:00
|
|
|
|
/** 请求失败时不走通用错误 toast,由调用方自行收敛提示。 */
|
|
|
|
|
|
suppressErrorMessage?: boolean;
|
|
|
|
|
|
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
|
|
|
|
|
|
skipTokenRefresh?: boolean;
|
2026-05-12 21:41:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
|
|
|
|
|
|
|
|
|
|
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
|
|
|
|
|
|
dedupe?: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function isFormDataLike(value: unknown): boolean {
|
|
|
|
|
|
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
|
|
|
|
|
|
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stableJson(value: unknown): string {
|
|
|
|
|
|
if (value === null || value === undefined) return '';
|
|
|
|
|
|
if (typeof value !== 'object') return JSON.stringify(value);
|
|
|
|
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
|
|
|
|
const obj = value as Record<string, unknown>;
|
|
|
|
|
|
const keys = Object.keys(obj).sort();
|
|
|
|
|
|
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function computeDedupeKey(config: DedupableConfig): string | null {
|
|
|
|
|
|
const method = (config.method ?? 'GET').toUpperCase();
|
|
|
|
|
|
if (!WRITE_METHODS.has(method)) return null;
|
|
|
|
|
|
if (config.dedupe === false) return null;
|
|
|
|
|
|
if (isFormDataLike(config.data)) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const url = config.url ?? '';
|
|
|
|
|
|
const paramsPart = stableJson(config.params);
|
|
|
|
|
|
const bodyPart = stableJson(config.data);
|
|
|
|
|
|
return `${method}|${url}?${paramsPart}|${bodyPart}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_TTL_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
|
|
export interface WithDedupeOptions {
|
|
|
|
|
|
ttlMs?: number;
|
|
|
|
|
|
now?: () => number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
|
|
|
|
|
|
|
|
|
|
|
|
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
|
|
|
|
|
|
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
|
|
|
|
const now = options.now ?? Date.now;
|
|
|
|
|
|
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
|
|
|
|
|
|
|
|
|
|
|
|
return new Proxy(request, {
|
|
|
|
|
|
apply(target, thisArg, args: Parameters<TFn>) {
|
|
|
|
|
|
const [config] = args;
|
|
|
|
|
|
const key = computeDedupeKey(config as DedupableConfig);
|
|
|
|
|
|
if (key === null) return Reflect.apply(target, thisArg, args);
|
|
|
|
|
|
|
|
|
|
|
|
const cached = pending.get(key);
|
|
|
|
|
|
if (cached && cached.expiresAt > now()) return cached.promise;
|
|
|
|
|
|
if (cached) pending.delete(key);
|
|
|
|
|
|
|
|
|
|
|
|
const promise = Promise.resolve()
|
|
|
|
|
|
.then(() => Reflect.apply(target, thisArg, args))
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
const current = pending.get(key);
|
|
|
|
|
|
if (current && current.promise === promise) {
|
|
|
|
|
|
pending.delete(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
pending.set(key, { promise, expiresAt: now() + ttl });
|
|
|
|
|
|
return promise;
|
|
|
|
|
|
}
|
|
|
|
|
|
}) as TFn;
|
|
|
|
|
|
}
|