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:
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 SQL,SeaORM 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user