import Taro from '@tarojs/taro'; import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; interface ApiResponse { success: boolean; data?: T; message?: string; error_code?: string; } const ERROR_CODE_MAP: Record = { VALIDATION_ERROR: '输入信息有误,请检查后重试', UNAUTHORIZED: '请先登录', FORBIDDEN: '权限不足', NOT_FOUND: '数据不存在', DUPLICATE: '数据已存在', RATE_LIMITED: '操作过于频繁,请稍后再试', CONCURRENCY_CONFLICT: '数据已被其他人修改,请刷新后重试', }; function safeGet(key: string): string { return secureGet(key); } // --- 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; const HEADERS_CACHE_TTL = 30_000; export function invalidateHeadersCache(): void { headersCacheTs = 0; } function refreshHeadersCache(): void { cachedToken = safeGet('access_token'); cachedTenantId = safeGet('tenant_id'); if (!responseCache.getPatientId()) { responseCache.setPatientId(safeGet('current_patient_id') || ''); } headersCacheTs = Date.now(); } async function getHeaders(): Promise> { if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) { refreshHeadersCache(); } const headers: Record = { 'Content-Type': 'application/json' }; if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`; 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; export function markLoggingOut(): void { isLoggingOut = true; invalidateHeadersCache(); } export function clearLoggingOut(): void { isLoggingOut = false; } async function tryRefreshToken(): Promise { if (isLoggingOut) return false; if (refreshPromise) return refreshPromise; refreshPromise = doRefresh(); refreshPromise.finally(() => { refreshPromise = null; }); return refreshPromise; } // 直接调用 Taro.request() 而非 request(),避免 ConcurrencyLimiter 死锁 async function doRefresh(): Promise { const refreshToken = secureGet('refresh_token'); if (!refreshToken) return false; try { const res = await Taro.request({ url: `${BASE_URL}/auth/refresh`, method: 'POST', data: { refresh_token: refreshToken }, }); if (res.statusCode === 200 && res.data?.success) { secureSet('access_token', res.data.data.access_token); secureSet('refresh_token', res.data.data.refresh_token); if (res.data.data.expires_in) { secureSet('token_expires_at', String(Date.now() + res.data.data.expires_in * 1000)); } invalidateHeadersCache(); return true; } } catch (err) { console.warn('[request] Token 刷新失败:', err); } isLoggingOut = true; secureRemove('access_token'); secureRemove('refresh_token'); secureRemove('user_data'); secureRemove('user_roles'); secureRemove('tenant_id'); secureRemove('wechat_openid'); secureRemove('current_patient'); secureRemove('current_patient_id'); clearRequestCache(); responseCache.setPatientId(''); headersCacheTs = 0; return false; } // --- reLaunch 去重 --- let reLaunchPromise: Promise | null = null; function safeReLaunch(url: string): void { if (reLaunchPromise) return; reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => { console.warn('[request] reLaunch failed:', err); }).then(() => { setTimeout(() => { reLaunchPromise = null; }, 2000); }); } // --- Core request --- async function request(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise { let retryCount401 = 0; for (;;) { if (signal?.aborted) throw new Error('请求已取消'); if (!bypassLimiter) await limiter.acquire(); try { const headers = await getHeaders(); const url = `${BASE_URL}${path}`; let res: Taro.request.SuccessCallbackResult; 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' }); throw new Error('网络超时'); } Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' }); throw new Error('网络异常'); } if (signal?.aborted) throw new Error('请求已取消'); if (res.statusCode === 401) { if (isLoggingOut || retryCount401 >= MAX_401_RETRY) { throw new Error('登录已过期'); } const hasToken = !!safeGet('access_token'); if (hasToken) { const refreshed = await tryRefreshToken(); if (refreshed) { isLoggingOut = false; retryCount401++; continue; } const pages = Taro.getCurrentPages(); const currentPath = pages[pages.length - 1]?.path || ''; if (!currentPath.includes('pages/login')) { safeReLaunch('/pages/login/index'); } } throw new Error('登录已过期'); } if (res.statusCode === 403) { Taro.showToast({ title: '权限不足', icon: 'none' }); throw new Error('权限不足'); } if (res.statusCode >= 500) { Taro.showToast({ title: '服务器繁忙,请稍后重试', icon: 'none' }); throw new Error('服务器错误'); } const body = res.data as ApiResponse & { message?: string }; if (!body.success) { const userMsg = body.error_code ? (ERROR_CODE_MAP[body.error_code] || body.message || '操作失败,请稍后重试') : (body.message || '操作失败,请稍后重试'); throw new Error(userMsg); } return body.data as T; } finally { if (!bypassLimiter) limiter.release(); } } } function buildQuery(params?: Record): string { if (!params) return ''; const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); return entries.length > 0 ? '?' + entries .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) .join('&') : ''; } // --- Public API --- export function setCachedPatientId(id: string): void { responseCache.setPatientId(id); } export function getCachedPatientId(): string { return responseCache.getPatientId(); } export function clearRequestCache(prefix?: string): void { responseCache.clear(prefix); } export const api = { get: (path: string, params?: Record, cacheTtl?: number, signal?: AbortSignal): Promise => { const url = `${path}${buildQuery(params)}`; const cached = responseCache.get(url); if (cached !== null) return Promise.resolve(cached); const inflight = responseCache.getInflight(url); if (inflight) return inflight; const promise = request('GET', url, undefined, undefined, signal).then((data) => { responseCache.removeInflight(url); responseCache.set(url, data, cacheTtl); return data; }).catch((err) => { responseCache.removeInflight(url); throw err; }); responseCache.setInflight(url, promise); return promise; }, 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); } /** 绕过并发限制的请求,用于长轮询等长时间 hang 的请求 */ export async function requestUnlimited(method: string, path: string, data?: unknown, timeout?: number): Promise { return request(method, path, data, timeout, undefined, true); } /** 重置所有模块级状态,用于测试隔离 */ export function resetForTesting(): void { limiter.reset(); responseCache.reset(); cachedToken = ''; cachedTenantId = ''; headersCacheTs = 0; refreshPromise = null; isLoggingOut = false; }