chore: 设计规格文档 + 销售数据 + 脚本工具 + 根目录 monorepo 配置

- docs/: 设计规格、讨论记录、销售数据、健康管理文档
- scripts/: 辅助脚本
- package.json + pnpm-lock.yaml: monorepo 根配置
This commit is contained in:
iven
2026-04-28 00:20:37 +08:00
parent 11777e3b68
commit 1265935fa3
20 changed files with 4718 additions and 0 deletions

View File

@@ -0,0 +1,591 @@
# PII 分级加密扩展设计
> 日期: 2026-04-26 | 状态: 草案 | 作者: iven + Claude
## 目录
1. [背景与动机](#1-背景与动机)
2. [设计目标](#2-设计目标)
3. [当前状态分析](#3-当前状态分析)
4. [分级加密策略](#4-分级加密策略)
5. [加密基础设施提升 (erp-core)](#5-加密基础设施提升)
6. [每租户独立密钥管理](#6-每租户独立密钥管理)
7. [Entity 层改造](#7-entity-层改造)
8. [Service 层集成](#8-service-层集成)
9. [数据迁移](#9-数据迁移)
10. [边界场景处理](#10-边界场景处理)
11. [分步实施计划](#11-分步实施计划)
12. [测试策略](#12-测试策略)
13. [风险与缓解](#13-风险与缓解)
---
## 1. 背景与动机
HMS 健康管理平台当前仅加密了 `patient.id_number`(身份证号)一个字段,使用 AES-256-GCM + HMAC-SHA256 搜索索引。其余 30+ 个 PII 字段(患者姓名、电话、过敏史、病史摘要、咨询内容等)均为明文存储。
**驱动力:**
- 体检中心/医疗机构在采购前会进行安全审计,明文 PII 是 dealbreaker
- 《个人信息保护法》对医疗健康信息有特殊保护要求
- 多租户 SaaS 架构下,数据隔离需要从访问控制扩展到存储加密
- 三专家评审一致指出 PII 加密范围不足是高优合规风险
**约束:**
- 系统前期不涉及实际医疗行为PII 加密是合规门槛而非实时临床安全
- 保持可搜索性:患者姓名是高频搜索字段,不能因加密丧失搜索能力
- 性能可接受:加密/解密开销不能显著影响 API 响应时间
---
## 2. 设计目标
| 目标 | 指标 |
|------|------|
| PII 字段加密覆盖 | 从 1 个字段扩展到 15+ 个高风险字段 |
| 多租户密钥隔离 | 每租户独立 DEK泄漏不影响其他租户 |
| 搜索能力保持 | HMAC 精确搜索 + 姓名明文模糊搜索 |
| 性能影响 | 单条解密 < 0.1ms,列表页不触发解密 |
| 零停机迁移 | 现有明文数据逐步加密,不锁表 |
---
## 3. 当前状态分析
### 已有加密能力
| 组件 | 位置 | 状态 |
|------|------|------|
| `HealthCrypto` | `crates/erp-health/src/crypto.rs` | AES-256-GCM + HMAC-SHA256 |
| 身份证号加密 | `patient_service.rs` | 已上线 |
| 脱敏管道 | `crates/erp-health/src/service/masking.rs` | `mask_id_number`, `mask_phone` |
| AI 脱敏层 | `crates/erp-ai/src/sanitization/mod.rs` | DTO 去标识化 |
| 密钥配置 | `crates/erp-server/config/default.toml` | 环境变量注入 |
### 明文高风险字段清单
**患者 (patient) — 8 个未加密 PII 字段:**
- `name` — 姓名(决定不加密,保持搜索性)
- `emergency_contact_phone` — 紧急联系人电话
- `allergy_history` — 过敏史
- `medical_history_summary` — 病史摘要
- `emergency_contact_name` — 紧急联系人姓名
- `birth_date` — 出生日期
- `blood_type` — 血型
- `notes` — 备注
**家庭成员 (patient_family_member) — 2 个:**
- `phone` — 电话
- `name` — 姓名
**咨询消息 (consultation_message) — 1 个:**
- `content` — 咨询/问诊内容
**随访记录 (follow_up_record) — 3 个:**
- `result` — 随访结果
- `patient_condition` — 患者状况
- `medical_advice` — 医嘱
**诊断 (diagnosis) — 1 个:**
- `notes` — 诊断备注
**医生档案 (doctor_profile) — 1 个:**
- `license_number` — 执业证号
**透析记录 (dialysis_record) — 2 个:**
- `symptoms` — 症状 JSON
- `complication_notes` — 并发症记录
**化验报告 (lab_report) — 1 个:**
- `doctor_notes` — 医生备注
---
## 4. 分级加密策略
### Tier 定义
| 级别 | 存储方式 | 搜索方式 | 展示方式 | 适用字段 |
|------|---------|---------|---------|---------|
| **Tier 1** | AES-256-GCM 加密 | HMAC-SHA256 精确匹配 | 解密后展示/脱敏 | 高敏感、可枚举的字段 |
| **Tier 2** | 明文 | 直接 SQL LIKE/模糊 | API 脱敏展示 | 中敏感、高频搜索的字段 |
| **Tier 3** | 明文 | 直接 SQL | 原样展示 | 非敏感或非 PII 字段 |
### Tier 1 — 加密存储字段
| Entity | 字段 | HMAC 搜索 |
|--------|------|----------|
| patient | `emergency_contact_phone` | `emergency_contact_phone_hash` |
| patient | `allergy_history` | — |
| patient | `medical_history_summary` | — |
| patient_family_member | `phone` | `phone_hash` |
| consultation_message | `content` | — |
**咨询内容搜索说明:** `consultation_message.content` 加密后无法做全文搜索。替代方案按咨询会话consultation维度搜索和过滤通过状态、时间、医生等非加密字段进入详情后查看具体消息内容。如未来需要全文搜索可引入加密索引如 Order-Preserving Encryption 或专用密文搜索引擎),但 V1 不实施。
| follow_up_record | `result` | — |
| follow_up_record | `patient_condition` | — |
| follow_up_record | `medical_advice` | — |
| doctor_profile | `license_number` | `license_number_hash` |
| diagnosis | `notes` | — |
| dialysis_record | `complication_notes` | — |
| dialysis_record | `symptoms` (JSON) | — |
| lab_report | `doctor_notes` | — |
**JSON 字段加密说明:** `dialysis_record.symptoms` 类型为 `serde_json::Value`,加密时先序列化为 JSON 字符串再加密,解密后反序列化回 `Value`
**注:** `patient.id_number``id_number_hash` 已存在,无需改动。
### Tier 2 — 明文 + API 脱敏
| Entity | 字段 | 脱敏规则 |
|--------|------|---------|
| patient | `emergency_contact_name` | 保留首字 + `**`(如 `张**`),两字姓名也适用(`张*` |
| patient | `birth_date` | 仅显示年份 |
| patient_family_member | `name` | 保留首字 + `**`,同上 |
### Tier 3 — 明文
`name`, `gender`, `blood_type`, 数值型体征数据, 日期, 状态字段, `icd_code`, `diagnosis_name`, `notes`patient 和 health_record 的备注字段,内容不可预测且非临床核心)等。
**分类澄清:** `notes` 字段在不同 Entity 中分级不同。`patient.notes` 归入 Tier 3一般备注`diagnosis.notes` 归入 Tier 1临床诊断备注`follow_up_record``result`/`patient_condition`/`medical_advice` 归入 Tier 1。具体分级以上面 Tier 1/2/3 表格为准。
**设计决策:患者姓名不加密。** 原因:医疗场景下姓名不是真正意义上的隐私(前台叫号、医生喊名),保持明文可搜索对运营效率至关重要。加密姓名会强制改变搜索交互模式(从"先搜索"变成"先选择"),代价过大。
---
## 5. 加密基础设施提升
### 5.1 从 HealthCrypto 到 PiiCrypto
`crates/erp-health/src/crypto.rs` 中的 `HealthCrypto` 提升到 `crates/erp-core/src/crypto/`
```
crates/erp-core/src/crypto/
├── mod.rs # PiiCrypto 公开接口
├── engine.rs # AES-256-GCM 加密/解密引擎
├── hmac.rs # HMAC-SHA256 搜索索引
├── key_manager.rs # 每租户 DEK 管理
└── masking.rs # 脱敏管道(从 health/service/masking.rs 提升)
```
### 5.2 PiiCrypto 接口设计
```rust
pub struct PiiCrypto {
kek: [u8; 32], // Master KEK
dek_cache: DashMap<Uuid, CachedDek>, // 租户 DEK 缓存
}
struct CachedDek {
dek: [u8; 32],
version: u32,
loaded_at: Instant,
}
impl PiiCrypto {
/// 加密单个字段,返回 Base64(nonce + ciphertext + tag)
pub fn encrypt(&self, dek: &[u8; 32], plaintext: &str) -> Result<String>;
/// 解密单个字段
pub fn decrypt(&self, dek: &[u8; 32], ciphertext: &str) -> Result<String>;
/// 生成 HMAC-SHA256 搜索索引
pub fn hmac_hash(&self, dek: &[u8; 32], value: &str) -> String;
/// 批量解密(避免重复 DEK 加载)
pub fn decrypt_batch(&self, dek: &[u8; 32], ciphertexts: &[String]) -> Result<Vec<String>>;
/// 获取/创建指定租户的 DEK
pub async fn get_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<DekWithVersion>;
/// 轮换指定租户的 DEK
pub async fn rotate_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<()>;
}
```
### 5.3 依赖关系变化
```
Before: erp-health → (内部 crypto.rs)
After: erp-core (crypto/) ← erp-health (调用 PiiCrypto)
← 未来其他模块
```
### 5.4 Cargo.toml 依赖变更
`erp-core/Cargo.toml` 需要新增:
```toml
[dependencies]
aes-gcm = "0.10"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
dashmap = "6"
```
`erp-health/Cargo.toml` 可移除 `aes-gcm`, `hmac`, `sha2`, `base64`, `hex`(已提升到 erp-core改为依赖 `erp-core` 的 crypto 模块。
### 5.5 erp-ai 依赖关系澄清
当前 erp-ai 通过 `HealthDataProvider` trait 获取**已脱敏**的 DTO不含 name/phone/id_number**不需要**直接访问加密数据。PiiCrypto 提升到 erp-core 后erp-ai 不需要调用 PiiCrypto 解密。AI 脱敏层继续作为独立防线运作。
如果未来 AI 需要原始 PII 数据(如精确匹配患者),可通过新增 `HealthDataProviderWithPii` trait 提供,此时才需要 erp-ai 调用 PiiCrypto。
---
## 6. 每租户独立密钥管理
### 6.1 密钥层级
```
Master KEK (Key Encryption Key)
来源: 环境变量 ERP__CRYPTO__KEK
用途: 加密保护所有租户的 DEK
格式: 32 字节 hex-encoded
└── Per-Tenant DEK (Data Encryption Key)
来源: 首次使用时随机生成,用 KEK 加密后存入 DB
用途: 加密该租户的 PII 数据
格式: 32 字节随机
```
### 6.2 数据库表
```sql
CREATE TABLE tenant_crypto_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
encrypted_dek VARCHAR(128) NOT NULL, -- AES-256-GCM 加密的 DEK
key_version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1,
UNIQUE(tenant_id, key_version)
);
-- 项目规范要求所有表包含: id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version
```
### 6.3 DEK 缓存策略
| 参数 | 值 | 原因 |
|------|-----|------|
| 最大缓存数 | 100 个租户 | 典型 SaaS 实例租户数 < 100 |
| TTL | 5 分钟 | 平衡安全性和性能 |
| 淘汰策略 | LRU | 简单有效 |
| 强制刷新 | 密钥轮换后 | 确保新密钥立即生效 |
使用 `DashMap`(并发安全 HashMap实现缓存避免每次请求查询数据库。
### 6.4 密钥轮换流程
1. 管理员调用 `POST /api/v1/admin/tenants/{id}/rotate-key`
2. 生成新 DEK用 KEK 加密,写入 `tenant_crypto_keys`version +1
3. 后台任务启动:
- 读取该租户所有加密数据
- 用旧 DEK 解密 → 用新 DEK 加密
- 更新 `key_version` 字段
- 分批处理(每批 100 条),每批提交一次
4. 轮换期间,读取侧根据每条记录的 `key_version` 字段精确选择对应 DEK不做"尝试解密 + 回退"(避免掩盖数据篡改或密钥管理错误)
5. 全部完成后,标记旧 DEK `is_active = false`
---
## 7. Entity 层改造
### 7.1 新增伴生字段
每个需要 HMAC 搜索的字段,在 SeaORM Entity 中新增 `_hash` 列:
| Entity | 新增字段 | 迁移操作 |
|--------|---------|---------|
| patient | `emergency_contact_phone_hash: Option<String>` | ALTER TABLE ADD COLUMN |
| patient_family_member | `phone_hash: Option<String>` | ALTER TABLE ADD COLUMN |
| doctor_profile | `license_number_hash: Option<String>` | ALTER TABLE ADD COLUMN |
**HMAC hash 列索引:** 每个 `_hash` 列创建普通 B-tree 索引(`CREATE INDEX idx_{table}_{field}_hash ON {table}({field}_hash) WHERE {field}_hash IS NOT NULL`),确保 HMAC 搜索不会退化为全表扫描。
### 7.2 Entity Model 变化示例
```rust
// crates/erp-health/src/entity/patient.rs
// 改造前:
pub struct Model {
pub id_number: Option<String>, // 已加密
pub id_number_hash: Option<String>, // 已有
pub emergency_contact_phone: Option<String>, // 明文
pub allergy_history: Option<String>, // 明文
// ...
}
// 改造后:
pub struct Model {
pub id_number: Option<String>, // 已加密 (不变)
pub id_number_hash: Option<String>, // 已有 (不变)
pub emergency_contact_phone: Option<String>, // 现在存储加密值
pub emergency_contact_phone_hash: Option<String>, // 新增: HMAC 搜索索引
pub allergy_history: Option<String>, // 现在存储加密值
// ...
}
```
**重要:** Entity 字段类型不变(仍为 `Option<String>`),只是存储内容从明文变为密文。这确保 SeaORM 查询层无需改动。
---
## 8. Service 层集成
### 8.1 加密/解密注入点
Service 层在每个 CRUD 操作中透明处理加密:
| 操作 | 加密处理 | 解密处理 |
|------|---------|---------|
| Create | 加密 Tier 1 字段 + 生成 HMAC | — |
| Update | 重新加密变更的 Tier 1 字段 + 更新 HMAC | — |
| Get by ID | — | 解密 Tier 1 字段 |
| List | — | 列表不展示加密字段(返回 None |
| Search | HMAC hash 替代明文搜索条件 | — |
### 8.2 PiiCrypto 注入方式
```rust
// erp-server/src/state.rs
pub struct HealthState {
pub pii_crypto: PiiCrypto, // 替换原来的 HealthCrypto
// ...
}
// patient_service.rs
pub async fn create_patient(
db: &DatabaseConnection,
crypto: &PiiCrypto,
tenant_id: Uuid,
input: CreatePatientRequest,
) -> Result<PatientResponse> {
let dek = crypto.get_dek(tenant_id, db).await?;
// 加密 Tier 1 字段
let encrypted_phone = input.emergency_contact_phone
.map(|p| crypto.encrypt(&dek.key, &p))
.transpose()?;
let phone_hash = input.emergency_contact_phone
.map(|p| crypto.hmac_hash(&dek.key, &p));
// ... 插入数据库
}
```
---
## 9. 数据迁移
### 9.1 迁移策略
采用**渐进式迁移**,不锁表,不中断服务:
1. **部署新代码** — 新代码根据 `key_version` 字段判断:`NULL` = 明文(直接返回),有值 = 密文(解密后返回)。不依赖 Base64 格式检测(自由文本字段本身可能是合法 Base64
2. **后台迁移任务** — 逐条加密现有明文数据
3. **完成切换** — 所有数据加密后,移除明文兼容路径
### 9.2 迁移任务设计
```rust
pub async fn migrate_pii_encryption(
db: &DatabaseConnection,
crypto: &PiiCrypto,
tenant_id: Uuid,
) -> Result<MigrationReport> {
let dek = crypto.get_dek(tenant_id, db).await?;
let batch_size = 100;
loop {
// 读取一批未迁移的记录key_version < current 或 key_version IS NULL
let batch = find_unencrypted_patients(db, tenant_id, batch_size).await?;
if batch.is_empty() { break; }
for patient in &batch {
// 加密明文字段
let encrypted_phone = patient.emergency_contact_phone.as_ref()
.map(|p| crypto.encrypt(&dek.key, p))
.transpose()?;
let phone_hash = patient.emergency_contact_phone.as_ref()
.map(|p| crypto.hmac_hash(&dek.key, p));
// 更新记录
update_patient_encrypted(db, patient.id, encrypted_phone, phone_hash, dek.version).await?;
}
sleep(Duration::from_millis(50)).await; // 避免压垮数据库
}
}
```
### 9.3 Entity 新增 key_version 字段
所有包含 Tier 1 字段的 Entity 新增 `key_version: Option<i32>` 列,用于:
- 跟踪每条记录使用的 DEK 版本
- 支持渐进式迁移和轮换
- NULL 表示未加密(明文)
### 9.4 回滚计划
部署后如果需要回滚到旧代码:
- **新创建的加密数据:** 旧代码无法读取(`key_version` 非空的记录)。回滚前需要运行"解密迁移"脚本,将加密数据还原为明文。
- **未迁移的明文数据:** 旧代码可正常读取。
- **回滚脚本:** 提供与加密迁移对称的解密迁移脚本,从最新 `key_version` 的 DEK 解密所有字段,清除 `key_version`(设为 NULL
- **回滚窗口:** 建议在 Phase A 部署后 24 小时内保持回滚能力,超过 24 小时确认无问题后可移除明文兼容路径。
---
## 10. 边界场景处理
### 10.1 列表页性能
**策略:** 列表页不返回 Tier 1 加密字段(设为 `None`),仅展示 Tier 2/3 字段。
这与当前 `model_to_resp` 的设计一致(列表中 `id_number` 已经返回 `None`),无需改变交互模式。
**前端影响评估:** 当前 `model_to_resp` 已经将 `emergency_contact_phone` 做脱敏处理(`mask_phone`)后返回。加密后,列表页的 `model_to_resp` 需要将该字段设为 `None`(因为不解密),详情页的 `model_to_resp_decrypted` 需要解密后再脱敏。前端列表页如果展示了 `emergency_contact_phone`,需要适配为"详情中查看"。`patient_family_member``doctor_profile` 同理。
### 10.2 AI 分析管道
erp-ai **不直接调用 PiiCrypto**。它通过 `HealthDataProvider` trait在 erp-core 中)获取已脱敏的 DTO不含 name/phone/id_number 等原始 PII。PiiCrypto 对 erp-ai 完全透明——erp-health 在提供数据给 erp-ai 之前已经在 Service 层完成了解密+脱敏。AI 脱敏层(`SanitizationService`)作为二次防护,确保发送给 LLM 的数据不包含原始 PII。
### 10.3 数据导出
导出操作需要独立的权限码(如 `patient.export`Service 层解密后输出。审计日志记录所有导出操作。
### 10.4 小程序端
小程序不直接处理加密数据。所有加密/解密在服务端完成API 响应中的 Tier 2 字段做脱敏处理。
### 10.5 跨租户数据泄漏
即使数据库层面发生泄漏(如 SQL 注入),攻击者没有 KEK 就无法解密 DEK没有 DEK 就无法解密 PII 数据。HMAC hash 字段不可逆,不泄漏原始值。
---
## 11. 分步实施计划
### Phase A — 基础设施 + Patient 实体2-3 天)
**目标:** 加密基础设施落地patient 实体全面加密。
| 步骤 | 内容 | 预估 |
|------|------|------|
| A1 | 提升加密基础设施到 erp-core/src/crypto/ | 0.5 天 |
| A2 | 实现 `tenant_crypto_keys` 表 + DEK 管理 | 0.5 天 |
| A3 | 修改 patient Entity新增 hash 字段 + key_version | 0.5 天 |
| A4 | 修改 patient_service 集成 PiiCrypto | 0.5 天 |
| A5 | 数据迁移脚本patient 表现有数据) | 0.5 天 |
| A6 | 单元测试 + 集成测试 | 0.5 天 |
**验证标准:**
- `cargo check` + `cargo test` 全部通过
- 创建新患者 → 数据库中 phone/allergy 为密文hash 正确
- 查询患者详情 → API 返回解密后的明文
- 搜索电话号码 → 通过 HMAC hash 匹配
### Phase B — 全 Entity 扩展 + 密钥轮换2-3 天)
**目标:** 所有 Tier 1 字段加密,管理端支持密钥轮换。
| 步骤 | 内容 | 预估 |
|------|------|------|
| B1 | consultation_message, follow_up_record 加密 | 0.5 天 |
| B2 | patient_family_member, doctor_profile 加密 | 0.5 天 |
| B3 | dialysis_record, lab_report 加密 | 0.5 天 |
| B4 | 密钥轮换管理端点 + 后台任务 | 0.5 天 |
| B5 | DEK 缓存DashMap LRU | 0.5 天 |
| B6 | 全量集成测试 + 性能基准 | 0.5 天 |
**验证标准:**
- 所有 Tier 1 字段在数据库中为密文
- 所有现有 API 功能正常(透明加密/解密)
- 密钥轮换后数据可正常读写
- 批量解密 50 条记录 < 10ms
---
## 12. 测试策略
### 12.1 单元测试
| 测试类别 | 覆盖内容 | 数量 |
|----------|---------|------|
| 加密引擎 | encrypt/decrypt 正确性、随机 nonce 不可预测 | 5 |
| HMAC 索引 | 相同输入相同输出、不同输入不同输出 | 3 |
| 密钥管理 | DEK 生成/缓存/轮换 | 5 |
| 脱敏管道 | 各脱敏规则正确性 | 4 |
### 12.2 集成测试
| 测试类别 | 覆盖内容 | 数量 |
|----------|---------|------|
| CRUD 加密流 | 创建→加密存储→查询→解密返回 | 8 |
| HMAC 搜索 | 精确匹配搜索正确性 | 3 |
| 多租户隔离 | 租户 A 的 DEK 无法解密租户 B 的数据 | 2 |
| 密钥轮换 | 轮换前后数据可正常访问 | 2 |
### 12.3 性能测试
| 指标 | 基准 | 目标 |
|------|------|------|
| 单字段加密 | — | < 0.05ms |
| 单字段解密 | — | < 0.05ms |
| 50 条批量解密 | — | < 10ms |
| HMAC 生成 | — | < 0.01ms |
---
## 13. 风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| 加密后搜索性能下降 | 低 | 中 | HMAC 索引 + B-tree 索引 |
| 迁移过程中服务中断 | 低 | 高 | 渐进式迁移 + 兼容读取 |
| DEK 缓存一致性 | 中 | 中 | TTL + 轮换后强制刷新 |
| 密钥泄漏 | 低 | 极高 | KEK 不存数据库 + 环境变量注入 + 泄漏恢复流程(见下) |
| 加密字段误漏 | 中 | 中 | 代码审查 + 单元测试覆盖 |
| KEK 泄漏灾难恢复 | 极低 | 极高 | 见 §14 灾难恢复 |
---
## 14. KEK 泄漏灾难恢复
**场景:** Master KEK 泄露(如环境变量配置文件意外提交到 Git
**恢复流程:**
1. **立即轮换 KEK** — 生成新 KEK更新环境变量
2. **重新包装所有 DEK** — 用新 KEK 解密并重新加密每个租户的 DEK
```sql
-- 不需要重新加密 PII 数据,只需重新加密 DEK
UPDATE tenant_crypto_keys SET encrypted_dek = new_kek_encrypt(dek) WHERE is_active = true;
```
3. **清除 DEK 缓存** — 强制所有实例从数据库重新加载
4. **轮换泄漏渠道** — 轮换所有可能暴露 KEK 的凭据Git tokens、CI secrets、部署脚本
5. **审计追溯** — 检查访问日志,确认是否有未授权的数据访问
**关键优势:** KEK 泄漏只需要重新包装 DEKN 次轻量操作),不需要重新加密所有 PII 数据(可能数百万条记录)。这是 KEK/DEK 两层架构的核心价值。
**预防措施:**
- KEK 通过 secrets manager 注入,不写入文件系统
- CI/CD 管道中扫描 KEK 值,防止意外提交
- `.gitignore` 排除 `.env` 文件
---
## 附录:决策记录
| 决策 | 选项 | 选择 | 原因 |
|------|------|------|------|
| 加密范围 | 全面/分级/TDE | 分级 | 成本与收益平衡 |
| 姓名是否加密 | 加密/不加密 | 不加密 | 医疗场景下姓名不属隐私,搜索性更重要 |
| 基础设施位置 | health/core/独立 crate | erp-core | 通用性 + 简洁性平衡 |
| 密钥策略 | 全局/per-tenant/渐进 | 每租户独立 | 安全隔离,泄漏影响面最小 |
| 实施节奏 | 一次完成/分步/最小可用 | 分步 (A+B) | 风险分散,每步可验证 |

View File

@@ -0,0 +1,406 @@
# HMS 平台基座回顾与演进设计
> 日期: 2026-04-26 | 状态: Draft | 方法: 三专家多视角评审
---
## 1. 概述
### 1.1 回顾目的
HMS 健康管理平台经过 17 天密集开发2026-04-10 ~ 2026-04-26从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。本次回顾旨在:
- **验证基座设计** — 星形依赖拓扑、ErpModule trait、事件总线、多租户策略是否经得起实践检验
- **评估演进路径** — 从插件开发模式到原生模块开发的决策是否正确
- **识别缺口与风险** — 通过多专家视角发现盲点
- **制定演进路线** — 基于 P0/P1/P2 优先级指导后续迭代
### 1.2 评审方法
采用三专家独立评审,每个专家从不同视角分析相同的诊断和建议:
| 专家 | 视角 | 关注点 |
|------|------|--------|
| 高级系统架构师 | 架构可持续性 | 模块边界、事件可靠性、技术债 |
| 医疗信息化专家 | 临床安全与合规 | 患者安全、PIPL 合规、领域模型 |
| 产品策略专家 | ROI 与开发节奏 | 优先级、技术债量化、路线图现实性 |
### 1.3 核心结论
**基座设计方向正确,但深度不足。** 星形依赖、trait 抽象、事件总线等基础架构经受住了实践检验。但在临床安全(危急值告警未闭环)、合规(知情同意缺失)、事件可靠性(无重放机制)方面存在需立即修复的缺口。插件系统已验证可行性但对 HMS 核心业务贡献有限,建议有条件冻结。
---
## 2. 基座设计验证
### 2.1 评分总览
| 维度 | 评分 | 说明 |
|------|------|------|
| 模块边界 | ★★★★ | 星形拓扑零循环依赖trait 契约清晰 |
| ErpModule trait | ★★★★ | 生命周期/权限/事件/健康检查统一接口 |
| 事件总线 | ★★★☆ | 基础设施扎实broadcast+outbox但无重放机制消费侧不完整 |
| 多租户 | ★★★☆ | JWT→TenantContext 全链路贯通,但缺 RLS 兜底和集成测试 |
| 权限体系 | ★★★★ | RBAC + 行级数据权限 + 按钮级控制 |
| 插件系统 | ★★★☆ | CRUD 场景验证通过,医疗场景天花板明显 |
| API 一致性 | ★★★★ | 统一 envelope、分页、OpenAPI 自动文档 |
| 数据库迁移 | ★★★★ | 59 个迁移幂等、可回滚、fixup 模式健康 |
| 测试覆盖 | ★☆☆☆ | 36 后端 + 3 前端,覆盖率 < 5% |
| 合规性 | ★☆☆☆ | 知情同意缺失审计不完整PIE 加密范围不足 |
### 2.2 星形依赖拓扑
```
erp-core (L1)
/ | \ \ \ \
erp-auth workflow message config erp-health erp-plugin erp-ai
\ | / / / / /
erp-server (L3, 组装入口)
```
- `erp-core`:零业务依赖,纯净基础层
- 7 个业务 crate各只依赖 `erp-core`,兄弟间无横向依赖
- `erp-server`:唯一组装点,负责路由合并和模块初始化
- **无循环依赖** — 架构师验证通过
### 2.3 ErpModule trait
当前 trait 提供统一的模块接口:
- **身份**`name()` / `id()` / `version()`
- **依赖声明**`dependencies()` — 用于拓扑排序启动顺序
- **生命周期**`on_startup()` / `on_shutdown()` / `health_check()`
- **多租户**`on_tenant_created()` / `on_tenant_deleted()`
- **权限自描述**`permissions()` — 模块声明自己需要的权限码
- **事件订阅**`register_event_handlers()` / `as_any()`
**已知张力**:路由注册不在 trait 中,而是通过各模块的 inherent method (`public_routes()` / `protected_routes()`) 手动在 `main.rs` 中合并。原因是 Axum 的 `Router<S>` 泛型约束不适合 trait object。这是务实的妥协但在添加新模块时有 boilerplate 成本。
### 2.4 事件总线
**实现机制**`tokio::sync::broadcast` (容量 1024) + `domain_events` 表持久化best-effort+ Outbox relay (5秒轮询3次重试)
**发布侧**(已识别的事件类型):
| 模块 | 事件类型数 | 示例 |
|------|-----------|------|
| erp-auth | 10 | `user.login`, `user.created`, `role.created` |
| erp-workflow | 4 | `process_instance.started`, `task.completed` |
| erp-message | 1 | `message.sent` |
| erp-health | 13 | `patient.created`, `health_data.critical_alert`, `follow_up.overdue` |
| erp-plugin | 2+ | `plugin.config.updated`, `plugin.trigger.*` |
**消费侧**(已识别的订阅者):
| 订阅者 | 订阅方式 | 处理的事件 |
|--------|---------|-----------|
| erp-message | `subscribe()` 全量 | `appointment.*`, `process_instance.*`, `task.*` |
| erp-health | `register_handlers_with_state` | `workflow.task.completed` |
| erp-plugin 通知 | `subscribe_filtered("plugin.trigger.*")` | 插件触发通知 |
| outbox relay | 轮询 DB | 重发 pending 事件 |
**已识别缺陷**
1. **无重放机制** — 内存 broadcast服务重启后未消费的事件丢失
2. **无幂等保护**`follow_up.overdue` 每 6 小时检查会重复发布同一条逾期事件
3. **全量订阅** — erp-message 使用 `subscribe()` 而非 `subscribe_filtered()`,所有事件都经过消息模块
### 2.5 多租户
**已实现**
- JWT claims 提取 `tenant_id``TenantContext` 注入请求扩展
- 所有 Entity 含 `tenant_id` 字段BaseFields 统一
- 所有 DomainEvent 携带 `tenant_id`
- `on_tenant_created()` / `on_tenant_deleted()` 钩子auth 和 health 已实现)
- 部门级数据范围(`department_ids` 在 TenantContext 中)
**缺失**
- 无 PostgreSQL RLS policy 作为兜底层
- 无强制 tenant_id 过滤的查询层机制 — 依赖每个 service 手动 `.filter()`
- 当前实际只有 default_tenant微信登录硬编码使用 `default_tenant_id`
- 无多租户管理 API创建/配置/迁移)
---
## 3. 演进路径回顾
### 3.1 时间线
```
4/10-4/16 基座搭建 (Phase 1-6)
→ core → auth → config → workflow → message
→ 全部原生 Rust 模块30+ 数据库表
4/13-4/18 WASM 插件实验
→ 插件系统设计与实现 (Wasmtime + WIT bindgen)
→ CRM (5实体) → Inventory (6实体) → Freelance → ITOps
→ 证明CRUD 密集型领域可行,沙盒隔离有效
→ 跨插件数据引用未解决
4/23-4/26 HMS 分叉 — 健康模块原生开发
→ 18+ 强类型实体 (患者/家属/医生/预约/排班/随访/咨询/体征/化验/透析/诊断/积分...)
→ PII 加密 (AES-256-GCM)、脱敏管道
→ AI 模块 (4 SSE 流式端点 + 3 REST 端点)
→ 微信小程序 (27 页面)
→ 按钮级权限控制
```
### 3.2 从插件到原生的决策链
**原始插件愿景**(设计规格 2026-04-13
- 平台模块原生,行业模块 WASM 插件
- 插件通过 9 个 Host API 函数通信db_insert/query/update/delete、event_publish、config_get 等)
- 数据存 JSONB 动态表,路由自动生成
- UI 配置驱动,通用 PluginCRUDPage 组件
**健康模块原生的 5 个硬限制**(设计规格 2026-04-23 §1.3
| 限制 | 影响 | 不可妥协原因 |
|------|------|-------------|
| 20 实体上限 | 健康平台轻松超过 | 18+ 实体已是最低合理粒度 |
| JSONB 存储 | 无强类型、无外键约束 | 医疗数据需要引用完整性和精确索引 |
| 无自定义 API | 只有自动 CRUD | 趋势分析/统计报表/日历视图无法实现 |
| 无文件上传 | 沙盒阻止文件系统访问 | 化验单/体检报告需要文件存储 |
| WASM 沙盒限制 | 无 native crypto/外部 API/后台任务 | PII 加密、微信集成、定时任务全部需要 |
### 3.3 得失评估
**得 — 正确的决策:**
| 决策 | 收益 |
|------|------|
| 星形依赖拓扑 | 模块独立性强,可独立测试和替换 |
| ErpModule 统一接口 | 新模块注册流程标准化 |
| 事件总线 | 跨模块解耦通信的基础设施已就绪 |
| JWT→TenantContext | 多租户全链路贯通 |
| 健康模块原生 | 不受沙盒限制,加密/文件/后台任务全部可用 |
| 插件实验 | 验证了平台灵活性CRM/库存可正常使用 |
**失 — 需要修正的问题:**
| 决策 | 代价 |
|------|------|
| 插件系统投入过大 | 22,000 行代码41% Rust 总量),对 HMS 核心业务贡献接近零 |
| 积分系统混入 health | 8 实体/12+ 路由,增加合规复杂度和数据泄露面 |
| 事件消费侧忽视 | 13 个事件只有 3 个被消费,危急告警和逾期通知空转 |
| 测试覆盖极薄 | 36 后端 + 3 前端测试,覆盖率 < 5% |
| 合规意识不足 | 知情同意缺失、审计不完整、PIE 加密范围不足 |
---
## 4. 三专家评审摘要
### 4.1 高级系统架构师
**诊断准确度7/10** — 四个张力都真实存在,但优先级和细节有偏差。
关键补充:
| 发现 | 严重程度 |
|------|---------|
| WIT 接口是同步调用阻塞WASM 运行时嵌入主进程(故障隔离不足) | 架构隐患 |
| EventBus 内存 broadcast 无重放机制,服务重启丢事件 | P1 |
| `follow_up.overdue` 无幂等保护,每 6h 检查重复发布 | P0 |
| erp-message 用 `subscribe()` 全量订阅,性能隐患 | P1 |
| RLS 不是 P0多租户集成测试才是 | 观点 |
| 积分系统8 独立实体、12+ 路由)不应在 erp-health 内 | 共识 |
| 缺监控/可观测性、数据备份策略、API 版本升级路线图 | 盲点 |
核心原则:**先补测试再重构,先修事件再上功能,先验证再加固。**
### 4.2 医疗信息化专家
**发现了比原始诊断更深层的临床安全风险。**
| 新发现 | 严重程度 |
|--------|---------|
| 危急值阈值全部硬编码(收缩压 180/80、心率 150/40不可配置 | P0 |
| `daily_monitoring` 表体征数据不经过危急值检测(合并前遗留) | P0 |
| 过敏史更新直接覆盖,无变更历史 | P0 |
| 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果) | P0 — PIPL 违规 |
| 只有身份证号存储加密,姓名/过敏史/诊断/咨询内容明文 | P1 |
| 审计日志不完整 — 只有预约状态变更记录前后值 | P1 |
| `ip_address``user_agent` 从未被填充 | P1 |
| 读操作(查看患者详情/化验报告)完全没有审计记录 | P1 |
| 诊断记录 `icd_code` 只做字符串约束,无格式校验,无同行审核 | P1 |
合规评估PIPL 第 29 条要求处理敏感个人信息须取得单独同意。医疗数据属于敏感个人信息。知情同意缺失是法律红线。
领域模型建议积分系统6 实体 + 2 线下活动实体)应拆分为独立 `erp-points``erp-engagement` 模块,与健康数据分离以降低合规复杂度。
### 4.3 产品策略专家
**开发节奏不可持续但不必恐慌。**
| 分析 | 结论 |
|------|------|
| 峰值 68 提交/天fix 提交占 21.6% | 短期冲刺可以,长期人会耗竭 |
| 41% Rust 代码在插件系统,对核心业务贡献接近零 | 最大 ROI 失衡 |
| 单人 + AI 的"速度幻觉" | 68 提交/天 = 审查不足,积分混入 health 就是例证 |
| 测试覆盖 < 5% | 正确水位不是 80%,而是关键路径不回退(目标 50-80 用例3-4 天) |
关键风险缓解建议:
- ADR架构决策记录强制化
- 医疗安全代码双人外部 review
- 每日提交上限 15 次
- 每月需求裁剪
V2 血透路线图评估:技术储备已够(`dialysis_service` 286 行骨架在),但缺市场验证。建议先做 3-5 家目标客户调研,确认需求后再做 2 周 MVP 试运行。
---
## 5. 共识优先级
### 5.1 三专家加权共识矩阵
| 议题 | 架构师 | 医疗专家 | 产品策略 | 共识等级 |
|------|--------|---------|---------|---------|
| 危急值告警闭环 | P0 | P0 + 硬编码 | P0 | 三方一致 |
| 知情同意 (PIPL) | 未涉及 | P0 | P0 | 两方一致 |
| 审计日志补全 | 未涉及 | P1 | P0 | P0-P1 |
| EventBus 可靠性 | P1 | 未涉及 | P0 | P0-P1 |
| 随访逾期通知 | P0 | P0 | P0 | 三方一致 |
| 积分系统拆分 | 应拆 | 应拆(合规) | 占 19.5% | 三方一致 |
| RLS | 不是 P0 | P1 | P0 | 有分歧 |
| 插件系统 | 有条件冻结 | 未涉及 | 冻结 | 两方一致 |
| 测试覆盖 | 先补测试 | 上线前必修 | 50-80 用例 | 三方一致 |
| V2 血透 | 未涉及 | 缺标准流程 | 先调研 | 两方一致 |
### 5.2 P0 — 上线前必修(估计 2-3 周)
| 序号 | 项 | 工作量 | 负责 crate | 说明 |
|------|---|--------|-----------|------|
| 1 | 危急值告警消费者 | 1 天 | erp-health + erp-message | `health_data.critical_alert` → 推送通知给责任医护 |
| 2 | 危急值阈值可配置化 | 2 天 | erp-health | 硬编码阈值改为数据库配置,支持科室/年龄差异化 |
| 3 | daily_monitoring 合并后告警验证 | 1 天 | erp-health | 确认合并到 vital_signs 后所有体征数据都经过告警检测 |
| 4 | 随访逾期通知 | 1 天 | erp-health + erp-message | `follow_up.overdue` → 催办通知 + 幂等保护 |
| 5 | 知情同意记录 | 3 天 | erp-health | 患者数据处理同意获取和记录机制 |
| 6 | 审计日志补全 | 3 天 | erp-core + erp-health | 临床数据变更记录前后值、读操作审计、IP/UA 填充 |
| 7 | EventBus 持久化增强 | 2 天 | erp-core | 服务重启不丢事件 + overdue 事件幂等 |
### 5.3 P1 — 治理2-4 周)
| 序号 | 项 | 工作量 | 说明 |
|------|---|--------|------|
| 8 | 积分系统剥离 | 5 天 | 从 erp-health 拆分为独立 erp-engagement crate |
| 9 | 关键路径测试 | 4 天 | 多租户隔离、患者安全路径、预约并发50-80 用例) |
| 10 | 插件系统冻结声明 | 0.5 天 | 保留代码README 声明实验性,不再投入 |
| 11 | erp-message 改用 `subscribe_filtered` | 1 天 | 减少无效事件传递 |
| 12 | 统一事件消费模式 | 2 天 | 消除 `register_event_handlers` vs `on_startup` 双路径 |
| 13 | 过敏史变更历史 | 1 天 | 更新时记录旧值 |
### 5.4 P2 — 扩展(后续迭代)
| 序号 | 项 | 前置条件 |
|------|---|---------|
| 14 | PostgreSQL RLS | P1 测试覆盖完成 |
| 15 | 血透专科 | 3-5 家客户调研完成 |
| 16 | OCR 化验单提取 | 血透验证后 |
| 17 | IM 咨询 | 血透验证后 |
| 18 | health 模块按子域重组目录 | P1 测试覆盖完成 |
| 19 | 前端测试覆盖提升 | P1 后端测试完成 |
| 20 | 动态菜单系统 | 现有计划可用 |
---
## 6. 风险与缓解
### 6.1 开发模式风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 单人认知单点 | 一人理解 16 个 cratebus factor = 1 | ADR 强制化,关键决策留文档 |
| AI 生成"编译对但逻辑错" | 危急值阈值硬编码、积分混入 health 就是例证 | 医疗安全代码双人外部 review |
| 速度幻觉 | 68 提交/天 = 审查不足 | 每日提交上限 15 次 |
| AI 回音壁 | AI 不质疑需求合理性 | 每月需求裁剪,引入真实用户反馈 |
### 6.2 临床安全风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 危急值告警未闭环 | 危急体征值无人响应,可致患者安全事故 | P0-1实现消费者 + 阈值可配置 |
| 逾期随访无催办 | 患者失访,影响医疗质量指标 | P0-4实现通知 + 幂等保护 |
| 过敏史无变更记录 | 无法追溯过敏史变更,用药风险 | P1-13添加变更历史 |
| 告警阈值硬编码 | 无法适应儿科/老年科/血透科不同范围 | P0-2数据库配置 |
### 6.3 合规风险
| 风险 | 法规依据 | 缓解措施 |
|------|---------|---------|
| 知情同意缺失 | PIPL 第 29 条 | P0-5实现同意记录机制 |
| 审计不完整 | 医疗机构信息化建设要求 | P0-6补全审计日志 |
| PIE 加密范围不足 | PIPL 第 51 条 | P1扩展加密到姓名/过敏史/诊断 |
| 数据删除权缺失 | PIPL 第 47 条 | P2实现患者数据导出/删除 |
### 6.4 架构风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| EventBus 无重放 | 服务重启丢事件 | P0-7增强持久化 |
| 全量订阅 | 性能隐患,所有事件经消息模块 | P1-11改用过滤订阅 |
| 路由手动合并 | 新模块 boilerplate 成本 | 长期ErpModule trait v2 |
| erp-health 过大 | 18+ 实体,维护复杂度上升 | P2-18按子域重组 |
---
## 7. 附录
### 7.1 关键文件索引
| 文件 | 说明 |
|------|------|
| `crates/erp-core/src/module.rs` | ErpModule trait + ModuleRegistry (拓扑排序) |
| `crates/erp-core/src/events.rs` | EventBus 实现 (broadcast + outbox) |
| `crates/erp-core/src/types.rs` | TenantContext, BaseFields, Pagination |
| `crates/erp-core/src/rbac.rs` | 权限/角色检查 |
| `crates/erp-server/src/main.rs` | 服务组装和手动路由合并 |
| `crates/erp-server/src/state.rs` | AppState + FromRef 桥接 |
| `crates/erp-server/src/outbox.rs` | Outbox relay (5s 轮询, 3 次重试) |
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT 认证 + TenantContext 注入 |
| `crates/erp-health/src/module.rs` | HealthModule (ErpModule 实现 + 后台任务) |
| `crates/erp-health/src/event.rs` | 健康模块事件订阅 |
| `crates/erp-health/src/crypto.rs` | AES-256-GCM 加密 |
| `crates/erp-health/src/service/masking.rs` | PII 脱敏管道 |
| `crates/erp-plugin/src/engine.rs` | WASM 插件引擎 |
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | 插件系统设计规格 |
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | 健康模块设计规格 |
| `docs/discussions/2026-04-18-plugin-platform-brainstorm.md` | 插件平台演进讨论 |
### 7.2 迁移历史时间线
| 日期 | 迁移范围 | 说明 |
|------|---------|------|
| 4/10-11 | 核心平台 | 租户、用户、凭证、角色、权限、组织、部门、岗位 |
| 4/12 | 配置 + 工作流 | 字典、菜单、设置、编号规则 + 流程定义/实例/令牌/任务 |
| 4/13 | 消息 + 审计 | 模板、消息、订阅 + 审计日志 |
| 4/14 | 修复 | 唯一索引与软删除冲突、标准字段补全 |
| 4/16 | 领域事件 | domain_events 表 |
| 4/17 | 插件系统 | 插件表、动态表 |
| 4/18 | 搜索 + 权限 | pg_trgm、实体注册表、数据范围 |
| 4/19 | 关联修复 | 用户部门、CRM 修复、插件市场 |
| 4/23 | 健康表 | 患者、微信用户、文章 |
| 4/24 | 索引修复 | 3 个 fixup 迁移 |
| 4/25 | 健康扩展 | 患者ID哈希、医生名、透析/化验增强、AI 表、积分 |
| 4/26 | 业务改进 | 诊断、列重命名、daily_monitoring 合并、菜单种子 |
**总计59 个迁移17 天内。** fixup 迁移模式健康(不编辑旧迁移,单独修复)。
### 7.3 项目统计快照 (2026-04-26)
| 指标 | 值 |
|------|-----|
| Rust crate 数 | 16 |
| Rust 代码行 | ~57,000 |
| 前端文件数 | 174 (TSX/TS) |
| 前端页面 | 62 |
| 小程序页面 | 27 |
| 数据库迁移 | 59 |
| 数据库表 | 30 基础 + 18 健康 + 3 AI |
| 后端测试 | 36 |
| 前端单元测试 | 3 |
| Git 提交 | 237 |
| 开发周期 | 17 天 |
---
*本文档由三专家多视角评审生成,作为 HMS 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*