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 Taro from '@tarojs/taro';
|
||||||
import { api, clearRequestCache } from '@/services/request';
|
import { api, clearRequestCache, resetForTesting } from '@/services/request';
|
||||||
|
|
||||||
describe('request module', () => {
|
describe('request module', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
clearRequestCache();
|
resetForTesting();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('api.get', () => {
|
describe('api.get', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, PropsWithChildren } from 'react';
|
import { useEffect, useRef, PropsWithChildren } from 'react';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
import Taro, { useDidShow, useDidHide } from '@tarojs/taro';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { flushEvents } from './services/analytics';
|
import { flushEvents } from './services/analytics';
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
@@ -28,16 +28,32 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
|||||||
return () => { delete (globalThis as any).__hms; };
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
return () => { stopAnalyticsTimer(); };
|
||||||
flushEvents();
|
|
||||||
}, 30000);
|
|
||||||
const onHide = () => { flushEvents(); };
|
|
||||||
Taro.eventCenter.on('appHide', onHide);
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
Taro.eventCenter.off('appHide', onHide);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <ErrorBoundary>{children}</ErrorBoundary>;
|
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 cachedToken = '';
|
||||||
let cachedTenantId = '';
|
let cachedTenantId = '';
|
||||||
let headersCacheTs = 0;
|
let headersCacheTs = 0;
|
||||||
@@ -41,9 +145,8 @@ export function invalidateHeadersCache(): void {
|
|||||||
function refreshHeadersCache(): void {
|
function refreshHeadersCache(): void {
|
||||||
cachedToken = safeGet('access_token');
|
cachedToken = safeGet('access_token');
|
||||||
cachedTenantId = safeGet('tenant_id');
|
cachedTenantId = safeGet('tenant_id');
|
||||||
// 首次启动时从 Storage 读取 patientId
|
if (!responseCache.getPatientId()) {
|
||||||
if (!cachedPatientId) {
|
responseCache.setPatientId(Taro.getStorageSync('current_patient_id') || '');
|
||||||
cachedPatientId = Taro.getStorageSync('current_patient_id') || '';
|
|
||||||
}
|
}
|
||||||
headersCacheTs = Date.now();
|
headersCacheTs = Date.now();
|
||||||
}
|
}
|
||||||
@@ -52,15 +155,15 @@ async function getHeaders(): Promise<Record<string, string>> {
|
|||||||
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
||||||
refreshHeadersCache();
|
refreshHeadersCache();
|
||||||
}
|
}
|
||||||
// Token 刷新已移至 401 重试路径,避免并发请求全部阻塞在 await tryRefreshToken()
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`;
|
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;
|
if (cachedTenantId) headers['X-Tenant-Id'] = cachedTenantId;
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Token refresh deduplication ---
|
// --- Token refresh deduplication ---
|
||||||
|
|
||||||
let refreshPromise: Promise<boolean> | null = null;
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
let isLoggingOut = false;
|
let isLoggingOut = false;
|
||||||
const MAX_401_RETRY = 1;
|
const MAX_401_RETRY = 1;
|
||||||
@@ -113,35 +216,18 @@ async function doRefresh(): Promise<boolean> {
|
|||||||
Taro.removeStorageSync('current_patient');
|
Taro.removeStorageSync('current_patient');
|
||||||
Taro.removeStorageSync('current_patient_id');
|
Taro.removeStorageSync('current_patient_id');
|
||||||
clearRequestCache();
|
clearRequestCache();
|
||||||
cachedPatientId = '';
|
responseCache.setPatientId('');
|
||||||
headersCacheTs = 0;
|
headersCacheTs = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Core request ---
|
// --- Core request ---
|
||||||
// 微信小程序并发请求限制为 10 个,超出会排队阻塞
|
|
||||||
const MAX_CONCURRENT = 8;
|
|
||||||
let activeRequests = 0;
|
|
||||||
const pendingQueue: Array<() => void> = [];
|
|
||||||
|
|
||||||
function acquireSlot(): Promise<void> {
|
async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal): Promise<T> {
|
||||||
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> {
|
|
||||||
let retryCount401 = 0;
|
let retryCount401 = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
await acquireSlot();
|
if (signal?.aborted) throw new Error('请求已取消');
|
||||||
|
await limiter.acquire();
|
||||||
try {
|
try {
|
||||||
const headers = await getHeaders();
|
const headers = await getHeaders();
|
||||||
const url = `${BASE_URL}${path}`;
|
const url = `${BASE_URL}${path}`;
|
||||||
@@ -149,6 +235,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
|||||||
try {
|
try {
|
||||||
res = await Taro.request({ url, method: method as any, data, header: headers, timeout: timeout || 15000 });
|
res = await Taro.request({ url, method: method as any, data, header: headers, timeout: timeout || 15000 });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (signal?.aborted) throw new Error('请求已取消');
|
||||||
const msg = err?.errMsg || '';
|
const msg = err?.errMsg || '';
|
||||||
if (msg.includes('timeout')) {
|
if (msg.includes('timeout')) {
|
||||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||||
@@ -158,6 +245,8 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
|||||||
throw new Error('网络异常');
|
throw new Error('网络异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) throw new Error('请求已取消');
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
if (isLoggingOut || retryCount401 >= MAX_401_RETRY) {
|
if (isLoggingOut || retryCount401 >= MAX_401_RETRY) {
|
||||||
throw new Error('登录已过期');
|
throw new Error('登录已过期');
|
||||||
@@ -196,7 +285,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
|||||||
}
|
}
|
||||||
return body.data as T;
|
return body.data as T;
|
||||||
} finally {
|
} finally {
|
||||||
releaseSlot();
|
limiter.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,70 +301,55 @@ function buildQuery(params?: Record<string, string | number | undefined>): strin
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GET request cache + deduplication ---
|
// --- Public API ---
|
||||||
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 = '';
|
|
||||||
|
|
||||||
export function setCachedPatientId(id: string): void {
|
export function setCachedPatientId(id: string): void {
|
||||||
cachedPatientId = id;
|
responseCache.setPatientId(id);
|
||||||
}
|
|
||||||
|
|
||||||
function getCacheKey(url: string): string {
|
|
||||||
return `${url}#${cachedPatientId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearRequestCache(prefix?: string): void {
|
export function clearRequestCache(prefix?: string): void {
|
||||||
if (prefix) {
|
responseCache.clear(prefix);
|
||||||
for (const key of responseCache.keys()) {
|
|
||||||
if (key.includes(prefix)) responseCache.delete(key);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
responseCache.clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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 url = `${path}${buildQuery(params)}`;
|
||||||
const cacheKey = getCacheKey(url);
|
|
||||||
|
|
||||||
const cached = responseCache.get(cacheKey);
|
const cached = responseCache.get<T>(url);
|
||||||
if (cached && Date.now() < cached.expiry) {
|
if (cached !== null) return Promise.resolve(cached);
|
||||||
return Promise.resolve(cached.data as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = inflightRequests.get(cacheKey);
|
const inflight = responseCache.getInflight<T>(url);
|
||||||
if (inflight) return inflight as Promise<T>;
|
if (inflight) return inflight;
|
||||||
|
|
||||||
const promise = request<T>('GET', url).then((data) => {
|
const promise = request<T>('GET', url, undefined, undefined, signal).then((data) => {
|
||||||
inflightRequests.delete(cacheKey);
|
responseCache.removeInflight(url);
|
||||||
const ttl = cacheTtl ?? DEFAULT_CACHE_TTL;
|
responseCache.set(url, data, cacheTtl);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
inflightRequests.delete(cacheKey);
|
responseCache.removeInflight(url);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
inflightRequests.set(cacheKey, promise);
|
responseCache.setInflight(url, promise);
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
|
post: <T>(path: string, data?: unknown, signal?: AbortSignal) => request<T>('POST', path, data, undefined, signal),
|
||||||
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
|
put: <T>(path: string, data?: unknown, signal?: AbortSignal) => request<T>('PUT', path, data, undefined, signal),
|
||||||
delete: <T>(path: string, data?: unknown) => request<T>('DELETE', path, data),
|
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> {
|
export async function requestWithTimeout<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
||||||
return request<T>(method, path, data, timeout);
|
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/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/services/auth.ts` | 微信登录/绑定手机号 API |
|
||||||
| `apps/miniprogram/src/stores/auth.ts` | 认证状态(login/bindPhone/restore) |
|
| `apps/miniprogram/src/stores/auth.ts` | 认证状态(login/bindPhone/restore) |
|
||||||
| `apps/miniprogram/src/stores/index.ts` | `resetAllStores()` 统一清理(解耦 store 间依赖) |
|
| `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 | **患者端登录后卡死深度审查(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 | **全量审计修复(第二轮)**:修复 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 |
|
| 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