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:
iven
2026-05-14 20:22:29 +08:00
parent a8d7183d7c
commit 447126b6c5
10 changed files with 416 additions and 51 deletions

View File

@@ -14,9 +14,6 @@
"ios >= 8"
],
"dependencies": {
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
@@ -25,20 +22,18 @@
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"babel-preset-taro": "^4.2.0",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zod": "^4.3.6",
"zustand": "^5.0.0"
},
"devDependencies": {
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/runtime": "^7.27.0",
"@tarojs/cli": "4.2.0",
"@tarojs/webpack5-runner": "4.2.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^18.3.0",
"babel-preset-taro": "^4.2.0",
"miniprogram-automator": "^0.12.1",
"sass": "^1.87.0",
"typescript": "^5.8.0",

View File

@@ -0,0 +1,15 @@
import { useAuthStore } from '@/stores/auth';
export function useAuthRequired() {
const user = useAuthStore((s) => s.user);
const currentPatient = useAuthStore((s) => s.currentPatient);
const loadPatients = useAuthStore((s) => s.loadPatients);
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
return {
isLoggedIn: !!user,
hasPatient: !!currentPatient,
isMedicalStaff: isMedicalStaff(),
loadPatients,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
export function usePageRefresh(onRefresh: () => Promise<void>) {
usePullDownRefresh(async () => {
try {
await onRefresh();
} finally {
Taro.stopPullDownRefresh();
}
});
const manualRefresh = useCallback(async () => {
Taro.startPullDownRefresh();
try {
await onRefresh();
} finally {
Taro.stopPullDownRefresh();
}
}, [onRefresh]);
return { manualRefresh };
}

View File

@@ -0,0 +1,65 @@
import { useState, useCallback, useRef } from 'react';
interface PaginationResult<T> {
list: T[];
setList: React.Dispatch<React.SetStateAction<T[]>>;
loading: boolean;
hasMore: boolean;
total: number;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
}
export function usePagination<T>(
fetcher: (page: number, pageSize: number) => Promise<{ data: T[]; total: number }>,
pageSize = 10,
): PaginationResult<T> {
const [list, setList] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const pageRef = useRef(1);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
try {
const res = await fetcher(pageRef.current, pageSize);
const items = res.data || [];
setList((prev) => [...prev, ...items]);
setTotal(res.total);
setHasMore(items.length >= pageSize);
pageRef.current += 1;
} catch {
// 错误由调用方处理
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetcher, pageSize, hasMore]);
const refresh = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
pageRef.current = 1;
setHasMore(true);
try {
const res = await fetcher(1, pageSize);
const items = res.data || [];
setList(items);
setTotal(res.total);
setHasMore(items.length >= pageSize);
pageRef.current = 2;
} catch {
// 错误由调用方处理
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetcher, pageSize]);
return { list, setList, loading, hasMore, total, loadMore, refresh };
}

View File

@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
import Loading from '@/components/Loading';
import { sanitizeHtml } from '@/utils/sanitize-html';
import { useElderClass } from '../../../hooks/useElderClass';
import './index.scss';
@@ -13,29 +14,10 @@ const TYPE_LABELS: Record<string, string> = {
report_summary_generation: '报告摘要',
};
/** 移除危险的 HTML 标签和事件属性,防止 XSS */
function sanitizeHtml(html: string): string {
return html
// 移除 <script> 标签及其内容
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// 移除 <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <style> 标签
.replace(/<\/?(?:iframe|object|embed|form|input|textarea|style)\b[^>]*>/gi, '')
// 移除 <link> 和 <meta> 标签
.replace(/<\/?(?:link|meta)\b[^>]*>/gi, '')
// 移除所有 on* 事件属性 (onclick, onerror, onload 等)
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
// 移除 javascript: 和 data: 协议的 href/src 属性
.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '')
.replace(/(href|src)\s*=\s*(?:"data:[^"]*"|'data:[^']*')/gi, '');
}
function markdownToHtml(md: string): string {
// 先转义 markdown 中可能存在的原始 HTML 标签
const escaped = sanitizeHtml(md);
return escaped
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^(#{1,3}) (.+)$/gm, (_, h: string, t: string) => `<h${h.length}>${t}</h${h.length}>`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>')

View File

@@ -3,6 +3,7 @@ import { View, Text, RichText } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics';
import { sanitizeHtml } from '@/utils/sanitize-html';
import { useElderClass } from '../../../hooks/useElderClass';
import { useAuthStore } from '../../../stores/auth';
import './index.scss';
@@ -82,7 +83,7 @@ export default function ArticleDetail() {
{/* 正文 */}
<View className='article-content'>
<RichText
nodes={article.content || ''}
nodes={sanitizeHtml(article.content || '')}
/>
</View>
</View>

View File

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

View File

@@ -2,11 +2,21 @@ import { create } from 'zustand';
import Taro from '@tarojs/taro';
import * as authApi from '@/services/auth';
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
import { clearRequestCache, markLoggingOut, clearLoggingOut } from '@/services/request';
import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
import { useHealthStore } from './health';
// --- 内存缓存,避免每次 Tab 切换重复 Storage IPC + JSON.parse ---
let cachedUserJson = '';
let cachedUserObj: AuthState['user'] = null;
let cachedRolesJson = '';
let cachedRolesObj: string[] = [];
let cachedPatientJson = '';
let cachedPatientObj: authApi.PatientInfo | null = null;
interface BindPhoneResp {
access_token: string;
refresh_token: string;
expires_in?: number;
user: { id: string; username: string; display_name?: string; phone?: string; tenant_id?: string };
}
@@ -68,15 +78,48 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
restore: () => {
let user: AuthState['user'] = null;
let roles: string[] = [];
// 利用内存缓存避免重复 Storage IPC + JSON.parse
try {
const userData = secureGet('user_data') || Taro.getStorageSync('user_data');
if (userData) user = JSON.parse(userData);
const rolesData = secureGet('user_roles') || Taro.getStorageSync('user_roles');
if (rolesData) roles = JSON.parse(rolesData);
const userData = secureGet('user_data') || Taro.getStorageSync('user_data') || '';
if (userData !== cachedUserJson) {
cachedUserJson = userData;
cachedUserObj = userData ? JSON.parse(userData) : null;
}
const rolesData = secureGet('user_roles') || Taro.getStorageSync('user_roles') || '';
if (rolesData !== cachedRolesJson) {
cachedRolesJson = rolesData;
cachedRolesObj = rolesData ? JSON.parse(rolesData) : [];
}
} catch { /* secure storage 不可用时保持默认值 */ }
const currentPatient = Taro.getStorageSync('current_patient') || null;
try {
let patientRaw = Taro.getStorageSync('current_patient');
// 防御双重序列化:如果 Storage 写入了 JSON 字符串而非对象,尝试解析
if (typeof patientRaw === 'string') {
try { patientRaw = JSON.parse(patientRaw); } catch { patientRaw = null; }
}
const patientJson = patientRaw ? JSON.stringify(patientRaw) : '';
if (patientJson !== cachedPatientJson) {
cachedPatientJson = patientJson;
cachedPatientObj = patientRaw || null;
}
} catch { cachedPatientObj = null; }
const user = cachedUserObj;
const roles = cachedRolesObj;
const currentPatient = cachedPatientObj;
// 同步 cachedPatientId 到 request.ts
if (currentPatient?.id) {
setCachedPatientId(currentPatient.id);
}
// 跳过未变更的 set()
const cur = get();
const userChanged = cur.user?.id !== user?.id;
const rolesChanged = cur.roles.length !== roles.length || cur.roles.some((r, i) => r !== roles[i]);
const patientChanged = cur.currentPatient?.id !== currentPatient?.id;
if (!userChanged && !rolesChanged && !patientChanged) return;
set({ user, roles, currentPatient });
},
@@ -92,6 +135,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
: [];
secureSet('access_token', access_token);
secureSet('refresh_token', refresh_token);
if (resp.token.expires_in) {
secureSet('token_expires_at', String(Date.now() + resp.token.expires_in * 1000));
}
secureSet('user_data', JSON.stringify(user));
secureSet('user_roles', JSON.stringify(roles));
secureSet('tenant_id', user.tenant_id || '');
@@ -118,12 +164,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
throw new Error('登录态丢失,请返回重试');
}
const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as Record<string, unknown>;
const tokenData = resp as { access_token: string; refresh_token: string; user: AuthState['user'] };
const tokenData = resp as { access_token: string; refresh_token: string; expires_in?: number; user: AuthState['user'] };
const roles = resp.roles instanceof Array
? (resp.roles as Array<Record<string, string>>).map((r) => r.code || r.name || String(r))
: [];
secureSet('access_token', tokenData.access_token);
secureSet('refresh_token', tokenData.refresh_token);
if (tokenData.expires_in) {
secureSet('token_expires_at', String(Date.now() + tokenData.expires_in * 1000));
}
secureSet('user_data', JSON.stringify(tokenData.user));
secureSet('user_roles', JSON.stringify(roles));
secureSet('tenant_id', tokenData.user?.tenant_id || '');
@@ -141,6 +190,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setCurrentPatient: (patient) => {
Taro.setStorageSync('current_patient_id', patient.id);
Taro.setStorageSync('current_patient', patient);
setCachedPatientId(patient.id);
clearRequestCache();
set({ currentPatient: patient });
},
@@ -159,8 +210,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => {
markLoggingOut();
clearRequestCache();
setCachedPatientId('');
secureRemove('access_token');
secureRemove('refresh_token');
secureRemove('token_expires_at');
secureRemove('user_data');
secureRemove('user_roles');
secureRemove('tenant_id');
@@ -169,6 +222,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
Taro.removeStorageSync('current_patient_id');
Taro.removeStorageSync('analytics_queue');
Taro.removeStorageSync('edit_patient');
useHealthStore.getState().clearCache();
set({ user: null, roles: [], currentPatient: null, patients: [] });
Taro.reLaunch({ url: '/pages/index/index' });
},

View File

@@ -0,0 +1,7 @@
const DANGEROUS_TAG_RE = /<(?:script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>|\/?(?:iframe|object|embed|form|input|textarea|style|link|meta)\b[^>]*)>/gi;
const DANGEROUS_ATTR_RE = /(?:\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)|(?:href|src)\s*=\s*(?:"(?:javascript|data):[^"]*"|'(?:javascript|data):[^']*'))/gi;
export function sanitizeHtml(html: string): string {
if (!html) return '';
return html.replace(DANGEROUS_TAG_RE, '').replace(DANGEROUS_ATTR_RE, '');
}