- Chunk 1: 测试基础设施 + SQL 审计 + SSE + 清缓存 - Chunk 2: Crypto 版本标识 + Blind Index + RLS Policy - Chunk 3: 危急值告警全链路 + EventBus Dead-Letter - Chunk 4: 积分拆 erp-points + 安全测试 + 前端优化
1003 lines
29 KiB
Markdown
1003 lines
29 KiB
Markdown
# 技术债清理实施计划
|
||
|
||
> **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<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
|
||
```
|
||
|
||
预期:所有测试 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<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.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<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` 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-前端优化) 独立,可随时执行
|
||
```
|