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;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ Taro 4.2 / React 18 / TypeScript / Zustand 5 / Sass / Zod / ECharts 6(按需
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `apps/miniprogram/config/index.ts` | Taro 构建配置(defineConstants 注入环境变量) |
|
||||
| `apps/miniprogram/src/services/request.ts` | HTTP 请求封装(401 自动刷新、错误处理) |
|
||||
| `apps/miniprogram/src/services/request.ts` | HTTP 请求封装(401 自动刷新、并发限制、缓存、AbortSignal 取消、`resetForTesting()` 测试隔离) |
|
||||
| `apps/miniprogram/src/services/auth.ts` | 微信登录/绑定手机号 API |
|
||||
| `apps/miniprogram/src/stores/auth.ts` | 认证状态(login/bindPhone/restore) |
|
||||
| `apps/miniprogram/src/stores/index.ts` | `resetAllStores()` 统一清理(解耦 store 间依赖) |
|
||||
@@ -906,7 +906,7 @@ node scripts/audit-pages.mjs --role doctor --batch-size 8
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-05-15 | **架构重构:统一页面数据加载 + Store 解耦 + 大页面拆分**:新增 `usePageData` hook(节流+下拉刷新+防重入+条件守卫);44/58 页面迁移接入;新增 `resetAllStores()` 解耦 store 间依赖(auth 不再直接导入 health);提取 `useHomeData`/`useHealthData` 将首页 424→282 行、健康页 422→254 行;构建通过 + 测试 74/75 |
|
||||
| 2026-05-15 | **架构重构 P2:request.ts 模块级状态收编 + AbortSignal + Analytics 受控**:提取 `ConcurrencyLimiter` 类(并发限制)、`ResponseCache` 类(缓存+去重+patientId 绑定);新增 `resetForTesting()` 测试隔离函数;`api.get/post/put/delete` 支持 `AbortSignal` 请求取消;app.tsx Analytics 定时器改为 `useDidShow`/`useDidHide` 控制后台暂停;构建通过 + 测试 74/75 |
|
||||
| 2026-05-15 | **患者端登录后卡死深度审查(3 专家组)**:根因 — 全局并发请求超微信 10 限制排队阻塞;端点可达性验证 33/33 全部存在;Tab 切换请求链路分析(最坏 13 并发);修复 HIGH×3(doRefresh 状态清理 + 401 跳转登录页 + 全局并发限制 MAX_CONCURRENT=8)+ MEDIUM×3(长轮询 generation counter + 首页/健康页 loadingRef 防重入 + refreshToday 去重) |
|
||||
| 2026-05-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1(pollingRef 未定义回归,咨询详情页 loadData 引用已删除的 pollingRef → 闭会话时 ReferenceError 崩溃);HIGH×3 — 401 重试递归占用双 slot 改为循环结构释放后重入、4 个医生端列表页(consultation/alerts/dialysis/prescription)添加 loadingRef 防重入、safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo);新增 `safeNavigateTo` 工具函数(`utils/navigate.ts`) |
|
||||
| 2026-05-15 | **setTimeout 无清理修复**:新增 `useSafeTimeout` hook(页面隐藏时自动 clearTimeout);10 个页面接入 — daily-monitoring(2)、exchange(4)、family-add、health/input、prescription detail/create、dialysis detail/create、appointment detail/create;所有 fire-and-forget 定时器替换为 safeSetTimeout |
|
||||
|
||||
Reference in New Issue
Block a user