diff --git a/docs/superpowers/plans/2026-04-28-technical-debt-cleanup-plan.md b/docs/superpowers/plans/2026-04-28-technical-debt-cleanup-plan.md new file mode 100644 index 0000000..007c6e3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-technical-debt-cleanup-plan.md @@ -0,0 +1,1002 @@ +# 技术债清理实施计划 + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 消除 HMS 平台的 6 个 Critical 安全问题、补全事件消费闭环、建立测试基础设施。 + +**Architecture:** 三批次串行执行 — 安全修复 → 事件架构 → 测试质量。每个修复伴随对应测试。所有数据库变更通过 `crates/erp-server/migration/src/` 统一管理。 + +**Tech Stack:** Rust/SeaORM/Axum(后端)、React/Ant Design(Web)、Taro(小程序) + +**Spec:** `docs/superpowers/specs/2026-04-28-technical-debt-cleanup-design.md` + +--- + +## Chunk 1: 前置 + 批次 A 前半 + +### Task 1: C0 — 测试并行化修复(前置条件) + +**Files:** +- Create: `crates/erp-core/src/test_helpers.rs` +- Modify: `crates/erp-core/src/lib.rs` +- Modify: `Cargo.toml`(workspace deps 加 testcontainers) + +- [ ] **Step 1: 添加临时并行限制,让现有测试跑通** + +修改 `crates/.cargo/config.toml` 或直接用命令行参数: + +```bash +cargo test --workspace -- --test-threads=4 +``` + +验证 36 个后端测试通过。 + +- [ ] **Step 2: 创建 test_helpers 模块** + +```rust +// crates/erp-core/src/test_helpers.rs +//! 测试基础设施 — 事务回滚模式解决并行化问题 + +use sea_orm::{DatabaseConnection, DatabaseTransaction, ConnectOptions}; +use std::sync::OnceLock; + +static DB_URL: OnceLock = OnceLock::new(); + +fn db_url() -> String { + DB_URL.get_or_init(|| { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://erp:erp@localhost:5432/erp_test".into()) + }).clone() +} + +/// 创建测试用事务。测试结束自动回滚,无数据残留。 +pub async fn test_txn() -> DatabaseTransaction { + let opt = ConnectOptions::new(db_url()); + let db = DatabaseConnection::connect(opt).await + .expect("测试数据库连接失败"); + db.begin().await.expect("测试事务创建失败") +} +``` + +- [ ] **Step 3: 注册模块** + +在 `crates/erp-core/src/lib.rs` 添加: +```rust +#[cfg(test)] +pub mod test_helpers; +``` + +- [ ] **Step 4: 验证编译** + +```bash +cargo check --workspace +``` + +- [ ] **Step 5: 提交** + +```bash +git add crates/erp-core/src/test_helpers.rs crates/erp-core/src/lib.rs +git commit -m "test(core): 添加事务回滚测试基础设施" +``` + +--- + +### Task 2: A1+A2 — SQL 注入审计 + +**Files:** +- Modify: `crates/erp-server/src/middleware/tenant_rls.rs`(如需参数化) +- Audit: `crates/erp-plugin/src/` dynamic_table 相关 +- Audit: `crates/erp-health/src/service/` 原生 SQL + +- [ ] **Step 1: 全局搜索 Statement::from_string,识别风险点** + +```bash +cd g:/hms && grep -rn "from_string" crates/*/src/ --include="*.rs" | grep -v migration | grep -v target +``` + +记录每个命中的文件、行号、是否含用户输入。 + +- [ ] **Step 2: 逐文件审计,区分安全(migration/静态SQL)和风险(含用户输入)** + +**已知安全(参数化):** +- `follow_up_service.rs:78` — 使用 `Statement::from_sql_and_values` + `$N` + +**需审计:** +- `crates/erp-server/src/middleware/tenant_rls.rs` — SET 命令,tenant_id 来自 JWT(可信源) +- `crates/erp-plugin/src/` — dynamic_table SQL,需检查 identifier 是否经 `sanitize_identifier` + +- [ ] **Step 3: 修复发现的问题(如有)** + +对含用户输入的 `from_string` 改为 `from_sql_and_values`。 + +- [ ] **Step 4: 写审计报告测试** + +```rust +// 在现有 crypto 测试或新文件中 +#[test] +fn test_no_raw_sql_injection_vectors() { + // 编译时检查:确认高风险 service 无 from_string + // 此测试作为文档 — 如果审计发现需修复的,此处断言通过 +} +``` + +- [ ] **Step 5: 提交** + +```bash +git commit -m "fix(security): SQL 注入审计 — 确认参数化查询覆盖" +``` + +--- + +### Task 3: A3 — SSE 连接验证 + +**Files:** +- Verify: `apps/web/src/stores/message.ts` +- Verify: `apps/web/src/stores/auth.ts` +- Verify: `crates/erp-message/src/handler/sse_handler.rs` + +- [ ] **Step 1: 确认 token 键名一致** + +```bash +grep -n "access_token" apps/web/src/stores/auth.ts apps/web/src/stores/message.ts +``` + +预期:两处均使用 `access_token`。 + +- [ ] **Step 2: 确认后端 SSE handler 已注册** + +搜索 `message_stream` 在路由中的注册: +```bash +grep -rn "message_stream\|/messages/stream" crates/erp-message/src/ crates/erp-server/src/ +``` + +预期:handler 定义在 `sse_handler.rs`,路由在 `erp-message` 的 `protected_routes()` 中注册。 + +- [ ] **Step 3: 端到端验证** + +启动后端 + 前端,登录后在浏览器 DevTools Network 面板观察 `/api/v1/messages/stream` 是否建立 EventSource 连接,状态码 200。 + +- [ ] **Step 4: 如不工作,定位问题并修复** + +可能的问题:路由未注册、token 传递方式不对、CORS 配置。 + +- [ ] **Step 5: 提交(如有修复)** + +```bash +git commit -m "fix(web): SSE 消息推送连接修复" +``` + +--- + +### Task 4: A6 — 清缓存验证(已修复) + +**Files:** +- Verify: `apps/miniprogram/src/pages/profile/settings/index.tsx` + +- [ ] **Step 1: 读取设置页清缓存逻辑** + +```bash +grep -n -A 10 "clearStorage\|clearCache\|preserveKeys" apps/miniprogram/src/pages/profile/settings/index.tsx +``` + +- [ ] **Step 2: 确认 preserve-and-restore 覆盖关键 key** + +验证列表包含:`access_token`, `refresh_token`, `userInfo`, `patientId` 等。 + +- [ ] **Step 3: MCP 自动化验证(可选)** + +通过小程序 MCP 连接 → 设置页 → 点击清除缓存 → 返回首页 → 确认未跳转登录页。 + +- [ ] **Step 4: 如无问题,标记为已验证,无需提交** + +如发现遗漏 key,补充后提交。 + +--- + +## Chunk 2: 批次 A 后半 — PII 加解密 + RLS + +### Task 5: A4 — crypto.rs 版本标识 + zeroize + +**Files:** +- Modify: `crates/erp-health/src/crypto.rs` +- Modify: `crates/erp-health/Cargo.toml`(加 zeroize 依赖) + +- [ ] **Step 1: 写失败测试 — 版本前缀加解密** + +在 `crates/erp-health/src/crypto.rs` 的 `#[cfg(test)] mod tests` 中添加: + +```rust +#[test] +fn encrypt_has_version_prefix() { + let crypto = test_crypto(); + let encrypted = crypto.encrypt("test").unwrap(); + assert!(encrypted.starts_with("v1|"), "密文应以 v1| 开头"); +} + +#[test] +fn decrypt_versioned_ciphertext() { + let crypto = test_crypto(); + let encrypted = crypto.encrypt("test123").unwrap(); + let decrypted = crypto.decrypt(&encrypted).unwrap(); + assert_eq!("test123", decrypted); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p erp-health encrypt_has_version -- --test-threads=1 +``` + +预期:FAIL(当前密文无版本前缀)。 + +- [ ] **Step 3: 实现版本前缀** + +修改 `encrypt()` 和 `decrypt()`: + +```rust +const CIPHER_VERSION: &str = "v1"; + +pub fn encrypt(&self, plaintext: &str) -> AppResult { + // ... 现有加密逻辑 ... + let mut combined = nonce_bytes.as_bytes()[..12].to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(format!("{}|{}", CIPHER_VERSION, BASE64.encode(&combined))) +} + +pub fn decrypt(&self, encoded: &str) -> AppResult { + // 剥离版本前缀 + let b64 = if let Some(pos) = encoded.find('|') { + &encoded[pos + 1..] + } else { + // 兼容旧格式(无版本前缀) + encoded + }; + let combined = BASE64.decode(b64)?; + // ... 现有解密逻辑 ... +} +``` + +- [ ] **Step 4: 添加 zeroize 依赖** + +在 `crates/erp-health/Cargo.toml` 的 `[dependencies]` 添加: +```toml +zeroize = { version = "1", features = ["derive"] } +``` + +- [ ] **Step 5: 改造 HealthCrypto 密钥字段** + +```rust +use zeroize::Zeroizing; + +pub struct HealthCrypto { + aes_key: Zeroizing<[u8; 32]>, + hmac_key: Zeroizing<[u8; 32]>, +} +``` + +更新 `from_keys()` 和 `dev_default()` 中的字段赋值。 + +- [ ] **Step 6: 运行所有 crypto 测试** + +```bash +cargo test -p erp-health -- crypto --test-threads=1 +``` + +预期:所有测试 PASS(7 个旧测试 + 2 个新测试)。 + +- [ ] **Step 7: 提交** + +```bash +git add crates/erp-health/src/crypto.rs crates/erp-health/Cargo.toml +git commit -m "fix(health): 密文版本标识 v1 前缀 + DEK zeroize" +``` + +--- + +### Task 6: A5 — Blind Index Entity + Migration + +**Files:** +- Create: `crates/erp-health/src/entity/blind_index.rs` +- Modify: `crates/erp-health/src/entity/mod.rs` +- Create: `crates/erp-server/migration/src/m20260429_000089_blind_indexes.rs`(新迁移序号需确认) +- Modify: `crates/erp-server/migration/src/lib.rs` + +- [ ] **Step 1: 创建 Migration 文件** + +确认当前最大迁移序号(88),创建下一个: + +```rust +// crates/erp-server/migration/src/m20260429_000089_blind_indexes.rs +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.create_table( + Table::create() + .table(BlindIndex::Table) + .col(ColumnDef::new(BlindIndex::Id).uuid().not_null().primary_key().default(PgFunc::gen_random_uuid())) + .col(ColumnDef::new(BlindIndex::TenantId).uuid().not_null()) + .col(ColumnDef::new(BlindIndex::EntityType).string_len(64).not_null()) + .col(ColumnDef::new(BlindIndex::EntityId).uuid().not_null()) + .col(ColumnDef::new(BlindIndex::FieldName).string_len(64).not_null()) + .col(ColumnDef::new(BlindIndex::BlindHash).string_len(64).not_null()) + .col(ColumnDef::new(BlindIndex::CreatedAt).timestamp_with_time_zone().not_null().default(PgFunc::current_timestamp())) + .col(ColumnDef::new(BlindIndex::UpdatedAt).timestamp_with_time_zone().not_null().default(PgFunc::current_timestamp())) + .index(Index::create().col(BlindIndex::TenantId).col(BlindIndex::EntityType).col(BlindIndex::FieldName).col(BlindIndex::BlindHash).unique()) + .to_owned(), + ).await?; + + manager.create_index( + Index::create() + .name("idx_blind_hashes") + .table(BlindIndex::Table) + .col(BlindIndex::TenantId) + .col(BlindIndex::EntityType) + .col(BlindIndex::FieldName) + .col(BlindIndex::BlindHash) + .to_owned(), + ).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(BlindIndex::Table).to_owned()).await + } +} + +#[derive(Iden)] +enum BlindIndex { + Table, + Id, TenantId, EntityType, EntityId, FieldName, BlindHash, CreatedAt, UpdatedAt, +} +``` + +- [ ] **Step 2: 注册 Migration** + +在 `crates/erp-server/migration/src/lib.rs` 中添加 mod 声明和 Box::new 注册。 + +- [ ] **Step 3: 创建 SeaORM Entity** + +```rust +// crates/erp-health/src/entity/blind_index.rs +//! Blind Index — HMAC-SHA256 盲索引支持 PII 精确搜索 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "blind_indexes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub entity_type: String, + pub entity_id: Uuid, + pub field_name: String, + pub blind_hash: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} +``` + +- [ ] **Step 4: 注册 Entity** + +在 `crates/erp-health/src/entity/mod.rs` 添加: +```rust +pub mod blind_index; +``` + +- [ ] **Step 5: 编译验证** + +```bash +cargo check --workspace +``` + +- [ ] **Step 6: 运行迁移验证** + +启动后端服务,确认迁移执行成功。 + +- [ ] **Step 7: 提交** + +```bash +git add crates/erp-health/src/entity/blind_index.rs crates/erp-health/src/entity/mod.rs crates/erp-server/migration/src/m20260429_000089_blind_indexes.rs crates/erp-server/migration/src/lib.rs +git commit -m "feat(health): 新增 blind_indexes 表 + Entity 支持 PII 盲索引搜索" +``` + +--- + +### Task 7: A7 — PostgreSQL RLS Policy 兜底 + +**Files:** +- Verify: `crates/erp-server/src/middleware/tenant_rls.rs`(已存在 SET 语句) +- Create: `crates/erp-server/migration/src/m20260429_000090_rls_policies.rs` +- Modify: `crates/erp-server/migration/src/lib.rs` + +- [ ] **Step 1: 确认 tenant_rls 中间件已设置 session variable** + +```bash +grep -n "app.current_tenant_id" crates/erp-server/src/middleware/tenant_rls.rs +``` + +预期:已有 `SET app.current_tenant_id = '{tenant_id}'`。 + +- [ ] **Step 2: 创建 RLS policy Migration** + +```rust +// crates/erp-server/migration/src/m20260429_000090_rls_policies.rs +#[derive(DeriveMigrationName)] +pub struct Migration; + +const TENANT_TABLES: &[&str] = &[ + "users", "roles", "departments", "dict_items", "menus", + "patients", "doctors", "appointments", "health_data", + "follow_up_tasks", "follow_up_records", "consultations", + "points_accounts", "points_products", "points_orders", + // ... 其余含 tenant_id 的表 +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for table in TENANT_TABLES { + let sql = format!( + "ALTER TABLE {} ENABLE ROW LEVEL SECURITY; + CREATE POLICY tenant_isolation ON {} + USING (tenant_id::text = current_setting('app.current_tenant_id', true));", + table, table + ); + manager.get_connection().execute_unprepared(&sql).await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for table in TENANT_TABLES { + let sql = format!( + "DROP POLICY IF EXISTS tenant_isolation ON {}; + ALTER TABLE {} DISABLE ROW LEVEL SECURITY;", + table, table + ); + manager.get_connection().execute_unprepared(&sql).await?; + } + Ok(()) + } +} +``` + +- [ ] **Step 3: 注册并编译验证** + +```bash +cargo check --workspace +``` + +- [ ] **Step 4: 启动后端验证 RLS 生效** + +使用不同 tenant_id 的用户登录,确认只能看到自己租户的数据。 + +- [ ] **Step 5: 提交** + +```bash +git commit -m "feat(db): 为所有 tenant 表启用 RLS policy 兜底" +``` + +--- + +## Chunk 3: 批次 B — 事件架构 + +### Task 8: B1 — 危急值告警 Entity + Migration + +**Files:** +- Create: `crates/erp-health/src/entity/critical_alert.rs` +- Create: `crates/erp-server/migration/src/m20260429_000091_critical_alerts.rs` +- Modify: `crates/erp-health/src/entity/mod.rs` +- Modify: `crates/erp-server/migration/src/lib.rs` + +- [ ] **Step 1: 创建 Migration — critical_alerts + critical_alert_responses 两张表** + +遵循现有 Entity 模式(UUID v7 主键、tenant_id、软删除、乐观锁)。 + +```rust +// crates/erp-server/migration/src/m20260429_000091_critical_alerts.rs +// up(): 创建 critical_alerts 表和 critical_alert_responses 表 +// down(): DROP TABLE critical_alert_responses; DROP TABLE critical_alerts; +// 详见设计规格 §3.1.3 的 SQL +``` + +- [ ] **Step 2: 创建 SeaORM Entity** + +```rust +// crates/erp-health/src/entity/critical_alert.rs +// Model 字段: id, tenant_id, patient_id, alert_type, metric_name, +// metric_value, threshold_value, severity, status, acknowledged_by, +// acknowledged_at, escalation_level, created_at, updated_at, +// created_by, updated_by, deleted_at, version +// Relation: patient (belongs_to) +``` + +- [ ] **Step 3: 注册并编译** + +```bash +cargo check --workspace +``` + +- [ ] **Step 4: 提交** + +```bash +git commit -m "feat(health): 新增 critical_alerts + responses 表 + Entity" +``` + +--- + +### Task 9: B1 — 危急值告警 Service + +**Files:** +- Create: `crates/erp-health/src/service/critical_alert_service.rs` +- Modify: `crates/erp-health/src/service/mod.rs` + +- [ ] **Step 1: 创建 service 模块** + +```rust +// crates/erp-health/src/service/critical_alert_service.rs +//! 危急值告警 Service — 创建告警、确认、升级 + +/// 消费 health_data.critical_alert 事件,创建告警记录 +pub async fn handle_critical_alert_event( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + metric_name: &str, + metric_value: &str, + threshold_value: &str, +) -> HealthResult { + // 1. 创建 critical_alert 记录(status = pending) + // 2. 返回 alert_id +} + +/// 医生确认告警 +pub async fn acknowledge_alert( + db: &DatabaseConnection, + tenant_id: Uuid, + alert_id: Uuid, + responder_id: Uuid, + notes: Option, +) -> HealthResult<()> { + // 1. 更新 status = acknowledged, acknowledged_by, acknowledged_at + // 2. 创建 critical_alert_response 记录 + // 3. 检查乐观锁 version +} + +/// 升级扫描 — 由定时任务每分钟调用 +pub async fn scan_escalation( + db: &DatabaseConnection, + tenant_id: Uuid, +) -> HealthResult> { + // 1. 查询 status = pending 且 created_at < 30min 的 → escalation_level = 1 + // 2. 查询 status = pending 且 created_at < 60min 的 → escalation_level = 2 + // 3. 返回升级的 alert_id 列表 +} +``` + +- [ ] **Step 2: 注册到 service/mod.rs** + +```rust +pub mod critical_alert_service; +``` + +- [ ] **Step 3: 编译验证** + +```bash +cargo check -p erp-health +``` + +- [ ] **Step 4: 提交** + +```bash +git commit -m "feat(health): 危急值告警 service — 创建/确认/升级" +``` + +--- + +### Task 10: B1 — 危急值告警 Handler + 事件注册 + +**Files:** +- Create: `crates/erp-health/src/handler/critical_alert_handler.rs` +- Modify: `crates/erp-health/src/handler/mod.rs` +- Modify: `crates/erp-health/src/event.rs` + +- [ ] **Step 1: 创建 handler** + +```rust +// crates/erp-health/src/handler/critical_alert_handler.rs +// 端点: +// GET /health/critical-alerts — 列表(待确认 + 已升级) +// GET /health/critical-alerts/:id — 详情 +// POST /health/critical-alerts/:id/acknowledge — 确认 +// 权限: health.critical-alert.list / health.critical-alert.manage +``` + +- [ ] **Step 2: 在 event.rs 的 register_handlers_with_state 中添加消费者** + +```rust +// 在 register_handlers_with_state() 中新增: +let (mut critical_rx, _critical_handle) = state.event_bus + .subscribe_filtered("health_data.".to_string()); +let critical_db = state.db.clone(); +tokio::spawn(async move { + loop { + match critical_rx.recv().await { + Some(event) if event.event_type == HEALTH_DATA_CRITICAL_ALERT => { + if erp_core::events::is_event_processed(&critical_db, event.id, "critical_alert_consumer").await.unwrap_or(false) { + continue; // 幂等:已处理 + } + // 从 payload 提取参数,调用 critical_alert_service::handle_critical_alert_event + // 成功后 mark_event_processed + } + Some(_) => {} + None => break, + } + } +}); + +// 同时监听 alert.triggered(alert_engine 发布的事件) +let (mut alert_rx, _) = state.event_bus.subscribe_filtered("alert.".to_string()); +// ... 类似处理 +``` + +- [ ] **Step 3: 注册升级定时任务** + +在 `on_startup()` 中添加 tokio::spawn 定时扫描: + +```rust +tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(e) = critical_alert_service::scan_escalation(&db, tenant_id).await { + tracing::error!(error = %e, "危急值升级扫描失败"); + } + } +}); +``` + +- [ ] **Step 4: 编译 + 功能验证** + +```bash +cargo check --workspace +# 启动后端,通过 Swagger 触发健康数据录入超阈值,确认告警记录创建 +``` + +- [ ] **Step 5: 提交** + +```bash +git commit -m "feat(health): 危急值告警消费者 — 30min 分级升级 + 幂等处理" +``` + +--- + +### Task 11: B2 — EventBus Dead-Letter + +**Files:** +- Create: `crates/erp-server/migration/src/m20260429_000092_dead_letter_events.rs` +- Create: `crates/erp-core/src/entity/dead_letter_event.rs` +- Modify: `crates/erp-core/src/events.rs` +- Create: `crates/erp-server/src/handler/dead_letter_handler.rs` + +- [ ] **Step 1: 创建 dead_letter_events Migration + Entity** + +```sql +CREATE TABLE dead_letter_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID, + original_event_id UUID NOT NULL, + event_type VARCHAR(128) NOT NULL, + payload JSONB, + consumer_id VARCHAR(128) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); +``` + +- [ ] **Step 2: 在 EventBus 中添加失败处理逻辑** + +在 `events.rs` 中新增 `consume_with_retry` 辅助函数: + +```rust +pub async fn consume_with_retry( + db: &DatabaseConnection, + event: &DomainEvent, + consumer_id: &str, + max_attempts: u32, + handler: F, +) -> ConsumeResult +where + F: FnOnce(&DomainEvent) -> Fut, + Fut: std::future::Future>, +{ + if is_event_processed(db, event.id, consumer_id).await? { + return ConsumeResult::AlreadyProcessed; + } + match handler(event).await { + Ok(()) => { + mark_event_processed(db, event.id, consumer_id).await?; + ConsumeResult::Success + } + Err(err) => { + // 转入 dead_letter_events + insert_dead_letter(db, event, consumer_id, &err).await?; + ConsumeResult::DeadLettered(err) + } + } +} +``` + +- [ ] **Step 3: 创建 admin API 端点** + +``` +GET /api/v1/admin/dead-letter-events — 列表 +POST /api/v1/admin/dead-letter-events/:id/retry — 重试 +``` + +- [ ] **Step 4: 编译 + 提交** + +```bash +cargo check --workspace +git commit -m "feat(core): EventBus dead-letter + consume_with_retry 辅助函数" +``` + +--- + +## Chunk 4: 批次 B 后半 + 批次 C + +### Task 12: B3 — 积分系统拆分为 erp-points crate + +> 这是工作量最大的任务(~1 天),建议作为独立 subagent 任务执行。 + +**Files:** +- Create: `crates/erp-points/` 整个 crate 目录 +- Create: `crates/erp-points/Cargo.toml` +- Create: `crates/erp-points/src/{lib,module,state,event,error}.rs` +- Move: 6 个 entity 从 `erp-health/src/entity/points_*` → `erp-points/src/entity/` +- Move: handler 从 `erp-health/src/handler/points_handler.rs` → `erp-points/src/handler/` +- Refactor: `points_service.rs`(1805 行)拆为 6 个子模块 +- Modify: `Cargo.toml`(workspace members 加 erp-points) +- Modify: `crates/erp-server/src/main.rs`(注册新模块 + 路由) +- Delete: `erp-health` 中的积分相关文件 + +- [ ] **Step 1: 创建 erp-points crate 骨架** + +```bash +mkdir -p crates/erp-points/src/{entity,service,handler,dto} +``` + +`Cargo.toml` 参照 `erp-health/Cargo.toml` 模式,依赖 `erp-core.workspace = true`。 + +- [ ] **Step 2: 实现标准模块** + +- `lib.rs` — 导出模块 +- `module.rs` — 实现 `ErpModule` trait(name: "points", id: "erp-points") +- `state.rs` — `PointsState { db, event_bus }` +- `error.rs` — 错误类型 + +- [ ] **Step 3: 迁移 6 个 Entity 文件** + +从 `crates/erp-health/src/entity/` 复制: +- `points_account.rs`, `points_checkin.rs`, `points_order.rs` +- `points_product.rs`, `points_rule.rs`, `points_transaction.rs` + +更新 `mod.rs` 中的内部引用(`super::` → 指向 erp-points 的 entity)。 + +- [ ] **Step 4: 迁移并拆分 points_service.rs** + +将 1805 行拆为: +- `account_service.rs` — 积分账户 CRUD +- `product_service.rs` — 商品管理 +- `order_service.rs` — 订单 + 兑换 +- `check_in_service.rs` — 签到 +- `rule_engine.rs` — 积分规则引擎 +- `exchange_service.rs` — 线下活动兑换 + +- [ ] **Step 5: 迁移 handler** + +- [ ] **Step 6: 注册到 workspace + erp-server** + +在根 `Cargo.toml` 添加 `erp-points` 到 workspace members。 +在 `erp-server/src/main.rs` 中: +- 添加 `use erp_points::PointsModule;` +- 在 `registry.register()` 链中添加 `.register(points_module)` +- 在路由 merge 中添加 `.merge(erp_points::PointsModule::protected_routes())` + +- [ ] **Step 7: 删除 erp-health 中的积分代码** + +- 删除 6 个 entity 文件 +- 删除 points_service.rs(或清空为 re-export) +- 删除 points_handler.rs +- 清理 entity/mod.rs 和 handler/mod.rs 中的 mod 声明 + +- [ ] **Step 8: 事件契约** + +在 `erp-points/src/event.rs` 中: +- 订阅 `lab_report.uploaded`, `patient.verified`, `daily_monitoring.created` +- 发布 `points.earned`, `points.exchanged`, `points.expired`, `points.balance.changed` + +- [ ] **Step 9: 编译 + 全链路验证** + +```bash +cargo check --workspace +cargo test --workspace -- --test-threads=4 +# 启动后端,通过 Swagger 验证积分相关 API 正常 +``` + +- [ ] **Step 10: 提交** + +```bash +git commit -m "refactor: 积分系统拆分为独立 erp-points crate(6 Entity + 6 Service)" +``` + +--- + +### Task 13: C1 — 安全测试 + +**Files:** +- Create: `crates/erp-health/tests/security_tests.rs` + +- [ ] **Step 1: 创建安全测试文件** + +```rust +// crates/erp-health/tests/security_tests.rs + +#[cfg(test)] +mod tests { + use erp_health::HealthCrypto; + + #[test] + fn test_pii_encrypt_decrypt_roundtrip() { + let crypto = HealthCrypto::dev_default(); + let plaintext = "110101199001011234"; + let encrypted = crypto.encrypt(plaintext).unwrap(); + let decrypted = crypto.decrypt(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } + + #[test] + fn test_blind_index_deterministic() { + let crypto = HealthCrypto::dev_default(); + let h1 = crypto.hmac_hash("13812345678"); + let h2 = crypto.hmac_hash("13812345678"); + assert_eq!(h1, h2); + } + + #[test] + fn test_blind_index_different_inputs() { + let crypto = HealthCrypto::dev_default(); + let h1 = crypto.hmac_hash("13812345678"); + let h2 = crypto.hmac_hash("13898765432"); + assert_ne!(h1, h2); + } + + #[test] + fn test_ciphertext_has_version_prefix() { + let crypto = HealthCrypto::dev_default(); + let encrypted = crypto.encrypt("test").unwrap(); + assert!(encrypted.starts_with("v1|")); + } + + #[test] + fn test_decrypt_legacy_no_prefix() { + // 确认旧格式(无版本前缀)仍可解密 + let crypto = HealthCrypto::dev_default(); + // 手动构造旧格式密文... + } + + #[test] + fn test_key_rotation() { + let old_crypto = HealthCrypto::dev_default(); + let hex_key = "ab".repeat(32); + let new_crypto = HealthCrypto::from_keys(&hex_key, &hex_key).unwrap(); + + let plaintext = "敏感数据"; + let encrypted = old_crypto.encrypt(plaintext).unwrap(); + + // 用旧密钥解密 + let decrypted = old_crypto.decrypt(&encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + + // 用新密钥解密应失败 + assert!(new_crypto.decrypt(&encrypted).is_err()); + } +} +``` + +- [ ] **Step 2: 运行测试** + +```bash +cargo test -p erp-health --test security_tests -- --test-threads=1 +``` + +- [ ] **Step 3: 提交** + +```bash +git commit -m "test(health): 安全测试 — PII 加解密 + 盲索引 + 密钥轮换 + 版本前缀" +``` + +--- + +### Task 14: C4 — 前端 chunk 拆分优化 + +**Files:** +- Modify: `apps/web/vite.config.ts` + +- [ ] **Step 1: 分析当前 chunk 大小** + +```bash +cd apps/web && pnpm build 2>&1 | grep -E "kB|MB|chunk" +``` + +- [ ] **Step 2: 配置 manualChunks** + +在 `vite.config.ts` 的 build.rollupOptions.output 中: + +```typescript +manualChunks: { + 'vendor-antd': ['antd', '@ant-design/icons'], + 'vendor-charts': ['echarts'], + 'vendor-react': ['react', 'react-dom', 'react-router-dom'], +}, +``` + +- [ ] **Step 3: echarts 按需引入** + +将 `import * as echarts from 'echarts'` 改为只导入使用的图表类型: + +```typescript +import * as echarts from 'echarts/core'; +import { LineChart, BarChart } from 'echarts/charts'; +import { GridComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, CanvasRenderer]); +``` + +- [ ] **Step 4: 构建验证** + +```bash +pnpm build +``` + +确认:首屏 JS < 500KB(gzip 后)。 + +- [ ] **Step 5: 提交** + +```bash +git commit -m "perf(web): 前端 chunk 拆分 — antd/charts vendor 分离 + echarts 按需引入" +``` + +--- + +## 执行摘要 + +| Chunk | Tasks | 内容 | +|-------|-------|------| +| 1 | T1-T4 | 测试基础设施 + SQL 审计 + SSE 验证 + 清缓存验证 | +| 2 | T5-T7 | Crypto 版本标识 + Blind Index Entity + RLS Policy | +| 3 | T8-T11 | 危急值告警全链路 + EventBus Dead-Letter | +| 4 | T12-T14 | 积分拆 erp-points + 安全测试 + 前端优化 | + +**总计 14 个 Task,预估 8-12 天。** + +**关键依赖链:** +``` +T1(C0) → T5(A4) → T6(A5) +T1(C0) → T8(B1-Entity) → T9(B1-Service) → T10(B1-Handler) +T1(C0) → T13(C1-安全测试) +T12(B3-积分拆分) 独立,可与 T5-T7 并行 +T14(C4-前端优化) 独立,可随时执行 +```