- 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码 - 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000 - 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth - 添加 skipAuth 配置选项避免给公开接口带上过期 access 头 - 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示 - 在登录成功后复位会话失效标志以支持下次正常提示 - 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn
91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
import type { InternalAxiosRequestConfig } from 'axios';
|
||
|
||
declare module 'axios' {
|
||
interface AxiosRequestConfig {
|
||
dedupe?: boolean;
|
||
/**
|
||
* 跳过 Authorization 注入。
|
||
*
|
||
* 用于公开接口(refresh-token / login / register 等 PermitAll 路径),
|
||
* 避免给它们带上过期 access 头被网关拦截。
|
||
*/
|
||
skipAuth?: boolean;
|
||
/** 请求失败时不走通用错误 toast,由调用方自行收敛提示。 */
|
||
suppressErrorMessage?: boolean;
|
||
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
|
||
skipTokenRefresh?: boolean;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|