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:
iven
2026-05-15 06:58:37 +08:00
parent 1fd2c7a533
commit 6d151bbfb1
4 changed files with 175 additions and 85 deletions

View File

@@ -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', () => {

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -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 | **架构重构 P2request.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×3doRefresh 状态清理 + 401 跳转登录页 + 全局并发限制 MAX_CONCURRENT=8+ MEDIUM×3长轮询 generation counter + 首页/健康页 loadingRef 防重入 + refreshToday 去重) |
| 2026-05-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1pollingRef 未定义回归,咨询详情页 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页面隐藏时自动 clearTimeout10 个页面接入 — daily-monitoring2、exchange4、family-add、health/input、prescription detail/create、dialysis detail/create、appointment detail/create所有 fire-and-forget 定时器替换为 safeSetTimeout |