- secureGet: 增加 TextEncoder/TextDecoder 替代 charCodeAt 避免 UTF-16 截断 - secureGet: _es_ 前缀键返回空时增加明文键 fallback(对齐 storageGet 语义) - request.ts safeGet / auth.ts storageGet: 简化为直接委托 secureGet
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
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<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
message?: string;
|
|
error_code?: string;
|
|
}
|
|
|
|
const ERROR_CODE_MAP: Record<string, string> = {
|
|
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<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;
|
|
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<Record<string, string>> {
|
|
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
|
refreshHeadersCache();
|
|
}
|
|
const headers: Record<string, string> = { '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<boolean> | 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<boolean> {
|
|
if (isLoggingOut) return false;
|
|
if (refreshPromise) return refreshPromise;
|
|
refreshPromise = doRefresh();
|
|
refreshPromise.finally(() => { refreshPromise = null; });
|
|
return refreshPromise;
|
|
}
|
|
|
|
// 直接调用 Taro.request() 而非 request(),避免 ConcurrencyLimiter 死锁
|
|
async function doRefresh(): Promise<boolean> {
|
|
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<void> | 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<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise<T> {
|
|
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<T> & { 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, string | number | undefined>): 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: <T>(path: string, params?: Record<string, string | number | undefined>, cacheTtl?: number, signal?: AbortSignal): Promise<T> => {
|
|
const url = `${path}${buildQuery(params)}`;
|
|
|
|
const cached = responseCache.get<T>(url);
|
|
if (cached !== null) return Promise.resolve(cached);
|
|
|
|
const inflight = responseCache.getInflight<T>(url);
|
|
if (inflight) return inflight;
|
|
|
|
const promise = request<T>('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: <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);
|
|
}
|
|
|
|
/** 绕过并发限制的请求,用于长轮询等长时间 hang 的请求 */
|
|
export async function requestUnlimited<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
|
return request<T>(method, path, data, timeout, undefined, true);
|
|
}
|
|
|
|
/** 重置所有模块级状态,用于测试隔离 */
|
|
export function resetForTesting(): void {
|
|
limiter.reset();
|
|
responseCache.reset();
|
|
cachedToken = '';
|
|
cachedTenantId = '';
|
|
headersCacheTs = 0;
|
|
refreshPromise = null;
|
|
isLoggingOut = false;
|
|
}
|