From 6d151bbfb174a65fb8c4c6a531d1d6a84fcff318 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 15 May 2026 06:58:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(mp):=20request.ts=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=BA=A7=E7=8A=B6=E6=80=81=E6=94=B6=E7=BC=96=20+=20AbortSignal?= =?UTF-8?q?=20+=20Analytics=20=E5=8F=97=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取 ConcurrencyLimiter 类(并发限制 8,可 reset) - 提取 ResponseCache 类(GET 缓存 + 去重 + patientId 绑定) - 新增 resetForTesting() 测试隔离函数 - api.get/post/put/delete 支持 AbortSignal 请求取消 - app.tsx Analytics 定时器改为 useDidShow/useDidHide 控制后台暂停 - 测试文件接入 resetForTesting() 构建通过,测试 74/75(1 个预存失败)。 --- .../__tests__/services/request.test.ts | 4 +- apps/miniprogram/src/app.tsx | 38 +++- apps/miniprogram/src/services/request.ts | 214 ++++++++++++------ wiki/miniprogram.md | 4 +- 4 files changed, 175 insertions(+), 85 deletions(-) diff --git a/apps/miniprogram/__tests__/services/request.test.ts b/apps/miniprogram/__tests__/services/request.test.ts index 282ca71..38d17d1 100644 --- a/apps/miniprogram/__tests__/services/request.test.ts +++ b/apps/miniprogram/__tests__/services/request.test.ts @@ -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', () => { diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index 2da8a0f..a38e3cc 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -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>) { return () => { delete (globalThis as any).__hms; }; }, []); + // Analytics 定时器:仅在页面可见时运行,后台时暂停以节省资源 + const analyticsTimerRef = useRef | 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 {children}; diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index b378dcd..1570304 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -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 { + if (this.active < this.max) { + this.active++; + return Promise.resolve(); + } + return new Promise((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(); + private inflight = new Map>(); + 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(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(url: string): Promise | null { + return (this.inflight.get(this.cacheKey(url)) as Promise | undefined) ?? null; + } + + setInflight(url: string, promise: Promise): 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> { if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) { refreshHeadersCache(); } - // Token 刷新已移至 401 重试路径,避免并发请求全部阻塞在 await tryRefreshToken() const headers: Record = { '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 | null = null; let isLoggingOut = false; const MAX_401_RETRY = 1; @@ -113,35 +216,18 @@ async function doRefresh(): Promise { 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 { - if (activeRequests < MAX_CONCURRENT) { - activeRequests++; - return Promise.resolve(); - } - return new Promise((resolve) => { pendingQueue.push(resolve); }); -} - -function releaseSlot(): void { - activeRequests--; - const next = pendingQueue.shift(); - if (next) { activeRequests++; next(); } -} - -async function request(method: string, path: string, data?: unknown, timeout?: number): Promise { +async function request(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal): Promise { 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(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(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(method: string, path: string, data?: unknown, timeout? } return body.data as T; } finally { - releaseSlot(); + limiter.release(); } } } @@ -212,70 +301,55 @@ function buildQuery(params?: Record): strin : ''; } -// --- GET request cache + deduplication --- -interface CacheEntry { data: unknown; expiry: number } -const MAX_CACHE_SIZE = 100; -const responseCache = new Map(); -const inflightRequests = new Map>(); -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: (path: string, params?: Record, cacheTtl?: number): Promise => { + get: (path: string, params?: Record, cacheTtl?: number, signal?: AbortSignal): Promise => { 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(url); + if (cached !== null) return Promise.resolve(cached); - const inflight = inflightRequests.get(cacheKey); - if (inflight) return inflight as Promise; + const inflight = responseCache.getInflight(url); + if (inflight) return inflight; - const promise = request('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('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: (path: string, data?: unknown) => request('POST', path, data), - put: (path: string, data?: unknown) => request('PUT', path, data), - delete: (path: string, data?: unknown) => request('DELETE', path, data), + post: (path: string, data?: unknown, signal?: AbortSignal) => request('POST', path, data, undefined, signal), + put: (path: string, data?: unknown, signal?: AbortSignal) => request('PUT', path, data, undefined, signal), + delete: (path: string, data?: unknown, signal?: AbortSignal) => request('DELETE', path, data, undefined, signal), }; export async function requestWithTimeout(method: string, path: string, data?: unknown, timeout?: number): Promise { return request(method, path, data, timeout); } + +/** 重置所有模块级状态,用于测试隔离 */ +export function resetForTesting(): void { + limiter.reset(); + responseCache.reset(); + cachedToken = ''; + cachedTenantId = ''; + headersCacheTs = 0; + refreshPromise = null; + isLoggingOut = false; +} diff --git a/wiki/miniprogram.md b/wiki/miniprogram.md index 49ebbaf..69d62ec 100644 --- a/wiki/miniprogram.md +++ b/wiki/miniprogram.md @@ -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 |