发散式技术债讨论结论,涵盖: - 批次 A:安全合规(SQL 审计、PII 后端加解密+Blind Index、RLS 兜底) - 批次 B:事件架构(vital.critical 消费者优先、积分拆 erp-points crate) - 批次 C:测试质量(事务回滚模式、安全测试驱动)
22 KiB
技术债清理设计规格
日期: 2026-04-28 | 状态: DRAFT | 来源: 发散式技术债讨论
1. 概述
1.1 背景
HMS 健康管理平台经过 17 天密集开发,功能层面已建成完整的医疗 SaaS 骨架:
| 维度 | 数据 |
|---|---|
| Rust crate | 15 个 |
| 数据库表 | 67 张(30 基础 + 34 健康 + 3 AI) |
| 后端代码 | ~63k 行 Rust |
| Web 前端 | 133 个源文件(77 TSX + 56 TS) |
| 微信小程序 | 40 个页面,Taro 4.2 + React 18 |
| Git 提交 | 301+ 次 |
但安全合规、测试质量、事件消费闭环三个维度存在系统性欠账,是当前最大的技术风险。
1.2 问题全景
安全(6 个 Critical):
- SQL 注入风险(需排查动态 SQL 使用场景)
- SSE 连接有效性需验证(键名一致但端点未确认可用)
- AES 加密密钥硬编码到小程序 bundle(
secure-storage.ts使用环境变量,但 dev 默认密钥不安全) - PII 数据明文传输/展示
- 密钥轮换端点未持久化、DEK 内存未 zeroize
事件(13 个事件仅 3 个被消费):
- 危急体征告警(
health_data.critical_alert)无人响应 - 患者创建、预约确认/取消、随访逾期等业务事件无消费者
- 缺少 dead-letter 和重试机制
测试(覆盖率 < 5%):
- 后端 36 个测试因并行化问题全部失败
- 前端仅 3 个单元测试
- 无安全测试、无多租户隔离测试
代码质量:
points_service.rs膨胀至 1805 行(超 800 行上限 2 倍+)- 积分系统(6 实体)与 health 模块耦合过重
- 密文缺版本标识,未来算法迁移困难
- 前端 chunk 过大(antd 1.5MB)
1.3 还债策略
按类型分批,批内 Critical 优先:
批次 A(安全与合规)→ 批次 B(事件与架构)→ 批次 C(测试与质量)
每个批次的修复都伴随对应测试(批次 C 随批次 A/B 同步推进)。
1.4 预估总工作量
| 批次 | 项目数 | 预估 |
|---|---|---|
| A 安全与合规 | 9 | 3-4 天 |
| B 事件与架构 | 5 | 3-5 天 |
| C 测试与质量 | 5 | 2-3 天(持续) |
| 合计 | 19 | 8-12 天 |
2. 批次 A:安全与合规
2.1 A1+A2:SQL 注入风险排查与修复
现状: 审计报告标记 tenant_rls.rs 和 follow_up_service.rs 存在 SQL 拼接。
实际代码验证:
follow_up_service.rs:73-84:使用$N占位符 +Statement::from_sql_and_values,为参数化查询,非注入风险tenant_rls.rs:文件不存在于当前代码库,可能已被修复或移除- 代码库中存在 29 处
Statement::from_string使用,需逐个审计
需审计的高风险文件(非 migration):
crates/erp-plugin/src/— dynamic_table 相关动态 SQLcrates/erp-health/src/service/— 各 service 中的原生 SQLcrates/erp-auth/src/— 用户查询相关
修复方案:
- 逐文件审计 29 处
Statement::from_string,区分 migration(安全)和业务代码(需参数化) - 如发现裸拼接,统一改为
Statement::from_sql_and_values+$N占位符 - 引入 Clippy lint 或 CI 检查禁止
Statement::from_string用于含用户输入的场景
2.2 A3:SSE 连接有效性验证
现状: apps/web/src/stores/message.ts:75 使用 localStorage.getItem('access_token') 获取 token,apps/web/src/stores/auth.ts:6 同样使用 access_token 键名。键名一致,但 SSE 端点 /api/v1/messages/stream 是否实际工作尚未验证。
修复方案:
- 验证 SSE 端点是否正常响应(后端 handler 是否正确注册)
- 统一使用 auth store 导出的获取方法而非直接
localStorage.getItem - 添加 token 有效性检查,避免无效连接
涉及文件:
apps/web/src/stores/message.tsapps/web/src/stores/auth.ts(确认 token 键名)
2.3 A4+A5+A8+A9:PII 后端加解密 + Blind Index
这是批次 A 中工作量最大、架构影响最深的修复项。四个 Critical 问题合并为一个实施任务。
2.3.1 架构决策:方案 D — 后端加解密 + Blind Index
核心原则:前端(含小程序)不接触加密密钥。
| 层 | 职责 |
|---|---|
| 后端 Service 层 | PII 字段透明加解密(写入前加密,读取后解密) |
| 后端 API 层 | 按角色/场景返回不同脱敏级别 |
| Blind Index 层 | HMAC-SHA256 盲索引支持精确搜索 |
| 前端 | 只展示后端返回的数据,不参与加密 |
2.3.2 现有加密基础设施
当前已实现(crates/erp-health/src/crypto.rs):
HealthCrypto:AES-256-GCM 加密 + HMAC-SHA256 盲索引encrypt():UUID v7 nonce + 密文 → Base64 编码decrypt():Base64 解码 → nonce 分离 → 解密hmac_hash():确定性 HMAC 哈希(盲索引)dev_default():开发环境硬编码密钥(需移除)- 已有 7 个单元测试验证加解密正确性
2.3.3 Blind Index 方案
新增数据库表:
CREATE TABLE blind_indexes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
entity_type VARCHAR(64) NOT NULL, -- 如 "patient", "doctor"
entity_id UUID NOT NULL,
field_name VARCHAR(64) NOT NULL, -- 如 "id_card", "phone"
blind_hash VARCHAR(64) NOT NULL, -- HMAC-SHA256 hex
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, entity_type, field_name, blind_hash)
);
CREATE INDEX idx_blind_hashes ON blind_indexes (tenant_id, entity_type, field_name, blind_hash);
搜索流程:
- 前端传入搜索条件(如手机号
13812345678) - 后端计算 HMAC 哈希
- 在
blind_indexes表精确匹配 - 返回匹配的 entity_id 列表
- 模糊搜索(如姓张的所有患者):后端解密后内存过滤
密钥轮换流程:
- 生成新 DEK
- 批量重新加密所有 PII 字段
- 重算所有 blind_hash
- 更新 KEK 加密的新 DEK
- 旧 DEK 标记过期,保留 7 天过渡期
2.3.4 API 脱敏级别
| 场景 | 返回策略 |
|---|---|
| 列表页 | 脱敏展示(张**、138****5678) |
| 详情页(有权限) | 完整明文 |
| 导出 | 按权限级别,默认脱敏 |
| API 内部调用 | 明文(服务间信任) |
新增脱敏工具函数:
mask_name("张三丰")→"张**"mask_phone("13812345678")→"138****5678"mask_id_card("110101199001011234")→"1101**********1234"
2.3.5 小程序侧改造
- 审计
apps/miniprogram/src/utils/secure-storage.ts— 当前使用环境变量TARO_APP_ENCRYPTION_KEY+ CryptoJS.AES 做本地存储加密 - 区分用途:
- PII 端到端加密 → 移除(后端全面接管)
- 本地 token 安全存储 → 保留(非 PII 用途)
- API 返回的 PII 数据由后端控制脱敏级别
- 列表页展示脱敏数据,详情页请求完整数据(需权限)
2.3.6 DEK 内存 zeroize
- 引入
zeroizecrate HealthCrypto的aes_key和hmac_key字段使用Zeroizing<[u8; 32]>- Drop 时自动覆写内存
2.3.7 密文版本标识
- 加密输出格式:
v1|Base64(nonce + ciphertext) - 未来算法升级时新增
v2|... - 解密时根据版本前缀选择对应算法
涉及文件:
crates/erp-health/src/crypto.rs— 主要修改crates/erp-health/src/state.rs— HealthState.crypto 使用crates/erp-health/src/service/— 所有涉及 PII 的 servicecrates/erp-health/entity/— 新增 blind_indexes entityapps/miniprogram/src/utils/secure-storage.ts— 审计并移除 PII 加密逻辑(保留本地存储加密)
2.4 A6:清缓存破坏认证状态
现状: 小程序设置页已实现 preserve-and-restore 模式(apps/miniprogram/src/pages/profile/settings/index.tsx:16-27):清除缓存前保存 access_token、refresh_token 等 8 个关键 key,清除后重新写入。
结论: 此问题 已修复,改为验证任务 — 确认 preserve-and-restore 逻辑覆盖所有必要 key 且工作正常。
涉及文件:
apps/miniprogram/src/pages/profile/settings/index.tsx
2.5 A7:PostgreSQL RLS Policy 兜底
现状: 多租户隔离完全依赖应用层中间件注入 tenant_id 过滤。应用层 bug 可直接导致跨租户数据泄露。
修复方案:
- 为所有含
tenant_id的表创建 RLS policy - 使用 PostgreSQL session variable(
SET app.current_tenant_id)传递当前租户 - 中间件在每次请求开始时设置 session variable
- RLS policy 作为应用层过滤的兜底,而非替代
-- 示例
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON patients
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
注意: RLS policy 不影响超级用户(SUPERUSER),仅对应用数据库用户生效。
涉及文件:
crates/erp-core/src/middleware/— 注入 session variable- 新增 SeaORM migration 文件(RLS policy 通过迁移实施,遵循 CLAUDE.md 规范)
3. 批次 B:事件与架构
3.1 B1:危急值告警消费者(最高优先)
现状: health_data.critical_alert 事件已定义(crates/erp-health/src/event.rs:43),alert_engine 已实现评估逻辑,但 无消费者响应此事件。这意味着当患者体征超过危急值阈值时,系统仅发布事件但不通知任何医生。
这是真实的医疗安全风险。
3.1.1 消费链路设计
健康数据录入 → critical_value_threshold 对比 → 超阈值
↓
EventBus.publish("health_data.critical_alert", payload)
↓
┌──────────────────────────┐
│ 危急值消费者 (consumer) │
├──────────────────────────┤
│ 1. 创建 critical_alert │ ← 新表,记录告警实例
│ 2. 通知主治医生 │ ← 站内消息
│ 3. 微信订阅消息推送 │ ← 患者端/医护端通知
│ 4. 启动响应计时 │ ← 30min 未确认升级
│ 5. 记录事件已处理 │ ← 幂等去重
└──────────────────────────┘
3.1.2 告警升级策略
| 级别 | 触发条件 | 动作 |
|---|---|---|
| Level 1 | 告警产生 | 通知主治医生(站内 + 微信) |
| Level 2 | 30min 未确认 | 通知科室主任 |
| Level 3 | 60min 未确认 | 通知院领导 + 标记为严重事件 |
升级检查使用 tokio::spawn 定时任务,每分钟扫描未确认的告警。
3.1.3 新增数据库表
CREATE TABLE critical_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
patient_id UUID NOT NULL,
alert_type VARCHAR(64) NOT NULL, -- vital_sign / lab_result
metric_name VARCHAR(64) NOT NULL, -- blood_pressure / heart_rate ...
metric_value TEXT NOT NULL,
threshold_value TEXT NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'critical',
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/acknowledged/resolved/escalated
acknowledged_by UUID,
acknowledged_at TIMESTAMPTZ,
escalation_level SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 1
);
CREATE TABLE critical_alert_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
alert_id UUID NOT NULL REFERENCES critical_alerts(id),
responder_id UUID NOT NULL, -- 确认人
response_type VARCHAR(16) NOT NULL, -- acknowledge/escalate/resolve
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version BIGINT NOT NULL DEFAULT 1
);
CREATE INDEX idx_critical_alerts_pending ON critical_alerts (tenant_id, status, created_at)
WHERE status IN ('pending', 'escalated');
3.1.4 幂等处理
利用 EventBus 已有的幂等基础设施:
is_event_processed(db, event_id, "critical_alert_consumer")检查mark_event_processed(db, event_id, "critical_alert_consumer")标记processed_events表的 event_id + consumer_id 唯一约束兜底
3.1.5 实现位置
- 消费者注册:
crates/erp-health/src/event.rs→register_handlers_with_state() - 告警 service:
crates/erp-health/src/service/critical_alert_service.rs(新增) - 告警 handler:
crates/erp-health/src/handler/critical_alert_handler.rs(新增) - Entity:
crates/erp-health/src/entity/critical_alert.rs(新增)
3.2 B2:EventBus 可靠性增强
现状: EventBus(crates/erp-core/src/events.rs)已实现:
- 事件持久化到
domain_events表(pending → published 状态机) - 内存 broadcast channel
- PostgreSQL NOTIFY outbox relay
- 过滤订阅(
subscribe_filtered) - 幂等去重(
is_event_processed/mark_event_processed)
缺少:
- 消费失败的重试机制
- dead-letter 存储
- 消费者健康监控
修复方案:
domain_events表增加attempts计数器(已有)和last_error字段(已有)- 新增
dead_letter_events表:超过最大重试次数的事件转入 - 消费者返回
Result时,失败则attempts++,达到阈值转入 dead-letter - 提供
GET /api/v1/admin/dead-letter-events端点查看失败事件 - 提供
POST /api/v1/admin/dead-letter-events/{id}/retry手动重试
3.3 B3:积分系统拆分为独立 erp-points crate
现状: 积分商城 6 个实体全在 erp-health 内(points_account、points_checkin、points_order、points_product、points_rule、points_transaction),points_service.rs 膨胀至 1805 行。
3.3.1 新 crate 结构
crates/erp-points/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── module.rs — ErpModule trait 实现
│ ├── state.rs — PointsState { db, event_bus }
│ ├── event.rs — 事件常量 + 消费者注册
│ ├── entity/
│ │ ├── mod.rs
│ │ ├── points_account.rs
│ │ ├── points_transaction.rs
│ │ ├── points_product.rs
│ │ ├── points_order.rs
│ │ ├── points_checkin.rs
│ │ └── points_rule.rs
│ ├── service/
│ │ ├── mod.rs
│ │ ├── account_service.rs
│ │ ├── product_service.rs
│ │ ├── order_service.rs
│ │ ├── check_in_service.rs
│ │ ├── exchange_service.rs
│ │ └── rule_engine.rs
│ ├── handler/
│ │ ├── mod.rs
│ │ └── points_handler.rs
│ ├── dto/
│ │ ├── mod.rs
│ │ └── points_dto.rs
│ └── error.rs
3.3.2 事件契约
erp-points 订阅的事件:
| 事件 | 来源 | 动作 |
|---|---|---|
lab_report.uploaded |
erp-health | 化验上传奖励(已有事件常量) |
patient.verified |
erp-health | 实名认证奖励(已有事件常量) |
daily_monitoring.created |
erp-health | 日常监测奖励(已有事件常量) |
注意: 原讨论中设想的
health.checkup.completed和health.appointment.completed事件在当前代码库中不存在,需在 erp-health 中新增定义并发布后才能订阅。建议作为后续迭代,Phase 1 先订阅已有事件。
erp-points 发布的事件:
| 事件 | 动作 |
|---|---|
points.earned |
通知消息模块 |
points.exchanged |
通知消息模块 |
points.expired |
积分过期通知(已有事件常量) |
points.balance.changed |
余额变动,供前端 SSE 推送(新增事件常量) |
3.3.3 迁移策略
- 创建
erp-pointscrate,搭建标准基础设施(module/state/error) - 从
erp-health迁移 6 个 Entity + 对应 Migration 文件 - 迁移 service 逻辑,
points_service.rs拆分为 6 个子模块 - 迁移 handler
erp-server注册新模块- 前端路由不变(API 路径保持
/api/v1/points/*) - 删除
erp-health中的积分相关代码
3.4 B4+B5:代码质量修复
B4 — points_service.rs 拆分:
- 随 B3 拆 crate 时一并处理
- 按 domain 拆为 account/product/order/check_in/exchange/rule_engine 6 个子模块
- 每个模块 < 400 行
B5 — 密文版本标识:
- 已在 §2.3.7 描述,加密输出格式改为
v1|Base64(nonce + ciphertext) - 解密时根据版本前缀路由到对应算法
4. 批次 C:测试与质量
4.1 C0:测试并行化基础设施修复
现状: cargo test --workspace 因 PostgreSQL 连接数不足导致 101 个测试全部失败(非代码 bug)。
修复方案:事务回滚模式
- 每个测试开始时开事务
- 测试内的所有数据库操作在此事务中执行
- 测试结束(无论成功失败)回滚事务
- 多个测试共享同一个数据库连接池,无连接竞争
#[cfg(test)]
async fn test_db() -> DatabaseTransaction {
let db = DatabaseConnection::connect(&database_url).await.unwrap();
db.begin().await.unwrap()
}
// 测试结束自动 drop DatabaseTransaction → 回滚
替代方案: --test-threads=4 限制并行度(临时方案,不推荐长期使用)。
4.2 C1:安全测试(与批次 A 联动)
每个批次 A 的修复对应一个安全测试:
| 测试 | 验证目标 | 实现方式 |
|---|---|---|
test_sql_injection_prevention |
动态 SQL 均参数化 | 注入 '; DROP TABLE -- 断言不执行 |
test_cross_tenant_isolation |
租户 A 看不到租户 B 数据 | 不同 tenant_id 查询断言为空 |
test_pii_round_trip_encryption |
加密→解密一致 | HealthCrypto encrypt→decrypt→比对 |
test_blind_index_exact_match |
盲索引精确搜索 | 插入→HMAC 搜索→断言找到 |
test_blind_index_no_false_positive |
盲索引无误匹配 | 搜索不存在的值→断言为空 |
test_key_rotation_re_encrypt |
密钥轮换后数据可解密 | 轮换→重加密→新密钥解密→比对 |
test_sse_token_key_correct |
SSE 用正确 token 键名 | 对比 auth store 和 message store 的 key |
test_rls_policy_enforcement |
RLS policy 兜底 | 设置不同 tenant_id session variable 断言隔离 |
test_cache_clear_preserves_auth |
清缓存不破坏认证 | 模拟清缓存→断言 token 存在 |
测试实现原则:
- 安全测试独立于业务测试,放在
tests/security/目录 - 每个测试可独立运行,不依赖其他测试的执行顺序
- 使用事务回滚,测试间互不干扰
4.3 C2:事件消费者测试
| 测试 | 验证目标 |
|---|---|
test_vital_critical_alert_created |
危急值事件→创建 alert 记录 |
test_vital_critical_acknowledge |
医生确认→状态更新 |
test_vital_critical_escalation |
30min 未确认→升级 |
test_event_idempotency |
同一事件消费两次→只创建一条 alert |
test_dead_letter_on_failure |
消费失败→转入 dead-letter |
4.4 C3:API 集成测试骨架
为关键 API 端点建立集成测试模式:
// tests/integration/api_test.rs
#[tokio::test]
async fn test_patient_crud() {
let app = TestApp::new().await;
let auth = app.login_as_doctor().await;
// Create
let resp = app.post("/api/v1/health/patients")
.set_auth(&auth)
.json(json!({ "name": "测试患者", ... }))
.send().await;
assert_eq!(resp.status(), 201);
// Read
let patient = app.get(&format!("/api/v1/health/patients/{}", id))
.set_auth(&auth)
.send().await;
assert_eq!(patient.status(), 200);
}
优先覆盖的 API:
- 患者管理 CRUD
- 预约创建/取消
- 健康数据录入
- 积分账户操作
4.5 C4:前端 chunk 拆分优化
现状: Vite 构建产生超大 chunk — antd 1.5MB、charts 1.4MB。
修复方案:
- 配置
manualChunks将 antd/antd-icons 拆为独立 vendor chunk - echarts 按需引入(只导入使用的图表类型)
- 路由级 code splitting(React.lazy + Suspense)
- 目标:首屏 JS < 500KB(gzip 后)
5. 核心决策记录
| 编号 | 决策 | 理由 | 影响 |
|---|---|---|---|
| D1 | PII 密钥管理 → 方案 D(后端加解密 + Blind Index) | 前端不接触密钥,安全性最高;Blind Index 支持搜索 | crypto.rs 重构、新增 blind_indexes 表、小程序移除加解密 |
| D2 | 积分系统 → 独立 erp-points crate | 清晰模块边界,积分可跨业务复用 | 新 crate + 6 实体迁移 + 事件契约 |
| D3 | 事件消费者 → vital.critical 最先 | 医疗安全底线,30min 未确认分级升级 | 新增 critical_alerts/responses 表 + 升级定时任务 |
| D4 | 测试入口 → 安全测试驱动 | 与批次 A 联动,修一个写一个 | 事务回滚模式 + 9 个安全测试 |
| D5 | 还债策略 → 按类型分批(安全→事件→测试) | 避免上下文切换,每批有清晰完成标准 | 3 批次串行执行 |
6. 实施顺序与依赖关系
批次 A(安全) 批次 B(事件) 批次 C(测试)
───────────────────── ───────────────── ────────────────
A1+A2 SQL 注入排查 ──测试──→ B1 vital.critical 消费者 C0 事务回滚模式
A3 SSE Token 修复 ──测试──→ B2 EventBus dead-letter C1 安全测试
A6 清缓存修复 ──测试──→ B3 积分拆 erp-points C2 事件测试
A4+A5+A8+A9 PII 方案 D ──测试──→ B4+B5 代码质量 C3 集成测试
A7 RLS policy 兜底 ──测试──→ C4 chunk 优化
关键依赖:
- C0(测试基础设施)必须在批次 B 开始前完成,作为批次 A 实施的前置条件
- B1 依赖 C0(测试基础设施就绪)
- B3 可与 A4 并行(无交叉文件)
- C1 随 A1-A7 逐步推进(修一个写一个)
- C4 独立于所有其他任务,可随时执行
每个任务的产出:
- 代码修改 + 对应测试
- 编译通过(
cargo check) - 测试通过(
cargo test --test-threads=4) - Git 提交