docs(wiki): 全面重写小程序质量规范清单 — 12类44条规则,覆盖164条git历史

从 164 条小程序 git 提交中全量抽象问题模式:
- 新增 4 类规则:前后端接口契约(字段对齐/API路径/必填字段)、安全(XSS/输入验证/敏感数据/权限)、定时器与副作用清理(setTimeout)、开发环境(DevTools优化)
- 补充已有类别:请求缓存去重(1.4)、统一组件库(3.5-3.6)、认证恢复(5.3-5.4)、对齐原型(7.4-7.5)、ErrorBoundary(9.3)
- 溯源表从 13 条扩至 38 条,每条对应具体修复提交
- 自查脚本从 6 项扩至 10 项
- 统计概览:44 规则 / 66+ fix 提交 / 20 CRITICAL+HIGH
This commit is contained in:
iven
2026-05-17 20:33:25 +08:00
parent 676a6c0e13
commit d26ea64ab2
2 changed files with 399 additions and 138 deletions

View File

@@ -119,7 +119,7 @@
### 患者端
- [[miniprogram]] — **微信小程序** · Taro 4.2 · 微信登录 · 手机绑定 · 健康数据查看
- [[miniprogram-quality-checklist]] — **小程序质量规范清单** · 8 类规则 · 提交前检查 · PR Review 依据
- [[miniprogram-quality-checklist]] — **小程序质量规范清单** · 12 类 44 条规则 · 66+ fix 提交抽象 · 提交前检查/PR Review/里程碑审计
### 基础设施
- [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**)

View File

@@ -1,6 +1,6 @@
# 小程序质量规范清单
> **最后更新:** 2026-05-17 | 来源:两轮深度审计(并发饥饿修复 + 二轮精细审查8 CRITICAL/HIGH + 12 MEDIUM/LOW 问题抽象
> **最后更新:** 2026-05-17 | 来源:164 条 git 提交历史 + 两轮深度审计,全量问题模式抽象
>
> **用途:** 新页面/新模块开发时的自检清单PR Review 的检查依据,新项目启动时的基线规范。每条规则都来自真实 bug 修复,不是理论推导。
@@ -8,14 +8,18 @@
## 目录
1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新
1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新、缓存策略
2. [[#2 导航与路由]] — 页栈保护、reLaunch 去重、分包预加载
3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载
3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载、统一组件库
4. [[#4 数据与内存]] — 数组上限、去重索引一致性、Storage 清理
5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性
6. [[#6 样式与布局]] — CSS 变量主题、长者模式、设计 token
7. [[#7 错误处理与日志]] — 静默吞错、生产日志、用户提示
8. [[#8 审计与交付]] — 提交前检查、Feature DoD
5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性、认证恢复
6. [[#6 前后端接口契约]] — 字段对齐、类型同步、API 路径一致
7. [[#7 样式与布局]] — CSS 变量主题、长者模式、设计 token、对齐原型
8. [[#8 安全]] — XSS 防护、输入验证、敏感数据、权限校验
9. [[#9 错误处理与日志]] — 静默吞错、生产日志、用户提示、ErrorBoundary
10. [[#10 定时器与副作用清理]] — setTimeout/setInterval 清理、useSafeTimeout
11. [[#11 开发环境]] — DevTools 卡死、source map、文件描述符限制
12. [[#12 审计与交付]] — 提交前检查、PR Review、里程碑审计
---
@@ -23,8 +27,9 @@
### 规则 1.1 — 全局并发限制器
> **根因:** 微信小程序 `wx.request` 并发上限为 10超出排队。无限制地发起请求导致请求饥饿。
> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s所有新请求排队等待直到超时。
> **根因:** 微信 `wx.request` 并发上限为 10超出排队。无限制请求导致饥饿。
> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s所有新请求排队超时。
> **提交:** `9d50ef7` `74bffb4`
- [ ] 所有 HTTP 请求必须经过全局 `ConcurrencyLimiter`
- [ ] 并发上限 ≤ 12微信限制 10留 2 个给框架内部请求)
@@ -34,7 +39,6 @@
// ✅ 正确Token 刷新绕过限流器
async function doRefresh(): Promise<boolean> {
const res = await Taro.request({ url: refreshTokenUrl, method: 'POST', ... });
// ...
}
// ❌ 错误Token 刷新走限流器,所有槽位被占时死锁
@@ -45,52 +49,43 @@ async function doRefresh(): Promise<boolean> {
### 规则 1.2 — 长轮询独立通道
> **根因:** 长轮询请求 hang 25-30s占用并发槽位,与普通 API 请求竞争。
> **案例:** 咨询页长轮询 + Tab 切换加载 = 开发者工具卡死。
> **根因:** 长轮询 hang 25-30s 占用并发槽位,与普通 API 竞争。
> **案例:** 咨询页长轮询 + Tab 切换 = 开发者工具卡死。
> **提交:** `9d50ef7` `5baa518`
- [ ] 长轮询必须使用 `requestUnlimited`(绕过限流器的独立通道)
- [ ] 长轮询必须有连续失败上限(默认 10 次),达到后停止轮询
- [ ] 长轮询必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效
- [ ] 必须有连续失败上限(默认 10 次),达到后停止
- [ ] 必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效
- [ ] 成功轮询间隔 ≥ 3 秒(不能 `delay=0` 立即递归,否则 CPU 飙升)
```typescript
// ✅ 正确:独立通道 + generation counter
useLongPolling({
pollFn: () => requestUnlimited('GET', path, undefined, 30000),
onData: (data) => { /* ... */ },
maxFailures: 10,
});
// ❌ 错误:长轮询走限流器
useLongPolling({
pollFn: () => api.get('/messages/poll'), // 占用槽位 30s
});
```
### 规则 1.3 — reLaunch 去重
> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`,微信框架崩溃
> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`。
> **案例:** 3 个请求同时 401 → 3 次 reLaunch → 页面栈混乱。
> **提交:** `9d50ef7`
- [ ] `reLaunch` 必须全局去重Promise 锁 + 2s 冷却期)
- [ ] 401 重试上限为 1 次,避免无限循环
```typescript
// ✅ 正确:去重
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); });
}
```
### 规则 1.4 — 请求缓存与去重
### 规则 1.4 — 请求层错误处理
> **根因:** Tab 切换时同一 API 被多次并发调用,浪费请求且竞态更新。
> **案例:** 首页/健康页每次 useDidShow 都重新请求,快速切换 Tab 触发大量重复。
> **提交:** `6d151bb` `447126b`
- [ ] GET 请求必须有响应缓存TTL 60s同 URL 短时间内命中缓存
- [ ] 同时进行的相同 GET 请求必须去重inflight Promise 共享)
- [ ] 缓存按 `patientId` 隔离,切换患者时自动失效
- [ ] `clearRequestCache()` 在 logout、切换患者时调用
### 规则 1.5 — 请求层错误处理
> **提交:** `fcce2f5` `4ca9027`
- [ ] `catch` 块不允许完全静默(至少 `console.warn`
- [ ] Token 刷新函数必须有死锁风险注释(提醒不要改用 `request()`
- [ ] Token 刷新函数必须有死锁风险注释
- [ ] 网络超时/异常必须有用户友好提示(不显示原始 error message
- [ ] 后端 `error_code` 必须有前端映射表(`ERROR_CODE_MAP`
---
@@ -98,39 +93,35 @@ function safeReLaunch(url: string): void {
### 规则 2.1 — 页栈溢出保护
> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出后报错 `navigateTo:fail`。
> **案例:** 患者列表 → 详情 → 报告 → 化验 → 趋势 → 咨询 → ... → 第 10 层崩溃。
> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出 `navigateTo:fail`。
> **案例:** 患者列表→详情→报告→化验→趋势→咨询→...→第 10 层崩溃。
> **提交:** `59dd5ef` `74bffb4`
- [ ] 所有 `Taro.navigateTo` 必须替换为 `safeNavigateTo`
- [ ] `safeNavigateTo` 实现:页栈 ≥ 9 时自动降级为 `redirectTo`
- [ ] `safeNavigateTo`:页栈 ≥ 9 时自动降级为 `redirectTo`
- [ ] 新代码 review 时全局搜索 `Taro.navigateTo`,不应存在任何直接调用
```typescript
const MAX_PAGE_STACK = 9;
export function safeNavigateTo(url: string): void {
const pages = Taro.getCurrentPages();
if (pages.length >= MAX_PAGE_STACK) {
Taro.redirectTo({ url });
} else {
Taro.navigateTo({ url });
}
}
```
### 规则 2.2 — 分包预加载与拆包策略
### 规则 2.2 — 分包预加载
> **根因:** 主包过大导致首次加载慢;单页分包浪费空间。
> **案例:** consultation 原在主包,移出后主包减重。
> **提交:** `59dd5ef` `4c38fcd`
- [ ] 高频入口页必须配置 `preloadRule`(首页预加载核心分包)
- [ ] 单页分包合并(多个单页合并为一个分包减少开销)
- [ ] 医生端独立分包(`pkg-doctor-core` / `pkg-doctor-clinical`
- [ ] TabBar 页面必须在 `app.config.ts` 正确声明
- [ ] 分包大小控制在微信限制内(主包 2MB总包 20MB
### 规则 2.3 — 页面生命周期防重入
> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化,导致页面数据加载两次
> **案例:** 分包页 navigateTo 后超时,因为双重 API 调用叠加。
> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化。
> **案例:** 分包页 navigateTo 后超时,双重 API 调用叠加。
> **提交:** `1fd2c7a` `59dd5ef`
- [ ] 数据加载统一通过 `usePageData` 单次回调,不额外添加 `useEffect` 初始化
- [ ] 需要跳过首次 mount 的场景用 `mountedRef` 守卫
- [ ] 加载状态用 `loadingRef` 防止并发重复加载(不用 `useState`,避免异步竞态)
- [ ] `usePageData` 支持 `throttleMs` 防抖(默认 5000ms
---
@@ -138,94 +129,107 @@ export function safeNavigateTo(url: string): void {
### 规则 3.1 — 禁止在 render body 内定义组件
> **根因:** 每次父组件 render 都会创建新的组件引用React 认为是新组件,销毁旧实例重建新实例。
> **案例:** `dialysis/create``InputField` 在 render 内定义14 个 Input 每次 render 全部销毁重建,导致输入焦点丢失、键盘闪烁
> **根因:** 每次父组件 render 创建新的组件引用React 销毁旧实例重建新实例。
> **案例:** dialysis/create 的 InputField 在 render 内定义14 个 Input 每次 render 全部销毁重建,输入焦点丢失。
> **提交:** `fcce2f5`
- [ ] 组件必须定义在函数组件外部(模块顶层)
- [ ] 如需访问父组件状态,通过 props 传入value/onChange 回调)
- [ ] Code review 时搜索 `const Xxx = () =>``function Xxx()` 出现在 return 之前、组件函数内部的模式
- [ ] Code review 时搜索 `const Xxx = () =>` 出现在 return 之前、组件函数内部的模式
```typescript
// ✅ 正确:组件定义在模块顶层
// ✅ 正确:组件定义在模块顶层,通过 props 传值
function InputField({ label, value, onChange }: Props) {
return <View>...</View>;
}
export default function MyForm() {
const [val, setVal] = useState('');
return <InputField label="名称" value={val} onChange={setVal} />;
}
// ❌ 错误:组件定义在 render body 内
export default function MyForm() {
const [val, setVal] = useState('');
const InputField = ({ label, field }) => ( // 每次 render 重建!
<View>...</View>
);
return <InputField label="名称" field="name" />;
const InputField = ({ label, field }) => ( <View>...</View> ); // 每次 render 重建!
}
```
### 规则 3.2 — 禁止双重 ScrollView
> **根因:** 外层 PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两个滚动容器冲突。
> **案例:** ai-report/list 页面滚动不流畅,触发双重滚动事件
> **根因:** PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两容器冲突。
> **案例:** ai-report/list 页面滚动不流畅。
> **提交:** `fcce2f5`
- [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}`
- [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动
- [ ] Code review 时检查 `PageShell` 子树中是否包含 `ScrollView`
### 规则 3.3 — 图片懒加载
> **提交:** `fcce2f5`
- [ ] 列表页、聊天消息中的 `<Image>` 必须添加 `lazyLoad` 属性
- [ ] 首屏可见图片不加 `lazyLoad`(避免延迟渲染)
- [ ] 图片 URL 必须使用 HTTPS微信强制要求
### 规则 3.4 — 列表渲染优化
> **提交:** `74bffb4`
- [ ] 长列表使用虚拟滚动或分页加载(`onScrollToLower`
- [ ] 渲染层消息上限(如 `MAX_RENDER_MESSAGES = 200`),超出截断并提示
- [ ] 状态层消息上限(如 `MAX_STATE_MESSAGES = 300`),防止内存增长
### 规则 3.5 — 统一组件库使用
> **根因:** 早期页面各自用原生 View/Text 手写布局60 页面风格不统一。
> **案例:** 迁移了 66 页面到 PageShell + ContentCard + StatusTag + LoadingCard + SearchSection + PaginationBar。
> **提交:** `80794c9` → `900c9ba`12 次迁移提交)
- [ ] 新页面必须使用统一组件库:`PageShell` / `ContentCard` / `StatusTag` / `LoadingCard` / `SearchSection` / `PaginationBar` / `EmptyState` / `ErrorState`
- [ ] 不允许在页面中手写 `.card` / `.list-item` 等重复的卡片样式
- [ ] 需要新组件时先扩展现有组件(添加 prop不要新建
- [ ] 组件必须有独立的 `.scss` 文件,不允许在页面 `.scss` 中覆盖组件内部样式
### 规则 3.6 — React 导入
> **根因:** Taro 4.x 的 JSX transform 不自动注入 React但某些组件需要 React 引用。
> **案例:** 迁移组件库后运行时报 `React is not defined`。
> **提交:** `1786f0d`
- [ ] 使用 JSX 的文件必须显式 `import React from 'react'`(即使 Taro 4.x 有自动 transform
- [ ] 组件使用 `React` API`React.memo``React.useRef`)时必须有导入
- [ ] SCSS 导入路径使用 `./index.scss`(相对当前组件),不使用 `@/components/...` 绝对路径到 scss
---
## 4 数据与内存
### 规则 4.1 — 数组必须有上限
> **根因:** 未设上限的数组在长时间运行或高频数据场景下无限增长。
> **案例:** BLE 设备 readings 数组持续追加,长时间连接后占大量内存。
> **根因:** 未设上限的数组在长时间运行或高频数据场景下无限增长。
> **案例:** BLE 设备 readings 数组持续追加,长时间连接后占大量内存。
> **提交:** `fcce2f5`
- [ ] 所有累积型数组必须设 MAX 常量(如 `MAX_LIVE_READINGS = 200`
- [ ] 追加时检查长度,超出时 `slice(-MAX)` 保留最新数据
- [ ] 该规则适用于:设备数据缓存、聊天消息、日志队列等
```typescript
// ✅ 正确
const MAX_LIVE_READINGS = 200;
const combined = [...this.readings, ...newReadings];
this.readings = combined.length > MAX_LIVE_READINGS
? combined.slice(-MAX_LIVE_READINGS)
: combined;
// ❌ 错误
this.readings = [...this.readings, ...newReadings]; // 无上限!
```
- [ ] 适用于:设备数据缓存、聊天消息、日志队列等
### 规则 4.2 — 去重索引与数据一致
> **根因:** `trimToMax` 丢弃旧数据后,去重索引 `seenKeys` 仍保留已丢弃数据的 key。
> **案例:** DataBuffer 丢弃旧桶后 seenKeys 膨胀,且可能拒绝本应接受的新数据key 碰撞)
> **案例:** DataBuffer seenKeys 膨胀,拒绝本应接受的新数据。
> **提交:** `fcce2f5`
- [ ] 数据裁剪时必须同步重建去重索引
- [ ] `flush()`/`clear()` 时必须清空索引
- [ ] 不要依赖 `Set` 自动清理——它不会自动 shrink
### 规则 4.3 — Storage 清理
### 规则 4.3 — Storage 使用规范
- [ ] `logout` 时必须清理所有业务相关 Storage key
> **根因:** Storage 滥用导致数据残留、切换用户数据泄漏。
> **案例:** 用 Storage 传递页面间数据,导致详情页显示上一个患者的信息。
> **提交:** `0bf1822` `3c828bf`
- [ ] **禁止用 Storage 传递页面间数据** — 必须通过 API 获取或 URL 参数
- [ ] logout 时必须清理所有业务相关 Storage key
- [ ] 带动态 key 的缓存(如 `ble_buffer_{id}_{bucket}`)必须用 `getStorageInfoSync().keys` 遍历清理
- [ ] 模块级缓存变量JS 内存中的 `let` 变量)也必须在 logout 时重置
- [ ] 敏感数据token使用 `secure-storage`(加密),不使用 `Taro.setStorageSync` 明文存储
---
@@ -233,62 +237,188 @@ this.readings = [...this.readings, ...newReadings]; // 无上限!
### 规则 5.1 — 模块级缓存必须可清理
> **根因:** `auth.ts` 顶层的 `cachedUserJson`/`cachedUserObj` 等变量 logout 后不清除,导致 restore() 恢复已登出用户数据。
> **根因:** `auth.ts` 顶层的缓存变量 logout 后不清除restore() 恢复已登出用户数据。
> **案例:** 切换账号后看到上一个用户的头像和名称。
> **提交:** `fcce2f5`
- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置
- [ ] 重置为初始值(`''` / `null` / `[]` / `{}`
- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置为初始值
- [ ] `restore()` 方法必须在开头读取最新缓存,不依赖残留值
```typescript
// logout 中必须包含:
cachedUserJson = '';
cachedUserObj = null;
cachedRolesJson = '';
cachedRolesObj = [];
cachedPatientJson = '';
cachedPatientObj = null;
```
- [ ] restore() 内部做变更检测,无变化时跳过 `set()` 避免不必要的重渲染
### 规则 5.2 — Token 刷新安全
> **根因:** `getHeaders()` 中做同步 Token 刷新预检查,所有请求被阻塞。
> **案例:** getHeaders 中 `await tryRefreshToken()` 导致并发请求排队 30s。
> **提交:** `9d50ef7` `447126b`
- [ ] `getHeaders()` 中不做同步 Token 刷新预检查(仅依赖 401 重试路径)
- [ ] 刷新失败时必须清除所有认证相关 Storage
- [ ] 刷新进行中用 Promise 去重,防止多个并发请求同时触发刷新
- [ ] 刷新失败时设置 `isLoggingOut` 标记,防止后续请求反复尝试刷新
### 规则 5.3 — 页面级认证恢复
> **根因:** Tab 切换回来后 auth store 未恢复,页面显示为未登录状态。
> **案例:** 首页 Tab 正常 → 切到咨询 Tab → 回到首页,用户信息消失。
> **提交:** `6632985` `c314093`
- [ ] 所有 TabBar 页面必须在 `useDidShow` 中调用 `auth.restore()`
- [ ] 子页面依赖 `usePageData` 自动恢复
- [ ] 不在 `useEffect` 中重复恢复(避免双重初始化)
- [ ] 退出登录后刷新必须清除 `isLoggingOut` 标记(`clearLoggingOut()`),否则重新登录后请求被拒
### 规则 5.4 — 角色与权限
> **根因:** 前端未区分医生/护士/患者角色,所有页面入口暴露给所有用户。
> **案例:** 患者能看到医生端入口,点击后 403。
> **提交:** `c38967a` `81c174a` `3dac6a9`
- [ ] 医生端入口必须通过 `isDoctor()` / `isNurse()` 守卫
- [ ] 页面组件使用 `useDoctorClass()` 切换样式模式
- [ ] 精简菜单:未实现的模块入口必须移除,不显示空白页
---
## 6 样式与布局
## 6 前后端接口契约
### 规则 6.1 — CSS 变量主题
### 规则 6.1 — 字段对齐
> **根因:** 前端 TypeScript 接口与后端 DTO 字段名/类型不一致,运行时静默失败。
> **案例:** 33 个接口端到端验证发现 8 个字段不对齐。
> **提交:** `8316281` `c53f562` `c38967a`
- [ ] 新增前端 service 函数时,必须对照后端 DTO 逐字段验证
- [ ] 后端 DTO 变更时,必须同步更新前端 TypeScript 接口
- [ ] 必填字段前端必须有默认值和校验
- [ ] 日期字段统一为 `string``YYYY-MM-DD`),数值字段注意 `number | undefined`
### 规则 6.2 — API 路径一致
> **根因:** 前端硬编码 API 路径,后端路由变更后前端 404。
> **案例:** FHIR 端点从 `/fhir` 迁移到 `/api/v1/fhir`,前端请求 404。
> **提交:** `4ca9027` `8316281`
- [ ] API 路径使用 `process.env.TARO_APP_API_URL` + 相对路径,不硬编码
- [ ] Service 层统一路径前缀,不在组件中直接拼接 URL
- [ ] 后端新增端点必须同步添加前端调用函数,不允许留空
### 规则 6.3 — 必填字段完整性
> **根因:** 前端提交表单缺少后端必填字段,导致 400 错误。
> **案例:** submitRecord 缺少 `task_id` 字段,后端 CreateFollowUpRecordReq 拒绝。
> **提交:** `fbb28e6` `603af83`
- [ ] 表单提交前检查所有后端 Required 字段是否已填
- [ ] 缺少字段时给出具体提示(如"缺少患者信息"),不是笼统的"操作失败"
- [ ] 隐式字段(如 `patient_id``version`)从路由参数或 store 获取,不依赖用户输入
---
## 7 样式与布局
### 规则 7.1 — CSS 变量主题
> **根因:** 硬编码 px 值和颜色号,无法统一调整。
> **案例:** 68 个 SCSS 文件手动 px 值,修改字号需要改 68 个文件。
> **提交:** `890c132` `551d19d`
- [ ] 所有颜色、字号、间距使用 `var(--tk-*)` 设计 token
- [ ] 不允许硬编码颜色值(如 `#333``rgb(0,0,0)`
- [ ] 深色模式通过 CSS 变量级联覆盖,不使用条件 class 切换
- [ ] 新增 design token 在 `tokens.scss` 统一定义,不在页面中 `--custom-var`
### 规则 6.2 — 长者模式
### 规则 7.2 — 长者模式
- [ ] 所有页面字号 ≥ 22px长者模式
- [ ] 使用 `.elder-mode` CSS 变量覆盖,不逐元素调整
> **根因:** 线性放大(所有 px 乘以 1.2)导致布局错乱。
> **案例:** 长者模式字号过大,卡片内容溢出。
> **提交:** `4335f7e` `257ca94`
- [ ] 所有页面字号 ≥ 22px长者模式使用 `.elder-mode` CSS 变量覆盖
- [ ] 非线性放大:标题放大比例 > 正文(如 h1: 1.3x, body: 1.15x
- [ ] 触摸目标 ≥ 44px
- [ ] 长者模式排除特定页面(如登录页,否则键盘遮挡)
### 规则 6.3 — 医生/患者双模式
### 规则 7.3 — 医生/患者双模式
> **提交:** `95e219a`
- [ ] 医生端使用 `.doctor-mode` 靛蓝覆盖
- [ ] 共用页面通过 `useDoctorClass()` / `useElderClass()` 切换
- [ ] 不为同一页面维护两套代码
### 规则 7.4 — 对齐设计原型
> **根因:** 开发时凭感觉写 UI与设计稿差距大。
> **案例:** 文章列表/详情页原型对齐、首页渐变头部移除、Profile 积分卡片等分。
> **提交:** `b8ce19f` `b84becf` `63d8b7a` `7b2c033` `6c42d54` `29d77e8`
- [ ] 新页面开发前先看原型稿,记录关键字号/间距/颜色
- [ ] 完成后截图与原型对比(不是"差不多",是像素级)
- [ ] 组件变体(如 SegmentTabs pill样式必须与原型一致
- [ ] 卡片布局必须用 ContentCard不手写 div 模拟
### 规则 7.5 — 状态色统一
> **根因:** 各页面自行定义状态颜色(绿色/红色/黄色),色调不一致。
> **提交:** `4a95a83`
- [ ] 状态色必须对齐设计系统色板(成功绿/警告黄/危险红/信息蓝)
- [ ] 使用 StatusTag 组件显示状态,不自行拼写 class 名
- [ ] CSS 变量定义一次,全局引用
---
## 7 错误处理与日志
## 8 安全
### 规则 7.1 — 生产日志保留策略
### 规则 8.1 — XSS 防护
> **根因:** AI 报告内容直接用 `dangerouslySetInnerHTML` 渲染,未过滤。
> **案例:** AI 分析结果注入 `<script>` 标签。
> **提交:** `931edc3` `8f35394`
- [ ] 所有用户输入和外部内容AI 生成、富文本)必须经过 `sanitizeHtml`
- [ ] Markdown → HTML 必须经过 sanitize
- [ ] 不使用 `dangerouslySetInnerHTML`,用 Taro `<RichText nodes={...}>` 替代
### 规则 8.2 — 输入验证
> **根因:** 前端不做验证,后端 400 错误不明确。
> **案例:** 患者创建空名称成功后端无校验日期格式不对dayjs 对象未格式化)。
> **提交:** `3424a33` `603af83`
- [ ] 所有表单提交前验证必填字段
- [ ] 日期字段必须格式化为 `YYYY-MM-DD` 字符串(不传 dayjs 对象)
- [ ] 数值字段必须转为 `number`(不传字符串)
- [ ] 后端返回 Validation 错误时,前端展示具体字段提示
### 规则 8.3 — 敏感数据
> **根因:** Token 明文存储在 Storage微信开发者工具可直接查看。
> **提交:** `83fe89c` `447126b`
- [ ] Access token / refresh token 必须使用 `secure-storage`AES-256-GCM 加密)
- [ ] 不在 URL 参数中传递 token
- [ ] 不在 `console.log` 中打印 token
- [ ] 请求头中的 token 通过 `getHeaders()` 统一注入,不在组件中手动拼接
### 规则 8.4 — 权限校验
> **根因:** 前端不做权限校验,依赖后端 403 拦截。
> **案例:** 患者端用了 `health.health-data.list` 权限码,应该是 `health.points.list`。
> **提交:** `3424a33` `c38967a`
- [ ] 医生端接口必须使用医生端 service`services/doctor/`),不用患者端 service
- [ ] 权限码拼写与后端 handler 一致(复数形式如 `alerts.manage` 不是 `alert.manage`
- [ ] 关键操作(删除、关闭会话)必须有二次确认弹窗
---
## 9 错误处理与日志
### 规则 9.1 — 生产日志保留策略
> **根因:** 生产构建移除了所有 console异常时无法排查。
> **提交:** `59dd5ef` `dc98394`
- [ ] 生产构建保留 `console.warn``console.error`
- [ ] 仅移除 `console.log``console.info``console.debug`
@@ -297,19 +427,30 @@ cachedPatientObj = null;
```javascript
// config/prod.ts
pure_funcs: ['console.log', 'console.info', 'console.debug'],
// 不包含 console.warn 和 console.error
```
### 规则 7.2 — 用户友好错误提示
### 规则 9.2 — 用户友好错误提示
> **提交:** `4ca9027` `3424a33`
- [ ] 错误映射表 `ERROR_CODE_MAP` 覆盖所有后端 error_code
- [ ] 不向用户展示原始 error message 或 HTTP 状态码
- [ ] 网络超时、服务器错误、权限不足各有独立提示文案
### 规则 7.3 — Markdown 渲染安全
### 规则 9.3 — ErrorBoundary
> **根因:** 简单的 `replace` 正则无法正确处理 HTML 分组(如连续 `<li>` 应合并到单个 `<ul>`
> **案例:** AI 报告详情页每个列表项独立成列表,显示异常。
> **根因:** 页面 JS 异常导致白屏,无降级 UI
> **提交:** `a63043f`(第一版)`8f35394`(增强版)
- [ ] 全局 `ErrorBoundary` 包裹 App 根组件
- [ ] ErrorBoundary 必须有降级 UI"页面出错了,点击重试"
- [ ] 不在 ErrorBoundary 中吃掉异常(至少 `console.error`
### 规则 9.4 — Markdown 渲染安全
> **根因:** 简单正则无法正确处理 HTML 分组。
> **案例:** AI 报告每个 `<li>` 独立成列表。
> **提交:** `fcce2f5`
- [ ] Markdown → HTML 转换必须正确处理连续同类标签的分组
- [ ] 使用 `sanitizeHtml` 防止 XSS
@@ -317,9 +458,61 @@ pure_funcs: ['console.log', 'console.info', 'console.debug'],
---
## 8 审计与交付
## 10 定时器与副作用清理
### 规则 8.1 — 提交前必检清单
### 规则 10.1 — setTimeout 必须清理
> **根因:** `setTimeout(() => Taro.navigateBack(), 1000)` 在组件卸载后仍执行,操作已不存在的页面。
> **案例:** 10 个页面有此问题。
> **提交:** `fed1759`
- [ ] 所有 `setTimeout` 必须使用 `useSafeTimeout` hook组件卸载自动清理
- [ ] 不允许裸写 `setTimeout`(除非在 try/catch 中且有 clearTimeout
```typescript
// ✅ 正确
const { safeSetTimeout } = useSafeTimeout();
safeSetTimeout(() => Taro.navigateBack(), 1000);
// ❌ 错误
setTimeout(() => Taro.navigateBack(), 1000); // 组件卸载后仍执行!
```
### 规则 10.2 — useEffect 清理
- [ ] `useEffect` 中注册的事件监听、BLE 通知必须在 cleanup 中移除
- [ ] 异步操作必须有 `mountedRef``abort controller` 守卫
- [ ] 长轮询使用 generation counter不需要手动清理 timer
---
## 11 开发环境
### 规则 11.1 — DevTools 性能优化
> **根因:** Vite dev server 默认配置导致开发者工具卡顿。
> **案例:** filesystem 缓存未开启 → 每次编译从零开始source map → EMFILE。
> **提交:** `0f6f7a2` `9c7ce93`
- [ ] `config/dev.ts` 开启 filesystem 缓存(`cacheDir`
- [ ] 真机调试时关闭 source map`sourceMapType: ''`
- [ ] 开启 prebundle 减少 Vite 按需编译开销
- [ ] 真机调试 EMFILE 时检查文件描述符限制
### 规则 11.2 — project.config.json
> **根因:** 自动化端口未开启MCP 无法连接。
> **提交:** `c314093` `8f35394`
- [ ] `automationAudits` 必须为 `true`MCP 自动化测试前提)
- [ ] `urlCheck` 设为 `false`(开发环境不检查合法域名)
- [ ] `compileHotReLoad` 开启加速开发迭代
---
## 12 审计与交付
### 规则 12.1 — 提交前必检清单
每次提交小程序代码前,逐项确认:
@@ -329,19 +522,28 @@ pure_funcs: ['console.log', 'console.info', 'console.debug'],
- [ ] 长轮询使用 `requestUnlimited` 而非 `api.get`
- [ ] 数组累积操作有 MAX 上限
- [ ] `<Image>` 列表使用 `lazyLoad`
- [ ] 新页面使用统一组件库PageShell / ContentCard 等)
- [ ] 前端 service 函数与后端 DTO 字段对齐
- [ ] `setTimeout` 使用 `useSafeTimeout`
- [ ] 表单提交验证必填字段
### 规则 8.2 — PR Review 检查点
### 规则 12.2 — PR Review 检查点
| 检查项 | 搜索模式 | 期望结果 |
|--------|----------|----------|
| 直接导航 | `Taro.navigateTo` | 0 匹配 |
| render body 组件 | `const [A-Z].*=.*(=>|{)` 在 return 前 | 0 匹配 |
| 双重滚动 | `PageShell` 子树含 `ScrollView` | 有 ScrollView 则 PageShell `scroll={false}` |
| 长轮询限流 | `pollFn:.*api\.(get|post)` | 应使用 `requestUnlimited` |
| 无上限数组 | `\.push\(|\.concat\(` 无后续 slice | 应有 MAX 截断 |
| render body 组件 | `const [A-Z].*=.*(=>\|{)` 在 return 前 | 0 匹配 |
| 双重滚动 | `PageShell` 子树含 `ScrollView` | 有 ScrollView 则 `scroll={false}` |
| 长轮询限流 | `pollFn:.*api\.(get\|post)` | 应使用 `requestUnlimited` |
| 无上限数组 | `.push(` 无后续 slice | 应有 MAX 截断 |
| 模块缓存泄漏 | logout 方法不含缓存重置 | 应清空所有模块级变量 |
| 硬编码样式 | `#[0-9a-f]{3,6}``\d+px` | 应使用 `var(--tk-*)` |
| Storage 传数据 | `setStorageSync.*detail` | 应通过 API 获取 |
| 裸 setTimeout | `setTimeout(` 无 useSafeTimeout | 应使用 hook |
| XSS 风险 | `dangerouslySetInnerHTML` | 应使用 `RichText` + sanitize |
| 明文 token | `setStorageSync.*token` | 应使用 `secureSet` |
### 规则 8.3 — 里程碑审计
### 规则 12.3 — 里程碑审计
每个功能里程碑完成后,执行:
@@ -350,28 +552,56 @@ pure_funcs: ['console.log', 'console.info', 'console.debug'],
- [ ] 弱网测试3G 模拟,长轮询不阻塞 UI
- [ ] 内存泄漏检查(长时间停留后页面不卡顿)
- [ ] 登录/登出流程(切换账号后数据正确)
- [ ] 多角色测试(患者/医生/护士/管理员各走一遍核心链路)
- [ ] 长者模式覆盖(所有页面 ≥ 22px触摸目标 ≥ 44px
- [ ] 设计原型对比(截图与原型稿像素级对比)
---
## 附录 A — 问题溯源表
## 附录 A — 问题溯源表(全量)
以下每条规则对应的具体 bug 和修复提交:
以下每条规则对应的具体 bug 和修复提交,按规则编号排序
| 规则 | 问题 | 严重度 | 修复提交 |
|------|------|--------|----------|
| 规则 | 问题 | 严重度 | 首次修复提交 |
|------|------|--------|-------------|
| 1.1 并发限制器 | 长轮询占满槽位导致请求饥饿 | CRITICAL | `9d50ef7` |
| 1.2 长轮询通道 | 咨询页长轮询阻塞所有 API | CRITICAL | `9d50ef7` |
| 1.3 reLaunch 去重 | 401 多次 reLaunch 崩溃 | HIGH | `9d50ef7` |
| 1.4 请求缓存去重 | Tab 切换重复请求 + 竞态更新 | MEDIUM | `6d151bb` |
| 1.5 错误处理 | tryRefreshToken 静默吞异常 | HIGH | `a63043f` |
| 2.1 页栈保护 | 深层导航超 10 层失败 | HIGH | `59dd5ef` |
| 2.3 防重入 | useEffect + usePageData 双重加载 | MEDIUM | `59dd5ef` |
| 3.1 render body 组件 | InputField 每次 render 重建 | HIGH | `fcce2f5` |
| 2.2 分包策略 | consultation 在主包增大体积 | MEDIUM | `4c38fcd` |
| 2.3 防重入 | useEffect + usePageData 双重加载 | MEDIUM | `1fd2c7a` |
| 3.1 render body | InputField 每次 render 重建 | HIGH | `fcce2f5` |
| 3.2 双重 ScrollView | ai-report/list 滚动冲突 | CRITICAL | `fcce2f5` |
| 3.3 图片懒加载 | 咨询详情图片无 lazyLoad | MEDIUM | `fcce2f5` |
| 3.4 列表优化 | 聊天消息无上限 DOM 爆炸 | MEDIUM | `74bffb4` |
| 3.5 统一组件库 | 60 页面各自手写布局 | HIGH | `80794c9` |
| 3.6 React 导入 | 组件库运行时 React is not defined | HIGH | `1786f0d` |
| 4.1 数组上限 | BLE readings 无限增长 | MEDIUM | `fcce2f5` |
| 4.2 索引一致 | DataBuffer seenKeys 不清理 | MEDIUM | `fcce2f5` |
| 4.3 Storage 传数据 | 详情页用 Storage 传递数据 | HIGH | `0bf1822` |
| 5.1 缓存清理 | auth.ts 模块缓存 logout 未清 | MEDIUM | `fcce2f5` |
| 7.1 生产日志 | safeReLaunch 静默吞错 | LOW | `fcce2f5` |
| 7.3 Markdown 分组 | li 元素未合并到 ul | MEDIUM | `fcce2f5` |
| 5.2 Token 安全 | getHeaders 同步刷新阻塞 30s | CRITICAL | `9d50ef7` |
| 5.3 认证恢复 | Tab 切换后 auth store 未恢复 | HIGH | `6632985` |
| 5.4 角色权限 | 患者看到医生端入口 | MEDIUM | `c38967a` |
| 6.1 字段对齐 | 33 接口前端/后端字段不匹配 | HIGH | `8316281` |
| 6.2 API 路径 | FHIR 端点迁移后前端 404 | MEDIUM | `8316281` |
| 6.3 必填字段 | submitRecord 缺 task_id 后端 400 | HIGH | `fbb28e6` |
| 7.1 CSS 变量 | 68 SCSS 硬编码 px | MEDIUM | `890c132` |
| 7.2 长者模式 | 线性放大导致布局错乱 | MEDIUM | `4335f7e` |
| 7.4 对齐原型 | 文章页/首页/Profile 与原型差距大 | HIGH | `63d8b7a` |
| 7.5 状态色 | 各页面状态颜色不一致 | LOW | `4a95a83` |
| 8.1 XSS | AI 报告内容未过滤 | CRITICAL | `931edc3` |
| 8.2 输入验证 | 患者空名称创建成功 | HIGH | `603af83` |
| 8.3 敏感数据 | Token 明文存储 | HIGH | `447126b` |
| 8.4 权限码 | 积分端点权限码错误 403 | HIGH | `3424a33` |
| 9.1 生产日志 | safeReLaunch 静默吞错 | LOW | `fcce2f5` |
| 9.3 ErrorBoundary | 页面异常白屏无降级 | HIGH | `a63043f` |
| 9.4 Markdown | li 元素未合并到 ul | MEDIUM | `fcce2f5` |
| 10.1 setTimeout | 10 页面 setTimeout 无清理 | HIGH | `fed1759` |
| 11.1 DevTools | 开发者工具编译卡顿 | MEDIUM | `0f6f7a2` |
| 11.2 project.config | MCP 自动化端口未开启 | LOW | `c314093` |
## 附录 B — 快速自查脚本
@@ -379,7 +609,7 @@ pure_funcs: ['console.log', 'console.info', 'console.debug'],
# 在 apps/miniprogram/ 目录下运行
echo "=== 1. 直接 Taro.navigateTo ==="
grep -rn "Taro\.navigateTo" src/ | grep -v "node_modules" | grep -v ".d.ts"
grep -rn "Taro\.navigateTo" src/ --include="*.tsx" --include="*.ts"
echo "=== 2. render body 组件定义 ==="
grep -rn "const [A-Z].*=.*=>" src/ --include="*.tsx" | head -20
@@ -406,5 +636,36 @@ grep -rn "<Image" src/ --include="*.tsx" -l | while read f; do
fi
done
echo "=== 7. 硬编码样式 ==="
grep -rn "#[0-9a-fA-F]\{3,6\}" src/ --include="*.scss" | grep -v "var(--" | head -10
grep -rn "[0-9]\+px" src/ --include="*.scss" | grep -v "var(--\|token" | head -10
echo "=== 8. 裸 setTimeout ==="
grep -rn "setTimeout(" src/ --include="*.tsx" | grep -v "useSafeTimeout\|safeSetTimeout\|clearTimeout" | head -10
echo "=== 9. Storage 传数据 ==="
grep -rn "setStorageSync.*detail\|getStorageSync.*detail" src/ --include="*.tsx" --include="*.ts"
echo "=== 10. 明文 token ==="
grep -rn "setStorageSync.*token\|getStorageSync.*token" src/ --include="*.ts" | grep -v "secure"
echo "=== 检查完成 ==="
```
## 附录 C — 统计概览
| 类别 | 规则数 | 对应修复提交数 | 涉及 CRITICAL/HIGH |
|------|--------|---------------|-------------------|
| 1. 并发与请求层 | 5 | 6 | 3 |
| 2. 导航与路由 | 3 | 5 | 2 |
| 3. 组件与渲染 | 6 | 16 | 3 |
| 4. 数据与内存 | 3 | 3 | 1 |
| 5. 认证与会话 | 4 | 7 | 2 |
| 6. 前后端接口契约 | 3 | 4 | 3 |
| 7. 样式与布局 | 5 | 10 | 1 |
| 8. 安全 | 4 | 6 | 3 |
| 9. 错误处理与日志 | 4 | 5 | 1 |
| 10. 定时器与副作用 | 2 | 1 | 1 |
| 11. 开发环境 | 2 | 3 | 0 |
| 12. 审计与交付 | 3 | — | — |
| **合计** | **44** | **66+** | **20** |