从 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
672 lines
29 KiB
Markdown
672 lines
29 KiB
Markdown
# 小程序质量规范清单
|
||
|
||
> **最后更新:** 2026-05-17 | 来源:164 条 git 提交历史 + 两轮深度审计,全量问题模式抽象
|
||
>
|
||
> **用途:** 新页面/新模块开发时的自检清单,PR Review 的检查依据,新项目启动时的基线规范。每条规则都来自真实 bug 修复,不是理论推导。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新、缓存策略
|
||
2. [[#2 导航与路由]] — 页栈保护、reLaunch 去重、分包预加载
|
||
3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载、统一组件库
|
||
4. [[#4 数据与内存]] — 数组上限、去重索引一致性、Storage 清理
|
||
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、里程碑审计
|
||
|
||
---
|
||
|
||
## 1 并发与请求层
|
||
|
||
### 规则 1.1 — 全局并发限制器
|
||
|
||
> **根因:** 微信 `wx.request` 并发上限为 10,超出排队。无限制发请求导致饥饿。
|
||
> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s,所有新请求排队超时。
|
||
> **提交:** `9d50ef7` `74bffb4`
|
||
|
||
- [ ] 所有 HTTP 请求必须经过全局 `ConcurrencyLimiter`
|
||
- [ ] 并发上限 ≤ 12(微信限制 10,留 2 个给框架内部请求)
|
||
- [ ] Token 刷新必须直接调用 `Taro.request()` 绕过限流器(否则 401 重试时死锁)
|
||
|
||
```typescript
|
||
// ✅ 正确:Token 刷新绕过限流器
|
||
async function doRefresh(): Promise<boolean> {
|
||
const res = await Taro.request({ url: refreshTokenUrl, method: 'POST', ... });
|
||
}
|
||
|
||
// ❌ 错误:Token 刷新走限流器,所有槽位被占时死锁
|
||
async function doRefresh(): Promise<boolean> {
|
||
return api.post('/auth/refresh', { refresh_token }); // 死锁!
|
||
}
|
||
```
|
||
|
||
### 规则 1.2 — 长轮询独立通道
|
||
|
||
> **根因:** 长轮询 hang 25-30s 占用并发槽位,与普通 API 竞争。
|
||
> **案例:** 咨询页长轮询 + Tab 切换 = 开发者工具卡死。
|
||
> **提交:** `9d50ef7` `5baa518`
|
||
|
||
- [ ] 长轮询必须使用 `requestUnlimited`(绕过限流器的独立通道)
|
||
- [ ] 必须有连续失败上限(默认 10 次),达到后停止
|
||
- [ ] 必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效
|
||
- [ ] 成功轮询间隔 ≥ 3 秒(不能 `delay=0` 立即递归,否则 CPU 飙升)
|
||
|
||
### 规则 1.3 — reLaunch 去重
|
||
|
||
> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`。
|
||
> **案例:** 3 个请求同时 401 → 3 次 reLaunch → 页面栈混乱。
|
||
> **提交:** `9d50ef7`
|
||
|
||
- [ ] `reLaunch` 必须全局去重(Promise 锁 + 2s 冷却期)
|
||
- [ ] 401 重试上限为 1 次,避免无限循环
|
||
|
||
### 规则 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 刷新函数必须有死锁风险注释
|
||
- [ ] 网络超时/异常必须有用户友好提示(不显示原始 error message)
|
||
- [ ] 后端 `error_code` 必须有前端映射表(`ERROR_CODE_MAP`)
|
||
|
||
---
|
||
|
||
## 2 导航与路由
|
||
|
||
### 规则 2.1 — 页栈溢出保护
|
||
|
||
> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出 `navigateTo:fail`。
|
||
> **案例:** 患者列表→详情→报告→化验→趋势→咨询→...→第 10 层崩溃。
|
||
> **提交:** `59dd5ef` `74bffb4`
|
||
|
||
- [ ] 所有 `Taro.navigateTo` 必须替换为 `safeNavigateTo`
|
||
- [ ] `safeNavigateTo`:页栈 ≥ 9 时自动降级为 `redirectTo`
|
||
- [ ] 新代码 review 时全局搜索 `Taro.navigateTo`,不应存在任何直接调用
|
||
|
||
### 规则 2.2 — 分包预加载与拆包策略
|
||
|
||
> **根因:** 主包过大导致首次加载慢;单页分包浪费空间。
|
||
> **案例:** consultation 原在主包,移出后主包减重。
|
||
> **提交:** `59dd5ef` `4c38fcd`
|
||
|
||
- [ ] 高频入口页必须配置 `preloadRule`(首页预加载核心分包)
|
||
- [ ] 单页分包合并(多个单页合并为一个分包减少开销)
|
||
- [ ] 医生端独立分包(`pkg-doctor-core` / `pkg-doctor-clinical`)
|
||
- [ ] TabBar 页面必须在 `app.config.ts` 正确声明
|
||
|
||
### 规则 2.3 — 页面生命周期防重入
|
||
|
||
> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化。
|
||
> **案例:** 分包页 navigateTo 后超时,双重 API 调用叠加。
|
||
> **提交:** `1fd2c7a` `59dd5ef`
|
||
|
||
- [ ] 数据加载统一通过 `usePageData` 单次回调,不额外添加 `useEffect` 初始化
|
||
- [ ] 需要跳过首次 mount 的场景用 `mountedRef` 守卫
|
||
- [ ] 加载状态用 `loadingRef` 防止并发重复加载(不用 `useState`,避免异步竞态)
|
||
- [ ] `usePageData` 支持 `throttleMs` 防抖(默认 5000ms)
|
||
|
||
---
|
||
|
||
## 3 组件与渲染
|
||
|
||
### 规则 3.1 — 禁止在 render body 内定义组件
|
||
|
||
> **根因:** 每次父组件 render 创建新的组件引用,React 销毁旧实例重建新实例。
|
||
> **案例:** dialysis/create 的 InputField 在 render 内定义,14 个 Input 每次 render 全部销毁重建,输入焦点丢失。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] 组件必须定义在函数组件外部(模块顶层)
|
||
- [ ] 如需访问父组件状态,通过 props 传入(value/onChange 回调)
|
||
- [ ] Code review 时搜索 `const Xxx = () =>` 出现在 return 之前、组件函数内部的模式
|
||
|
||
```typescript
|
||
// ✅ 正确:组件定义在模块顶层,通过 props 传值
|
||
function InputField({ label, value, onChange }: Props) {
|
||
return <View>...</View>;
|
||
}
|
||
|
||
// ❌ 错误:组件定义在 render body 内
|
||
export default function MyForm() {
|
||
const InputField = ({ label, field }) => ( <View>...</View> ); // 每次 render 重建!
|
||
}
|
||
```
|
||
|
||
### 规则 3.2 — 禁止双重 ScrollView
|
||
|
||
> **根因:** PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两容器冲突。
|
||
> **案例:** ai-report/list 页面滚动不流畅。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}`
|
||
- [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动
|
||
|
||
### 规则 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 数组持续追加,长时间连接后占大量内存。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] 所有累积型数组必须设 MAX 常量(如 `MAX_LIVE_READINGS = 200`)
|
||
- [ ] 追加时检查长度,超出时 `slice(-MAX)` 保留最新数据
|
||
- [ ] 适用于:设备数据缓存、聊天消息、日志队列等
|
||
|
||
### 规则 4.2 — 去重索引与数据一致
|
||
|
||
> **根因:** `trimToMax` 丢弃旧数据后,去重索引 `seenKeys` 仍保留已丢弃数据的 key。
|
||
> **案例:** DataBuffer seenKeys 膨胀,拒绝本应接受的新数据。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] 数据裁剪时必须同步重建去重索引
|
||
- [ ] `flush()`/`clear()` 时必须清空索引
|
||
- [ ] 不要依赖 `Set` 自动清理——它不会自动 shrink
|
||
|
||
### 规则 4.3 — Storage 使用规范
|
||
|
||
> **根因:** 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` 明文存储
|
||
|
||
---
|
||
|
||
## 5 认证与会话
|
||
|
||
### 规则 5.1 — 模块级缓存必须可清理
|
||
|
||
> **根因:** `auth.ts` 顶层的缓存变量 logout 后不清除,restore() 恢复已登出用户数据。
|
||
> **案例:** 切换账号后看到上一个用户的头像和名称。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置为初始值
|
||
- [ ] `restore()` 方法必须在开头读取最新缓存,不依赖残留值
|
||
- [ ] 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.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`
|
||
|
||
### 规则 7.2 — 长者模式
|
||
|
||
> **根因:** 线性放大(所有 px 乘以 1.2)导致布局错乱。
|
||
> **案例:** 长者模式字号过大,卡片内容溢出。
|
||
> **提交:** `4335f7e` `257ca94`
|
||
|
||
- [ ] 所有页面字号 ≥ 22px(长者模式),使用 `.elder-mode` CSS 变量覆盖
|
||
- [ ] 非线性放大:标题放大比例 > 正文(如 h1: 1.3x, body: 1.15x)
|
||
- [ ] 触摸目标 ≥ 44px
|
||
- [ ] 长者模式排除特定页面(如登录页,否则键盘遮挡)
|
||
|
||
### 规则 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 变量定义一次,全局引用
|
||
|
||
---
|
||
|
||
## 8 安全
|
||
|
||
### 规则 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`
|
||
- [ ] 关键路径(请求层、认证层)的异常必须有 `console.warn`
|
||
|
||
```javascript
|
||
// config/prod.ts
|
||
pure_funcs: ['console.log', 'console.info', 'console.debug'],
|
||
```
|
||
|
||
### 规则 9.2 — 用户友好错误提示
|
||
|
||
> **提交:** `4ca9027` `3424a33`
|
||
|
||
- [ ] 错误映射表 `ERROR_CODE_MAP` 覆盖所有后端 error_code
|
||
- [ ] 不向用户展示原始 error message 或 HTTP 状态码
|
||
- [ ] 网络超时、服务器错误、权限不足各有独立提示文案
|
||
|
||
### 规则 9.3 — ErrorBoundary
|
||
|
||
> **根因:** 页面 JS 异常导致白屏,无降级 UI。
|
||
> **提交:** `a63043f`(第一版)`8f35394`(增强版)
|
||
|
||
- [ ] 全局 `ErrorBoundary` 包裹 App 根组件
|
||
- [ ] ErrorBoundary 必须有降级 UI("页面出错了,点击重试")
|
||
- [ ] 不在 ErrorBoundary 中吃掉异常(至少 `console.error`)
|
||
|
||
### 规则 9.4 — Markdown 渲染安全
|
||
|
||
> **根因:** 简单正则无法正确处理 HTML 分组。
|
||
> **案例:** AI 报告每个 `<li>` 独立成列表。
|
||
> **提交:** `fcce2f5`
|
||
|
||
- [ ] Markdown → HTML 转换必须正确处理连续同类标签的分组
|
||
- [ ] 使用 `sanitizeHtml` 防止 XSS
|
||
- [ ] 复杂 Markdown 场景考虑引入成熟库(如 marked + DOMPurify)
|
||
|
||
---
|
||
|
||
## 10 定时器与副作用清理
|
||
|
||
### 规则 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 — 提交前必检清单
|
||
|
||
每次提交小程序代码前,逐项确认:
|
||
|
||
- [ ] `grep -r "Taro.navigateTo" src/` — 无直接调用(全部用 `safeNavigateTo`)
|
||
- [ ] `grep -r "const.*=.*() =>" src/` — render body 内无组件定义
|
||
- [ ] `grep -r "ScrollView" src/` — 无双重 ScrollView 嵌套
|
||
- [ ] 长轮询使用 `requestUnlimited` 而非 `api.get`
|
||
- [ ] 数组累积操作有 MAX 上限
|
||
- [ ] `<Image>` 列表使用 `lazyLoad`
|
||
- [ ] 新页面使用统一组件库(PageShell / ContentCard 等)
|
||
- [ ] 前端 service 函数与后端 DTO 字段对齐
|
||
- [ ] `setTimeout` 使用 `useSafeTimeout`
|
||
- [ ] 表单提交验证必填字段
|
||
|
||
### 规则 12.2 — PR Review 检查点
|
||
|
||
| 检查项 | 搜索模式 | 期望结果 |
|
||
|--------|----------|----------|
|
||
| 直接导航 | `Taro.navigateTo` | 0 匹配 |
|
||
| 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` |
|
||
|
||
### 规则 12.3 — 里程碑审计
|
||
|
||
每个功能里程碑完成后,执行:
|
||
|
||
- [ ] 全页面导航测试(覆盖 10 层页栈场景)
|
||
- [ ] Tab 快速切换测试(连续切换 5 次 Tab,无卡死)
|
||
- [ ] 弱网测试(3G 模拟,长轮询不阻塞 UI)
|
||
- [ ] 内存泄漏检查(长时间停留后页面不卡顿)
|
||
- [ ] 登录/登出流程(切换账号后数据正确)
|
||
- [ ] 多角色测试(患者/医生/护士/管理员各走一遍核心链路)
|
||
- [ ] 长者模式覆盖(所有页面 ≥ 22px,触摸目标 ≥ 44px)
|
||
- [ ] 设计原型对比(截图与原型稿像素级对比)
|
||
|
||
---
|
||
|
||
## 附录 A — 问题溯源表(全量)
|
||
|
||
以下每条规则对应的具体 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.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` |
|
||
| 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 — 快速自查脚本
|
||
|
||
```bash
|
||
# 在 apps/miniprogram/ 目录下运行
|
||
|
||
echo "=== 1. 直接 Taro.navigateTo ==="
|
||
grep -rn "Taro\.navigateTo" src/ --include="*.tsx" --include="*.ts"
|
||
|
||
echo "=== 2. render body 组件定义 ==="
|
||
grep -rn "const [A-Z].*=.*=>" src/ --include="*.tsx" | head -20
|
||
|
||
echo "=== 3. ScrollView 嵌套 ==="
|
||
grep -rn "ScrollView" src/ --include="*.tsx" -l | while read f; do
|
||
if grep -q "PageShell" "$f" && grep -q "ScrollView" "$f"; then
|
||
if ! grep -q "scroll={false}" "$f"; then
|
||
echo "⚠️ $f — PageShell + ScrollView 但无 scroll={false}"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
echo "=== 4. 长轮询未用 requestUnlimited ==="
|
||
grep -rn "pollFn.*api\.\(get\|post\)" src/ --include="*.tsx"
|
||
|
||
echo "=== 5. 无上限数组累积 ==="
|
||
grep -rn "\.push(" src/ --include="*.ts" -A2 | grep -v "slice\|MAX\|limit" | head -20
|
||
|
||
echo "=== 6. Image 无 lazyLoad(列表页)==="
|
||
grep -rn "<Image" src/ --include="*.tsx" -l | while read f; do
|
||
if grep -q "\.map(" "$f" && ! grep -q "lazyLoad" "$f"; then
|
||
echo "⚠️ $f — 列表页 Image 无 lazyLoad"
|
||
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** |
|