Files
hms/apps/miniprogram/src/services/request.ts
iven fd994edf3e fix(mp): 存储层语义统一 + UTF-16 截断修复
- secureGet: 增加 TextEncoder/TextDecoder 替代 charCodeAt 避免 UTF-16 截断
- secureGet: _es_ 前缀键返回空时增加明文键 fallback(对齐 storageGet 语义)
- request.ts safeGet / auth.ts storageGet: 简化为直接委托 secureGet
2026-05-21 22:34:14 +08:00

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