Files
hms/docs/superpowers/plans/2026-04-28-technical-debt-cleanup-plan.md
iven aa5b26bf12
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
docs(plan): 技术债清理实施计划 — 14 个 Task / 4 个 Chunk
- Chunk 1: 测试基础设施 + SQL 审计 + SSE + 清缓存
- Chunk 2: Crypto 版本标识 + Blind Index + RLS Policy
- Chunk 3: 危急值告警全链路 + EventBus Dead-Letter
- Chunk 4: 积分拆 erp-points + 安全测试 + 前端优化
2026-04-28 11:07:54 +08:00

1003 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术债清理实施计划
> **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 DesignWeb、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<String> = 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<String> {
// ... 现有加密逻辑 ...
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<String> {
// 剥离版本前缀
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
```
预期:所有测试 PASS7 个旧测试 + 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<Uuid> {
// 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<String>,
) -> 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<Vec<Uuid>> {
// 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.triggeredalert_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<F, Fut>(
db: &DatabaseConnection,
event: &DomainEvent,
consumer_id: &str,
max_attempts: u32,
handler: F,
) -> ConsumeResult
where
F: FnOnce(&DomainEvent) -> Fut,
Fut: std::future::Future<Output = Result<(), String>>,
{
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` traitname: "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 crate6 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 < 500KBgzip 后)。
- [ ] **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-前端优化) 独立,可随时执行
```