Files
hms/docs/superpowers/specs/2026-04-28-technical-debt-cleanup-design.md
iven 755d95480e docs(spec): 技术债清理设计规格 — 安全/事件/测试三批次策略
发散式技术债讨论结论,涵盖:
- 批次 A:安全合规(SQL 审计、PII 后端加解密+Blind Index、RLS 兜底)
- 批次 B:事件架构(vital.critical 消费者优先、积分拆 erp-points crate)
- 批次 C:测试质量(事务回滚模式、安全测试驱动)
2026-04-28 10:03:03 +08:00

22 KiB
Raw Blame History

技术债清理设计规格

日期: 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 加密密钥硬编码到小程序 bundlesecure-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+A2SQL 注入风险排查与修复

现状: 审计报告标记 tenant_rls.rsfollow_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 相关动态 SQL
  • crates/erp-health/src/service/ — 各 service 中的原生 SQL
  • crates/erp-auth/src/ — 用户查询相关

修复方案:

  1. 逐文件审计 29 处 Statement::from_string,区分 migration安全和业务代码需参数化
  2. 如发现裸拼接,统一改为 Statement::from_sql_and_values + $N 占位符
  3. 引入 Clippy lint 或 CI 检查禁止 Statement::from_string 用于含用户输入的场景

2.2 A3SSE 连接有效性验证

现状: apps/web/src/stores/message.ts:75 使用 localStorage.getItem('access_token') 获取 tokenapps/web/src/stores/auth.ts:6 同样使用 access_token 键名。键名一致,但 SSE 端点 /api/v1/messages/stream 是否实际工作尚未验证。

修复方案:

  1. 验证 SSE 端点是否正常响应(后端 handler 是否正确注册)
  2. 统一使用 auth store 导出的获取方法而非直接 localStorage.getItem
  3. 添加 token 有效性检查,避免无效连接

涉及文件:

  • apps/web/src/stores/message.ts
  • apps/web/src/stores/auth.ts(确认 token 键名)

2.3 A4+A5+A8+A9PII 后端加解密 + 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

  • HealthCryptoAES-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);

搜索流程:

  1. 前端传入搜索条件(如手机号 13812345678
  2. 后端计算 HMAC 哈希
  3. blind_indexes 表精确匹配
  4. 返回匹配的 entity_id 列表
  5. 模糊搜索(如姓张的所有患者):后端解密后内存过滤

密钥轮换流程:

  1. 生成新 DEK
  2. 批量重新加密所有 PII 字段
  3. 重算所有 blind_hash
  4. 更新 KEK 加密的新 DEK
  5. 旧 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

  • 引入 zeroize crate
  • HealthCryptoaes_keyhmac_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 的 service
  • crates/erp-health/entity/ — 新增 blind_indexes entity
  • apps/miniprogram/src/utils/secure-storage.ts — 审计并移除 PII 加密逻辑(保留本地存储加密)

2.4 A6清缓存破坏认证状态

现状: 小程序设置页已实现 preserve-and-restore 模式(apps/miniprogram/src/pages/profile/settings/index.tsx:16-27):清除缓存前保存 access_tokenrefresh_token 等 8 个关键 key清除后重新写入。

结论: 此问题 已修复,改为验证任务 — 确认 preserve-and-restore 逻辑覆盖所有必要 key 且工作正常。

涉及文件:

  • apps/miniprogram/src/pages/profile/settings/index.tsx

2.5 A7PostgreSQL RLS Policy 兜底

现状: 多租户隔离完全依赖应用层中间件注入 tenant_id 过滤。应用层 bug 可直接导致跨租户数据泄露。

修复方案:

  1. 为所有含 tenant_id 的表创建 RLS policy
  2. 使用 PostgreSQL session variableSET app.current_tenant_id)传递当前租户
  3. 中间件在每次请求开始时设置 session variable
  4. 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:43alert_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.rsregister_handlers_with_state()
  • 告警 servicecrates/erp-health/src/service/critical_alert_service.rs(新增)
  • 告警 handlercrates/erp-health/src/handler/critical_alert_handler.rs(新增)
  • Entitycrates/erp-health/src/entity/critical_alert.rs(新增)

3.2 B2EventBus 可靠性增强

现状: EventBuscrates/erp-core/src/events.rs)已实现:

  • 事件持久化到 domain_eventspending → published 状态机)
  • 内存 broadcast channel
  • PostgreSQL NOTIFY outbox relay
  • 过滤订阅(subscribe_filtered
  • 幂等去重(is_event_processed / mark_event_processed

缺少:

  • 消费失败的重试机制
  • dead-letter 存储
  • 消费者健康监控

修复方案:

  1. domain_events 表增加 attempts 计数器(已有)和 last_error 字段(已有)
  2. 新增 dead_letter_events 表:超过最大重试次数的事件转入
  3. 消费者返回 Result 时,失败则 attempts++,达到阈值转入 dead-letter
  4. 提供 GET /api/v1/admin/dead-letter-events 端点查看失败事件
  5. 提供 POST /api/v1/admin/dead-letter-events/{id}/retry 手动重试

3.3 B3积分系统拆分为独立 erp-points crate

现状: 积分商城 6 个实体全在 erp-health 内(points_accountpoints_checkinpoints_orderpoints_productpoints_rulepoints_transactionpoints_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.completedhealth.appointment.completed 事件在当前代码库中不存在,需在 erp-health 中新增定义并发布后才能订阅。建议作为后续迭代Phase 1 先订阅已有事件。

erp-points 发布的事件:

事件 动作
points.earned 通知消息模块
points.exchanged 通知消息模块
points.expired 积分过期通知(已有事件常量)
points.balance.changed 余额变动,供前端 SSE 推送(新增事件常量

3.3.3 迁移策略

  1. 创建 erp-points crate搭建标准基础设施module/state/error
  2. erp-health 迁移 6 个 Entity + 对应 Migration 文件
  3. 迁移 service 逻辑,points_service.rs 拆分为 6 个子模块
  4. 迁移 handler
  5. erp-server 注册新模块
  6. 前端路由不变API 路径保持 /api/v1/points/*
  7. 删除 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

修复方案:事务回滚模式

  1. 每个测试开始时开事务
  2. 测试内的所有数据库操作在此事务中执行
  3. 测试结束(无论成功失败)回滚事务
  4. 多个测试共享同一个数据库连接池,无连接竞争
#[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 C3API 集成测试骨架

为关键 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。

修复方案:

  1. 配置 manualChunks 将 antd/antd-icons 拆为独立 vendor chunk
  2. echarts 按需引入(只导入使用的图表类型)
  3. 路由级 code splittingReact.lazy + Suspense
  4. 目标:首屏 JS < 500KBgzip 后)

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 提交