refactor(mp): request.ts 模块级状态收编 + AbortSignal + Analytics 受控
- 提取 ConcurrencyLimiter 类(并发限制 8,可 reset) - 提取 ResponseCache 类(GET 缓存 + 去重 + patientId 绑定) - 新增 resetForTesting() 测试隔离函数 - api.get/post/put/delete 支持 AbortSignal 请求取消 - app.tsx Analytics 定时器改为 useDidShow/useDidHide 控制后台暂停 - 测试文件接入 resetForTesting() 构建通过,测试 74/75(1 个预存失败)。
This commit is contained in:
@@ -23,12 +23,12 @@ vi.mock('@/utils/secure-storage', () => ({
|
||||
}));
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api, clearRequestCache } from '@/services/request';
|
||||
import { api, clearRequestCache, resetForTesting } from '@/services/request';
|
||||
|
||||
describe('request module', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearRequestCache();
|
||||
resetForTesting();
|
||||
});
|
||||
|
||||
describe('api.get', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, PropsWithChildren } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useEffect, useRef, PropsWithChildren } from 'react';
|
||||
import Taro, { useDidShow, useDidHide } from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { flushEvents } from './services/analytics';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
@@ -28,16 +28,32 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
||||
return () => { delete (globalThis as any).__hms; };
|
||||
}, []);
|
||||
|
||||
// Analytics 定时器:仅在页面可见时运行,后台时暂停以节省资源
|
||||
const analyticsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startAnalyticsTimer = () => {
|
||||
if (analyticsTimerRef.current) return;
|
||||
analyticsTimerRef.current = setInterval(flushEvents, 30000);
|
||||
};
|
||||
|
||||
const stopAnalyticsTimer = () => {
|
||||
if (analyticsTimerRef.current) {
|
||||
clearInterval(analyticsTimerRef.current);
|
||||
analyticsTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useDidShow(() => {
|
||||
startAnalyticsTimer();
|
||||
});
|
||||
|
||||
useDidHide(() => {
|
||||
stopAnalyticsTimer();
|
||||
flushEvents();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
flushEvents();
|
||||
}, 30000);
|
||||
const onHide = () => { flushEvents(); };
|
||||
Taro.eventCenter.on('appHide', onHide);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
Taro.eventCenter.off('appHide', onHide);
|
||||
};
|
||||
return () => { stopAnalyticsTimer(); };
|
||||
}, []);
|
||||
|
||||
return <ErrorBoundary>{children}</ErrorBoundary>;
|
||||
|
||||
@@ -28,7 +28,111 @@ function safeGet(key: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内存缓存 header 字段,避免每次请求 3 次 Storage 读 ---
|
||||
// --- Concurrency limiter ---
|
||||
|
||||
class ConcurrencyLimiter {
|
||||
private active = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private max: number) {}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.active < this.max) {
|
||||
this.active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => { this.queue.push(resolve); });
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.active--;
|
||||
const next = this.queue.shift();
|
||||
if (next) { this.active++; next(); }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.active = 0;
|
||||
this.queue.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const limiter = new ConcurrencyLimiter(8);
|
||||
|
||||
// --- Response cache + deduplication ---
|
||||
|
||||
interface CacheEntry { data: unknown; expiry: number }
|
||||
|
||||
class ResponseCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private inflight = new Map<string, Promise<unknown>>();
|
||||
private patientId = '';
|
||||
|
||||
constructor(private maxSize = 100, private defaultTtl = 60_000) {}
|
||||
|
||||
setPatientId(id: string): void {
|
||||
if (this.patientId !== id) {
|
||||
this.patientId = id;
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
getPatientId(): string { return this.patientId; }
|
||||
|
||||
private cacheKey(url: string): string {
|
||||
return `${url}#${this.patientId}`;
|
||||
}
|
||||
|
||||
get<T>(url: string): T | null {
|
||||
const entry = this.cache.get(this.cacheKey(url));
|
||||
if (entry && Date.now() < entry.expiry) return entry.data as T;
|
||||
return null;
|
||||
}
|
||||
|
||||
getInflight<T>(url: string): Promise<T> | null {
|
||||
return (this.inflight.get(this.cacheKey(url)) as Promise<T> | undefined) ?? null;
|
||||
}
|
||||
|
||||
setInflight(url: string, promise: Promise<unknown>): void {
|
||||
this.inflight.set(this.cacheKey(url), promise);
|
||||
}
|
||||
|
||||
removeInflight(url: string): void {
|
||||
this.inflight.delete(this.cacheKey(url));
|
||||
}
|
||||
|
||||
set(url: string, data: unknown, ttl?: number): void {
|
||||
const key = this.cacheKey(url);
|
||||
const effectiveTtl = ttl ?? this.defaultTtl;
|
||||
if (effectiveTtl <= 0) return;
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const oldest = this.cache.keys().next().value;
|
||||
if (oldest) this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(key, { data, expiry: Date.now() + effectiveTtl });
|
||||
}
|
||||
|
||||
clear(prefix?: string): void {
|
||||
if (prefix) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(prefix)) this.cache.delete(key);
|
||||
}
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
this.inflight.clear();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cache.clear();
|
||||
this.inflight.clear();
|
||||
this.patientId = '';
|
||||
}
|
||||
}
|
||||
|
||||
const responseCache = new ResponseCache();
|
||||
|
||||
// --- Headers cache ---
|
||||
|
||||
let cachedToken = '';
|
||||
let cachedTenantId = '';
|
||||
let headersCacheTs = 0;
|
||||
@@ -41,9 +145,8 @@ export function invalidateHeadersCache(): void {
|
||||
function refreshHeadersCache(): void {
|
||||
cachedToken = safeGet('access_token');
|
||||
cachedTenantId = safeGet('tenant_id');
|
||||
// 首次启动时从 Storage 读取 patientId
|
||||
if (!cachedPatientId) {
|
||||
cachedPatientId = Taro.getStorageSync('current_patient_id') || '';
|
||||
if (!responseCache.getPatientId()) {
|
||||
responseCache.setPatientId(Taro.getStorageSync('current_patient_id') || '');
|
||||
}
|
||||
headersCacheTs = Date.now();
|
||||
}
|
||||
@@ -52,15 +155,15 @@ async function getHeaders(): Promise<Record<string, string>> {
|
||||
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
||||
refreshHeadersCache();
|
||||
}
|
||||
// Token 刷新已移至 401 重试路径,避免并发请求全部阻塞在 await tryRefreshToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`;
|
||||
if (cachedPatientId) headers['X-Patient-Id'] = cachedPatientId;
|
||||
if (responseCache.getPatientId()) headers['X-Patient-Id'] = responseCache.getPatientId();
|
||||
if (cachedTenantId) headers['X-Tenant-Id'] = cachedTenantId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// --- Token refresh deduplication ---
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
let isLoggingOut = false;
|
||||
const MAX_401_RETRY = 1;
|
||||
@@ -113,35 +216,18 @@ async function doRefresh(): Promise<boolean> {
|
||||
Taro.removeStorageSync('current_patient');
|
||||
Taro.removeStorageSync('current_patient_id');
|
||||
clearRequestCache();
|
||||
cachedPatientId = '';
|
||||
responseCache.setPatientId('');
|
||||
headersCacheTs = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Core request ---
|
||||
// 微信小程序并发请求限制为 10 个,超出会排队阻塞
|
||||
const MAX_CONCURRENT = 8;
|
||||
let activeRequests = 0;
|
||||
const pendingQueue: Array<() => void> = [];
|
||||
|
||||
function acquireSlot(): Promise<void> {
|
||||
if (activeRequests < MAX_CONCURRENT) {
|
||||
activeRequests++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => { pendingQueue.push(resolve); });
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
activeRequests--;
|
||||
const next = pendingQueue.shift();
|
||||
if (next) { activeRequests++; next(); }
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
||||
async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal): Promise<T> {
|
||||
let retryCount401 = 0;
|
||||
for (;;) {
|
||||
await acquireSlot();
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
await limiter.acquire();
|
||||
try {
|
||||
const headers = await getHeaders();
|
||||
const url = `${BASE_URL}${path}`;
|
||||
@@ -149,6 +235,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
try {
|
||||
res = await Taro.request({ url, method: method as any, data, header: headers, timeout: timeout || 15000 });
|
||||
} catch (err: any) {
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
const msg = err?.errMsg || '';
|
||||
if (msg.includes('timeout')) {
|
||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||
@@ -158,6 +245,8 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
if (isLoggingOut || retryCount401 >= MAX_401_RETRY) {
|
||||
throw new Error('登录已过期');
|
||||
@@ -196,7 +285,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
return body.data as T;
|
||||
} finally {
|
||||
releaseSlot();
|
||||
limiter.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,70 +301,55 @@ function buildQuery(params?: Record<string, string | number | undefined>): strin
|
||||
: '';
|
||||
}
|
||||
|
||||
// --- GET request cache + deduplication ---
|
||||
interface CacheEntry { data: unknown; expiry: number }
|
||||
const MAX_CACHE_SIZE = 100;
|
||||
const responseCache = new Map<string, CacheEntry>();
|
||||
const inflightRequests = new Map<string, Promise<unknown>>();
|
||||
const DEFAULT_CACHE_TTL = 60_000;
|
||||
let cachedPatientId = '';
|
||||
// --- Public API ---
|
||||
|
||||
export function setCachedPatientId(id: string): void {
|
||||
cachedPatientId = id;
|
||||
}
|
||||
|
||||
function getCacheKey(url: string): string {
|
||||
return `${url}#${cachedPatientId}`;
|
||||
responseCache.setPatientId(id);
|
||||
}
|
||||
|
||||
export function clearRequestCache(prefix?: string): void {
|
||||
if (prefix) {
|
||||
for (const key of responseCache.keys()) {
|
||||
if (key.includes(prefix)) responseCache.delete(key);
|
||||
}
|
||||
} else {
|
||||
responseCache.clear();
|
||||
}
|
||||
responseCache.clear(prefix);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Record<string, string | number | undefined>, cacheTtl?: number): Promise<T> => {
|
||||
get: <T>(path: string, params?: Record<string, string | number | undefined>, cacheTtl?: number, signal?: AbortSignal): Promise<T> => {
|
||||
const url = `${path}${buildQuery(params)}`;
|
||||
const cacheKey = getCacheKey(url);
|
||||
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && Date.now() < cached.expiry) {
|
||||
return Promise.resolve(cached.data as T);
|
||||
}
|
||||
const cached = responseCache.get<T>(url);
|
||||
if (cached !== null) return Promise.resolve(cached);
|
||||
|
||||
const inflight = inflightRequests.get(cacheKey);
|
||||
if (inflight) return inflight as Promise<T>;
|
||||
const inflight = responseCache.getInflight<T>(url);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = request<T>('GET', url).then((data) => {
|
||||
inflightRequests.delete(cacheKey);
|
||||
const ttl = cacheTtl ?? DEFAULT_CACHE_TTL;
|
||||
if (ttl > 0) {
|
||||
if (responseCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = responseCache.keys().next().value;
|
||||
if (oldest) responseCache.delete(oldest);
|
||||
}
|
||||
responseCache.set(cacheKey, { data, expiry: Date.now() + ttl });
|
||||
}
|
||||
const promise = request<T>('GET', url, undefined, undefined, signal).then((data) => {
|
||||
responseCache.removeInflight(url);
|
||||
responseCache.set(url, data, cacheTtl);
|
||||
return data;
|
||||
}).catch((err) => {
|
||||
inflightRequests.delete(cacheKey);
|
||||
responseCache.removeInflight(url);
|
||||
throw err;
|
||||
});
|
||||
|
||||
inflightRequests.set(cacheKey, promise);
|
||||
responseCache.setInflight(url, promise);
|
||||
return promise;
|
||||
},
|
||||
|
||||
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
|
||||
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
|
||||
delete: <T>(path: string, data?: unknown) => request<T>('DELETE', path, data),
|
||||
post: <T>(path: string, data?: unknown, signal?: AbortSignal) => request<T>('POST', path, data, undefined, signal),
|
||||
put: <T>(path: string, data?: unknown, signal?: AbortSignal) => request<T>('PUT', path, data, undefined, signal),
|
||||
delete: <T>(path: string, data?: unknown, signal?: AbortSignal) => request<T>('DELETE', path, data, undefined, signal),
|
||||
};
|
||||
|
||||
export async function requestWithTimeout<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
||||
return request<T>(method, path, data, timeout);
|
||||
}
|
||||
|
||||
/** 重置所有模块级状态,用于测试隔离 */
|
||||
export function resetForTesting(): void {
|
||||
limiter.reset();
|
||||
responseCache.reset();
|
||||
cachedToken = '';
|
||||
cachedTenantId = '';
|
||||
headersCacheTs = 0;
|
||||
refreshPromise = null;
|
||||
isLoggingOut = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user