fix(mp): 安全 P0 修复 + 架构 Hook 层补充 + 五专家组分析报告
安全修复: - 提取 sanitizeHtml 共享工具,修复 article/detail RichText XSS 风险 - request.ts 生产环境强制 HTTPS,消除 HTTP 回退风险 - 错误信息净化:后端错误码映射为用户友好消息,不再透传原始内容 - Token 生命周期管理:利用 expires_in 记录过期时间,请求前主动刷新 工程修复: - Babel 依赖从 dependencies 移至 devDependencies(包体积优化) 架构改进: - 新增 usePagination hook(分页加载 + hasMore + refresh,10+ 页面可复用) - 新增 useAuthRequired hook(登录态 + 患者档案 + 角色判断统一入口) - 新增 usePageRefresh hook(下拉刷新统一封装,17 页面可复用) 文档: - 五专家组深度分析+头脑风暴报告(架构7.2/安全5.5/UX6.0/工程5.5/产品7.2)
This commit is contained in:
@@ -1,14 +1,32 @@
|
||||
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';
|
||||
const BASE_URL = (() => {
|
||||
const url = process.env.TARO_APP_API_URL || '';
|
||||
if (!url) return 'http://localhost:3000/api/v1';
|
||||
if (process.env.NODE_ENV === 'production' && url.startsWith('http://')) {
|
||||
return url.replace('http://', 'https://');
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
|
||||
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 {
|
||||
try {
|
||||
return secureGet(key);
|
||||
@@ -17,23 +35,53 @@ function safeGet(key: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内存缓存 header 字段,避免每次请求 3 次 Storage 读 ---
|
||||
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');
|
||||
// 首次启动时从 Storage 读取 patientId
|
||||
if (!cachedPatientId) {
|
||||
cachedPatientId = Taro.getStorageSync('current_patient_id') || '';
|
||||
}
|
||||
headersCacheTs = Date.now();
|
||||
}
|
||||
|
||||
async function getHeaders(): Promise<Record<string, string>> {
|
||||
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
||||
refreshHeadersCache();
|
||||
}
|
||||
// Token 过期预检查,提前 60 秒主动刷新
|
||||
if (!isLoggingOut) {
|
||||
const expiresAt = parseInt(safeGet('token_expires_at'), 10);
|
||||
if (expiresAt && Date.now() > expiresAt - 60_000) {
|
||||
await tryRefreshToken();
|
||||
refreshHeadersCache();
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = safeGet('access_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const patientId = Taro.getStorageSync('current_patient_id');
|
||||
if (patientId) headers['X-Patient-Id'] = patientId;
|
||||
const tenantId = safeGet('tenant_id');
|
||||
if (tenantId) headers['X-Tenant-Id'] = tenantId;
|
||||
if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`;
|
||||
if (cachedPatientId) headers['X-Patient-Id'] = cachedPatientId;
|
||||
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 {
|
||||
@@ -60,6 +108,10 @@ async function doRefresh(): Promise<boolean> {
|
||||
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 {
|
||||
@@ -75,7 +127,7 @@ async function doRefresh(): Promise<boolean> {
|
||||
}
|
||||
|
||||
// --- Core request ---
|
||||
async function request<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
|
||||
async function request<T>(method: string, path: string, data?: unknown, timeout?: number, _retryCount401 = 0): Promise<T> {
|
||||
const headers = await getHeaders();
|
||||
const url = `${BASE_URL}${path}`;
|
||||
let res: Taro.request.SuccessCallbackResult;
|
||||
@@ -92,10 +144,13 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
|
||||
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) return request<T>(method, path, data);
|
||||
if (refreshed) return request<T>(method, path, data, timeout, _retryCount401 + 1);
|
||||
const pages = Taro.getCurrentPages();
|
||||
const currentPath = pages[pages.length - 1]?.path || '';
|
||||
if (!currentPath.includes('pages/login')) {
|
||||
@@ -116,7 +171,10 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
|
||||
const body = res.data as ApiResponse<T>;
|
||||
if (!body.success) throw new Error(body.message || '请求失败');
|
||||
if (!body.success) {
|
||||
const userMsg = body.error_code ? (ERROR_CODE_MAP[body.error_code] || '操作失败,请稍后重试') : '操作失败,请稍后重试';
|
||||
throw new Error(userMsg);
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
@@ -133,13 +191,18 @@ function buildQuery(params?: Record<string, string | number | undefined>): strin
|
||||
|
||||
// --- GET request cache + deduplication ---
|
||||
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 {
|
||||
cachedPatientId = id;
|
||||
}
|
||||
|
||||
function getCacheKey(url: string): string {
|
||||
const patientId = Taro.getStorageSync('current_patient_id') || '';
|
||||
return `${url}#${patientId}`;
|
||||
return `${url}#${cachedPatientId}`;
|
||||
}
|
||||
|
||||
export function clearRequestCache(prefix?: string): void {
|
||||
@@ -169,6 +232,10 @@ export const api = {
|
||||
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 });
|
||||
}
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user