docs: 修正测试策略 spec 的事实性错误

修正 spec review 发现的问题:
- C-1: TestDb 实际是本地 PostgreSQL 隔离,非 Testcontainers
- C-2: E2E 已有 4 spec/10 测试,非零测试
- 补充 6 个遗漏的 service(alert/daily_monitoring/critical_value_threshold 等)
- 增加 Phase 0 基础设施搭建
- 修正 CI 配置(增加 PostgreSQL service、验证链)
- 补充 5 个遗漏风险项和回退策略
- 统一"全量 80%"目标的准确含义
This commit is contained in:
iven
2026-04-27 00:21:02 +08:00
parent 8cd65f7be5
commit 5b81a0051f
33 changed files with 2380 additions and 82 deletions

View File

@@ -72,6 +72,11 @@ mod m20260427_000069_add_dialysis_record_key_version;
mod m20260427_000070_add_lab_report_key_version;
mod m20260427_000071_add_diagnosis_key_version;
mod m20260427_000072_widen_encrypted_phone_columns;
mod m20260426_000073_create_device_readings;
mod m20260426_000074_create_vital_signs_hourly;
mod m20260426_000075_create_patient_devices;
mod m20260426_000076_create_alert_rules;
mod m20260426_000077_create_alerts;
pub struct Migrator;
@@ -151,6 +156,11 @@ impl MigratorTrait for Migrator {
Box::new(m20260427_000070_add_lab_report_key_version::Migration),
Box::new(m20260427_000071_add_diagnosis_key_version::Migration),
Box::new(m20260427_000072_widen_encrypted_phone_columns::Migration),
Box::new(m20260426_000073_create_device_readings::Migration),
Box::new(m20260426_000074_create_vital_signs_hourly::Migration),
Box::new(m20260426_000075_create_patient_devices::Migration),
Box::new(m20260426_000076_create_alert_rules::Migration),
Box::new(m20260426_000077_create_alerts::Migration),
]
}
}

View File

@@ -0,0 +1,67 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 分区表必须用 raw SQLSeaORM schema builder 不支持 PARTITION BY
let sql = r#"
CREATE TABLE IF NOT EXISTS device_readings (
id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
patient_id UUID NOT NULL,
device_id VARCHAR(64),
device_type VARCHAR(32) NOT NULL,
device_model VARCHAR(64),
raw_value JSONB NOT NULL,
measured_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
) PARTITION BY RANGE (measured_at);
"#;
manager.get_connection().execute_unprepared(sql).await?;
// 分区表主键必须包含分区键
manager.get_connection().execute_unprepared(
"ALTER TABLE device_readings ADD PRIMARY KEY (id, measured_at);"
).await?;
// 核心查询索引
manager.get_connection().execute_unprepared(
"CREATE INDEX idx_dr_tenant_patient ON device_readings (tenant_id, patient_id, measured_at DESC);"
).await?;
manager.get_connection().execute_unprepared(
"CREATE INDEX idx_dr_device_type ON device_readings (tenant_id, device_type, measured_at DESC);"
).await?;
// 创建初始分区(当前月 + 未来 3 个月)
for (suffix, start, end) in [
("2026_05", "2026-05-01", "2026-06-01"),
("2026_06", "2026-06-01", "2026-07-01"),
("2026_07", "2026-07-01", "2026-08-01"),
("2026_08", "2026-08-01", "2026-09-01"),
] {
let partition_sql = format!(
"CREATE TABLE IF NOT EXISTS device_readings_{suffix} PARTITION OF device_readings FOR VALUES FROM ('{start}') TO ('{end}');"
);
manager.get_connection().execute_unprepared(&partition_sql).await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for suffix in ["2026_05", "2026_06", "2026_07", "2026_08"] {
manager.get_connection().execute_unprepared(
&format!("DROP TABLE IF EXISTS device_readings_{suffix};")
).await.ok();
}
manager.get_connection().execute_unprepared(
"DROP TABLE IF EXISTS device_readings;"
).await?;
Ok(())
}
}

View File

@@ -0,0 +1,58 @@
use sea_orm_migration::prelude::*;
#[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(Alias::new("vital_signs_hourly"))
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key().default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("device_type")).string().not_null())
.col(ColumnDef::new(Alias::new("hour_start")).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(Alias::new("min_val")).double())
.col(ColumnDef::new(Alias::new("max_val")).double())
.col(ColumnDef::new(Alias::new("avg_val")).double().not_null())
.col(ColumnDef::new(Alias::new("sample_count")).integer().not_null().default(1))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
).await?;
// UNIQUE 约束 — 每个患者每个指标每小时一条
manager.create_index(
Index::create()
.name("idx_vsh_unique")
.table(Alias::new("vital_signs_hourly"))
.col(Alias::new("tenant_id"))
.col(Alias::new("patient_id"))
.col(Alias::new("device_type"))
.col(Alias::new("hour_start"))
.unique()
.to_owned(),
).await?;
// 查询索引
manager.create_index(
Index::create()
.name("idx_vsh_tenant_patient")
.table(Alias::new("vital_signs_hourly"))
.col(Alias::new("tenant_id"))
.col(Alias::new("patient_id"))
.col(Alias::new("device_type"))
.col(Alias::new("hour_start"))
.to_owned(),
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Alias::new("vital_signs_hourly")).to_owned()).await
}
}

View File

@@ -0,0 +1,57 @@
use sea_orm_migration::prelude::*;
#[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(Alias::new("patient_devices"))
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key().default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("device_id")).string().not_null())
.col(ColumnDef::new(Alias::new("device_model")).string())
.col(ColumnDef::new(Alias::new("device_type")).string())
.col(ColumnDef::new(Alias::new("bound_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("last_sync_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
).await?;
// 每个患者每个设备只能绑定一次
manager.create_index(
Index::create()
.name("idx_pd_unique")
.table(Alias::new("patient_devices"))
.col(Alias::new("tenant_id"))
.col(Alias::new("patient_id"))
.col(Alias::new("device_id"))
.unique()
.to_owned(),
).await?;
// 查询索引
manager.create_index(
Index::create()
.name("idx_pd_tenant_patient")
.table(Alias::new("patient_devices"))
.col(Alias::new("tenant_id"))
.col(Alias::new("patient_id"))
.to_owned(),
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Alias::new("patient_devices")).to_owned()).await
}
}

View File

@@ -0,0 +1,50 @@
use sea_orm_migration::prelude::*;
#[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(Alias::new("alert_rules"))
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key().default(Expr::cust("gen_random_uuid()")))
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("name")).string().not_null())
.col(ColumnDef::new(Alias::new("description")).text())
.col(ColumnDef::new(Alias::new("device_type")).string().not_null())
.col(ColumnDef::new(Alias::new("condition_type")).string().not_null())
.col(ColumnDef::new(Alias::new("condition_params")).json_binary().not_null().default(Expr::cust("'{}'::jsonb")))
.col(ColumnDef::new(Alias::new("severity")).string().not_null().default("'warning'"))
.col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(Expr::cust("true")))
.col(ColumnDef::new(Alias::new("apply_tags")).json_binary())
.col(ColumnDef::new(Alias::new("notify_roles")).json_binary().default(Expr::cust("'[]'::jsonb")))
.col(ColumnDef::new(Alias::new("cooldown_minutes")).integer().not_null().default(60))
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().default(Expr::cust("NOW()")))
.col(ColumnDef::new(Alias::new("created_by")).uuid())
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
.to_owned(),
).await?;
// 查询索引
manager.create_index(
Index::create()
.name("idx_ar_tenant_active")
.table(Alias::new("alert_rules"))
.col(Alias::new("tenant_id"))
.col(Alias::new("is_active"))
.col(Alias::new("device_type"))
.to_owned(),
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Alias::new("alert_rules")).to_owned()).await
}
}

View File

@@ -0,0 +1,98 @@
use sea_orm_migration::prelude::*;
#[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(TenantCryptoKey::Table)
.col(
ColumnDef::new(TenantCryptoKey::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(TenantCryptoKey::TenantId).uuid().not_null())
.col(ColumnDef::new(TenantCryptoKey::EncryptedDek).string_len(128).not_null())
.col(
ColumnDef::new(TenantCryptoKey::KeyVersion)
.integer()
.not_null()
.default(1),
)
.col(
ColumnDef::new(TenantCryptoKey::IsActive)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(TenantCryptoKey::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(TenantCryptoKey::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(TenantCryptoKey::CreatedBy).uuid())
.col(ColumnDef::new(TenantCryptoKey::UpdatedBy).uuid())
.col(ColumnDef::new(TenantCryptoKey::DeletedAt).timestamp_with_time_zone())
.col(
ColumnDef::new(TenantCryptoKey::Version)
.integer()
.not_null()
.default(1),
)
.index(
Index::create()
.col(TenantCryptoKey::TenantId)
.col(TenantCryptoKey::KeyVersion)
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_tenant_crypto_keys_tenant")
.table(TenantCryptoKey::Table)
.col(TenantCryptoKey::TenantId)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(TenantCryptoKey::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum TenantCryptoKey {
Table,
Id,
TenantId,
EncryptedDek,
KeyVersion,
IsActive,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}