fix(server+health): 修复权限同步 + 迁移幂等性 + 缺失菜单种子数据
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

- sync_module_permissions 每次启动都确保 admin 拥有所有权限(修复 CRITICAL-001)
- 新增迁移 m20260505_000116: 补充 11 项缺失的健康管理菜单(多租户安全)
- 修复 000101: UUID 格式错误(缺少第 4 段)
- 修复 000104/000106/000107: Expr::val → Expr::cust(SQL 函数不应被引号包裹)
- 修复 000109: 外键创建改为 IF NOT EXISTS 模式
- 修复 000110: 表名 critical_alerts → critical_alert(匹配实际表名)
- 修复 000111/000112: create_table + create_index 添加 if_not_exists()
- 修复 000113: 改为 raw SQL 幂等模式,修正 FK 目标表名 patients → patient
This commit is contained in:
iven
2026-05-05 02:02:45 +08:00
parent bef2ea7169
commit 99dad17eac
12 changed files with 325 additions and 469 deletions

View File

@@ -115,6 +115,7 @@ mod m20260505_000112_create_shift_management;
mod m20260505_000113_create_ble_gateways;
mod m20260505_000114_dialysis_record_add_workflow_instance;
mod m20260505_000115_family_member_health_proxy;
mod m20260505_000116_seed_missing_health_menus;
pub struct Migrator;
@@ -237,6 +238,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260505_000113_create_ble_gateways::Migration),
Box::new(m20260505_000114_dialysis_record_add_workflow_instance::Migration),
Box::new(m20260505_000115_family_member_health_proxy::Migration),
Box::new(m20260505_000116_seed_missing_health_menus::Migration),
]
}
}

View File

@@ -195,7 +195,7 @@ async fn insert_item(
) -> Result<(), DbErr> {
// 生成条目 ID基于字典 ID 后缀 + 序号
let suffix = &dict_id[24..];
let item_id = format!("d200{idx:04x}-0000-0000-{suffix}");
let item_id = format!("d200{idx:04x}-0000-0000-0000-{suffix}");
let color_sql = match color {
Some(c) => format!("'{c}'"),
None => "NULL".to_string(),

View File

@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.default(Expr::val("gen_random_uuid()")),
.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())
@@ -29,13 +29,13 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.col(
ColumnDef::new(Alias::new("version"))

View File

@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.default(Expr::val("gen_random_uuid()")),
.default(Expr::cust("gen_random_uuid()")),
)
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(
@@ -56,13 +56,13 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())

View File

@@ -39,7 +39,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.to_owned(),
)
@@ -53,7 +53,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
.default(Expr::cust("NOW()")),
)
.to_owned(),
)

View File

@@ -6,104 +6,43 @@ pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// follow_up_task.related_appointment_id → appointment.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_follow_up_task_appointment")
.from(Alias::new("follow_up_task"), Alias::new("related_appointment_id"))
.to(Alias::new("appointment"), Alias::new("id"))
.on_delete(ForeignKeyAction::SetNull)
.to_owned(),
)
.await?;
let db = manager.get_connection();
// points_transaction.account_id → points_account.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_points_transaction_account")
.from(Alias::new("points_transaction"), Alias::new("account_id"))
.to(Alias::new("points_account"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
// 安全创建外键:先检查是否已存在,不存在才创建
let fks: &[(&str, &str, &str, &str, &str, &str)] = &[
("fk_follow_up_task_appointment", "follow_up_task", "related_appointment_id", "appointment", "id", "SET NULL"),
("fk_points_transaction_account", "points_transaction", "account_id", "points_account", "id", "CASCADE"),
("fk_points_transaction_rule", "points_transaction", "rule_id", "points_rule", "id", "SET NULL"),
("fk_points_transaction_order", "points_transaction", "order_id", "points_order", "id", "SET NULL"),
("fk_points_order_product", "points_order", "product_id", "points_product", "id", "RESTRICT"),
("fk_points_order_patient", "points_order", "patient_id", "patient", "id", "CASCADE"),
("fk_offline_event_registration_event", "offline_event_registration", "event_id", "offline_event", "id", "CASCADE"),
("fk_offline_event_registration_patient", "offline_event_registration", "patient_id", "patient", "id", "CASCADE"),
];
// points_transaction.rule_id → points_rule.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_points_transaction_rule")
.from(Alias::new("points_transaction"), Alias::new("rule_id"))
.to(Alias::new("points_rule"), Alias::new("id"))
.on_delete(ForeignKeyAction::SetNull)
.to_owned(),
)
.await?;
for &(name, from_table, from_col, to_table, to_col, on_delete) in fks {
let sql = format!(
r#"DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = '{name}' AND table_name = '{from_table}'
) THEN
ALTER TABLE {from_table}
ADD CONSTRAINT {name}
FOREIGN KEY ({from_col}) REFERENCES {to_table}({to_col})
ON DELETE {on_delete};
END IF;
END$$;"#
);
db.execute_unprepared(&sql).await?;
}
// points_transaction.order_id → points_order.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_points_transaction_order")
.from(Alias::new("points_transaction"), Alias::new("order_id"))
.to(Alias::new("points_order"), Alias::new("id"))
.on_delete(ForeignKeyAction::SetNull)
.to_owned(),
)
.await?;
// points_order.product_id → points_product.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_points_order_product")
.from(Alias::new("points_order"), Alias::new("product_id"))
.to(Alias::new("points_product"), Alias::new("id"))
.on_delete(ForeignKeyAction::Restrict)
.to_owned(),
)
.await?;
// points_order.patient_id → patient.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_points_order_patient")
.from(Alias::new("points_order"), Alias::new("patient_id"))
.to(Alias::new("patient"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
// offline_event_registration.event_id → offline_event.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_offline_event_registration_event")
.from(Alias::new("offline_event_registration"), Alias::new("event_id"))
.to(Alias::new("offline_event"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
// offline_event_registration.patient_id → patient.id
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_offline_event_registration_patient")
.from(Alias::new("offline_event_registration"), Alias::new("patient_id"))
.to(Alias::new("patient"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let fks = [
"fk_offline_event_registration_patient",
"fk_offline_event_registration_event",
@@ -114,15 +53,12 @@ impl MigrationTrait for Migration {
"fk_points_transaction_account",
"fk_follow_up_task_appointment",
];
for fk in fks {
manager
.drop_foreign_key(
ForeignKey::drop()
.name(fk)
.table(Alias::new("dummy"))
.to_owned(),
)
.await?;
for fk in &fks {
db.execute_unprepared(&format!(
"ALTER TABLE dummy DROP CONSTRAINT IF EXISTS {fk}"
))
.await
.ok();
}
Ok(())
}

View File

@@ -6,11 +6,11 @@ pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// critical_alerts.version: i64 → i32
// critical_alert.version: i64 → i32
manager
.alter_table(
Table::alter()
.table(Alias::new("critical_alerts"))
.table(Alias::new("critical_alert"))
.modify_column(
ColumnDef::new(Alias::new("version"))
.integer()
@@ -21,11 +21,11 @@ impl MigrationTrait for Migration {
)
.await?;
// critical_alert_responses.version: i64 → i32
// critical_alert_response.version: i64 → i32
manager
.alter_table(
Table::alter()
.table(Alias::new("critical_alert_responses"))
.table(Alias::new("critical_alert_response"))
.modify_column(
ColumnDef::new(Alias::new("version"))
.integer()
@@ -41,7 +41,7 @@ impl MigrationTrait for Migration {
manager
.alter_table(
Table::alter()
.table(Alias::new("critical_alerts"))
.table(Alias::new("critical_alert"))
.modify_column(
ColumnDef::new(Alias::new("version"))
.big_integer()
@@ -55,7 +55,7 @@ impl MigrationTrait for Migration {
manager
.alter_table(
Table::alter()
.table(Alias::new("critical_alert_responses"))
.table(Alias::new("critical_alert_response"))
.modify_column(
ColumnDef::new(Alias::new("version"))
.big_integer()

View File

@@ -11,6 +11,7 @@ impl MigrationTrait for Migration {
.create_table(
Table::create()
.table(Alias::new("care_plans"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
@@ -35,7 +36,7 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("goals"))
.json_binary()
.not_null()
.default("[]"),
.default(Expr::cust("'[]'::jsonb")),
)
.col(ColumnDef::new(Alias::new("start_date")).date())
.col(ColumnDef::new(Alias::new("end_date")).date())
@@ -66,6 +67,7 @@ impl MigrationTrait for Migration {
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_care_plans_tenant_patient")
.table(Alias::new("care_plans"))
.col(Alias::new("tenant_id"))
@@ -78,6 +80,7 @@ impl MigrationTrait for Migration {
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_care_plans_tenant_status")
.table(Alias::new("care_plans"))
.col(Alias::new("tenant_id"))
@@ -92,6 +95,7 @@ impl MigrationTrait for Migration {
.create_table(
Table::create()
.table(Alias::new("care_plan_items"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
@@ -153,6 +157,7 @@ impl MigrationTrait for Migration {
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_care_plan_items_tenant_plan")
.table(Alias::new("care_plan_items"))
.col(Alias::new("tenant_id"))
@@ -167,6 +172,7 @@ impl MigrationTrait for Migration {
.create_table(
Table::create()
.table(Alias::new("care_plan_outcomes"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
@@ -231,6 +237,7 @@ impl MigrationTrait for Migration {
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_care_plan_outcomes_tenant_plan")
.table(Alias::new("care_plan_outcomes"))
.col(Alias::new("tenant_id"))

View File

@@ -6,245 +6,100 @@ 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(Shift::Table)
.col(
ColumnDef::new(Shift::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Shift::TenantId).uuid().not_null())
.col(ColumnDef::new(Shift::ShiftDate).date().not_null())
.col(ColumnDef::new(Shift::Period).string_len(20).not_null())
.col(ColumnDef::new(Shift::NurseId).uuid())
.col(ColumnDef::new(Shift::Status).string_len(20).not_null().default("scheduled"))
.col(ColumnDef::new(Shift::Notes).text())
.col(ColumnDef::new(Shift::CreatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(Shift::UpdatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(Shift::CreatedBy).uuid())
.col(ColumnDef::new(Shift::UpdatedBy).uuid())
.col(ColumnDef::new(Shift::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(Shift::Version).integer().not_null().default(1))
.to_owned(),
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS shift (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
shift_date DATE NOT NULL,
period VARCHAR(20) NOT NULL,
nurse_id UUID,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS patient_assignment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
shift_id UUID NOT NULL,
patient_id UUID NOT NULL,
care_level VARCHAR(20) NOT NULL DEFAULT 'routine',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS handoff_log (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
from_shift_id UUID NOT NULL,
to_shift_id UUID NOT NULL,
patient_id UUID NOT NULL,
notes TEXT,
pending_items JSONB,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
"#,
)
.await?;
manager
.create_index(
Index::create()
.name("idx_shifts_tenant_date")
.table(Shift::Table)
.col(Shift::TenantId)
.col(Shift::ShiftDate)
.col(Shift::DeletedAt)
.to_owned(),
)
.await?;
// 索引(幂等)
let indexes = [
"CREATE INDEX IF NOT EXISTS idx_shifts_tenant_date ON shift (tenant_id, shift_date, deleted_at)",
"CREATE INDEX IF NOT EXISTS idx_shifts_tenant_nurse ON shift (tenant_id, nurse_id, deleted_at)",
"CREATE INDEX IF NOT EXISTS idx_patient_assignments_shift ON patient_assignment (tenant_id, shift_id, deleted_at)",
"CREATE INDEX IF NOT EXISTS idx_patient_assignments_patient ON patient_assignment (tenant_id, patient_id, deleted_at)",
"CREATE INDEX IF NOT EXISTS idx_handoff_log_to_shift ON handoff_log (tenant_id, to_shift_id, deleted_at)",
];
for sql in &indexes {
db.execute_unprepared(sql).await.ok();
}
manager
.create_index(
Index::create()
.name("idx_shifts_tenant_nurse")
.table(Shift::Table)
.col(Shift::TenantId)
.col(Shift::NurseId)
.col(Shift::DeletedAt)
.to_owned(),
)
.await?;
// 患者分配表
manager
.create_table(
Table::create()
.table(PatientAssignment::Table)
.col(
ColumnDef::new(PatientAssignment::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(PatientAssignment::TenantId).uuid().not_null())
.col(ColumnDef::new(PatientAssignment::ShiftId).uuid().not_null())
.col(ColumnDef::new(PatientAssignment::PatientId).uuid().not_null())
.col(ColumnDef::new(PatientAssignment::CareLevel).string_len(20).not_null().default("routine"))
.col(ColumnDef::new(PatientAssignment::Notes).text())
.col(ColumnDef::new(PatientAssignment::CreatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(PatientAssignment::UpdatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(PatientAssignment::CreatedBy).uuid())
.col(ColumnDef::new(PatientAssignment::UpdatedBy).uuid())
.col(ColumnDef::new(PatientAssignment::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(PatientAssignment::Version).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_patient_assignments_shift")
.from(PatientAssignment::Table, PatientAssignment::ShiftId)
.to(Shift::Table, Shift::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_patient_assignments_shift")
.table(PatientAssignment::Table)
.col(PatientAssignment::TenantId)
.col(PatientAssignment::ShiftId)
.col(PatientAssignment::DeletedAt)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_patient_assignments_patient")
.table(PatientAssignment::Table)
.col(PatientAssignment::TenantId)
.col(PatientAssignment::PatientId)
.col(PatientAssignment::DeletedAt)
.to_owned(),
)
.await?;
// 交接日志表
manager
.create_table(
Table::create()
.table(HandoffLog::Table)
.col(
ColumnDef::new(HandoffLog::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(HandoffLog::TenantId).uuid().not_null())
.col(ColumnDef::new(HandoffLog::FromShiftId).uuid().not_null())
.col(ColumnDef::new(HandoffLog::ToShiftId).uuid().not_null())
.col(ColumnDef::new(HandoffLog::PatientId).uuid().not_null())
.col(ColumnDef::new(HandoffLog::Notes).text())
.col(ColumnDef::new(HandoffLog::PendingItems).json_binary())
.col(ColumnDef::new(HandoffLog::CreatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(HandoffLog::UpdatedAt).timestamp_with_time_zone().not_null())
.col(ColumnDef::new(HandoffLog::CreatedBy).uuid())
.col(ColumnDef::new(HandoffLog::UpdatedBy).uuid())
.col(ColumnDef::new(HandoffLog::DeletedAt).timestamp_with_time_zone())
.col(ColumnDef::new(HandoffLog::Version).integer().not_null().default(1))
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_handoff_log_from_shift")
.from(HandoffLog::Table, HandoffLog::FromShiftId)
.to(Shift::Table, Shift::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_handoff_log_to_shift")
.from(HandoffLog::Table, HandoffLog::ToShiftId)
.to(Shift::Table, Shift::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_handoff_log_to_shift")
.table(HandoffLog::Table)
.col(HandoffLog::TenantId)
.col(HandoffLog::ToShiftId)
.col(HandoffLog::DeletedAt)
.to_owned(),
)
.await?;
// 外键(幂等)
let fks = [
("fk_patient_assignments_shift", "ALTER TABLE patient_assignment ADD CONSTRAINT fk_patient_assignments_shift FOREIGN KEY (shift_id) REFERENCES shift(id) ON DELETE CASCADE"),
("fk_handoff_log_from_shift", "ALTER TABLE handoff_log ADD CONSTRAINT fk_handoff_log_from_shift FOREIGN KEY (from_shift_id) REFERENCES shift(id) ON DELETE CASCADE"),
("fk_handoff_log_to_shift", "ALTER TABLE handoff_log ADD CONSTRAINT fk_handoff_log_to_shift FOREIGN KEY (to_shift_id) REFERENCES shift(id) ON DELETE CASCADE"),
];
for (name, sql) in &fks {
let check = format!(
"SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_name = '{name}'"
);
if let Some(row) = db.query_one(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres, check,
)).await? {
let count: i64 = row.try_get_by_index::<i64>(0).unwrap_or(0);
if count == 0 {
db.execute_unprepared(sql).await?;
}
}
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(HandoffLog::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(PatientAssignment::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Shift::Table).to_owned())
.await?;
let db = manager.get_connection();
db.execute_unprepared("DROP TABLE IF EXISTS handoff_log").await?;
db.execute_unprepared("DROP TABLE IF EXISTS patient_assignment").await?;
db.execute_unprepared("DROP TABLE IF EXISTS shift").await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Shift {
Table,
Id,
TenantId,
ShiftDate,
Period,
NurseId,
Status,
Notes,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum PatientAssignment {
Table,
Id,
TenantId,
ShiftId,
PatientId,
CareLevel,
Notes,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum HandoffLog {
Table,
Id,
TenantId,
FromShiftId,
ToShiftId,
PatientId,
Notes,
PendingItems,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}

View File

@@ -1,4 +1,4 @@
use sea_orm_migration::{prelude::*, schema::*};
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
@@ -6,125 +6,87 @@ 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("ble_gateways"))
.col(uuid("id").primary_key())
.col(uuid("tenant_id").not_null())
.col(string("gateway_id").unique_key().not_null())
.col(string("name").not_null())
.col(string("api_key_hash").not_null())
.col(string("api_key_prefix").not_null())
.col(string("status").default("active").not_null())
.col(string("firmware_version").null())
.col(string("ip_address").null())
.col(timestamp_with_time_zone("last_heartbeat_at").null())
.col(json("metadata").null())
.col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null())
.col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null())
.col(uuid("created_by").null())
.col(uuid("updated_by").null())
.col(timestamp_with_time_zone("deleted_at").null())
.col(integer("version").default(1).not_null())
.to_owned(),
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE IF NOT EXISTS ble_gateways (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
gateway_id VARCHAR NOT NULL UNIQUE,
name VARCHAR NOT NULL,
api_key_hash VARCHAR NOT NULL,
api_key_prefix VARCHAR NOT NULL,
status VARCHAR NOT NULL DEFAULT 'active',
firmware_version VARCHAR,
ip_address VARCHAR,
last_heartbeat_at TIMESTAMPTZ,
metadata JSON,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS gateway_patient_bindings (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
gateway_id_fk UUID NOT NULL,
patient_id UUID NOT NULL,
peripheral_mac VARCHAR,
device_type VARCHAR,
status VARCHAR NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1
);
"#,
)
.await?;
manager
.create_table(
Table::create()
.table(Alias::new("gateway_patient_bindings"))
.col(uuid("id").primary_key())
.col(uuid("tenant_id").not_null())
.col(uuid("gateway_id_fk").not_null())
.col(uuid("patient_id").not_null())
.col(string("peripheral_mac").null())
.col(string("device_type").null())
.col(string("status").default("active").not_null())
.col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null())
.col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null())
.col(uuid("created_by").null())
.col(uuid("updated_by").null())
.col(timestamp_with_time_zone("deleted_at").null())
.col(integer("version").default(1).not_null())
.to_owned(),
)
.await?;
// 索引(幂等)
let indexes = [
("idx_ble_gateways_tenant_id", "CREATE INDEX IF NOT EXISTS idx_ble_gateways_tenant_id ON ble_gateways (tenant_id)"),
("idx_ble_gateways_api_key_prefix", "CREATE INDEX IF NOT EXISTS idx_ble_gateways_api_key_prefix ON ble_gateways (api_key_prefix)"),
("idx_gateway_patient_bindings_gateway", "CREATE INDEX IF NOT EXISTS idx_gateway_patient_bindings_gateway ON gateway_patient_bindings (gateway_id_fk)"),
("idx_gateway_patient_bindings_patient", "CREATE INDEX IF NOT EXISTS idx_gateway_patient_bindings_patient ON gateway_patient_bindings (patient_id)"),
];
for (_, sql) in &indexes {
db.execute_unprepared(sql).await.ok();
}
// 索引
manager
.create_index(
Index::create()
.name("idx_ble_gateways_tenant_id")
.table(Alias::new("ble_gateways"))
.col(Alias::new("tenant_id"))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_ble_gateways_api_key_prefix")
.table(Alias::new("ble_gateways"))
.col(Alias::new("api_key_prefix"))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_gateway_patient_bindings_gateway")
.table(Alias::new("gateway_patient_bindings"))
.col(Alias::new("gateway_id_fk"))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_gateway_patient_bindings_patient")
.table(Alias::new("gateway_patient_bindings"))
.col(Alias::new("patient_id"))
.to_owned(),
)
.await?;
// 外键约束
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_gpb_gateway")
.from(Alias::new("gateway_patient_bindings"), Alias::new("gateway_id_fk"))
.to(Alias::new("ble_gateways"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_gpb_patient")
.from(Alias::new("gateway_patient_bindings"), Alias::new("patient_id"))
.to(Alias::new("patients"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
// 外键约束(幂等)
let fks = [
("fk_gpb_gateway", "ALTER TABLE gateway_patient_bindings ADD CONSTRAINT fk_gpb_gateway FOREIGN KEY (gateway_id_fk) REFERENCES ble_gateways(id) ON DELETE CASCADE"),
("fk_gpb_patient", "ALTER TABLE gateway_patient_bindings ADD CONSTRAINT fk_gpb_patient FOREIGN KEY (patient_id) REFERENCES patient(id) ON DELETE CASCADE"),
];
for (name, sql) in &fks {
let check = format!(
"SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_name = '{name}'"
);
let result = db.query_one(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
check,
)).await?;
let count: i64 = result.unwrap().try_get_by_index::<i64>(0).unwrap_or(0);
if count == 0 {
db.execute_unprepared(sql).await?;
}
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Alias::new("gateway_patient_bindings")).to_owned())
let db = manager.get_connection();
db.execute_unprepared("DROP TABLE IF EXISTS gateway_patient_bindings")
.await?;
manager
.drop_table(Table::drop().table(Alias::new("ble_gateways")).to_owned())
db.execute_unprepared("DROP TABLE IF EXISTS ble_gateways")
.await?;
Ok(())
}

View File

@@ -0,0 +1,94 @@
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> {
let db = manager.get_connection();
// 批量插入缺失的健康管理菜单(多租户安全)
let menus: &[(&str, &str, &str, &str, i32)] = &[
("b0000003-0000-7000-8000-000000000022", "护理计划", "/health/care-plans", "SolutionOutlined", 19),
("b0000003-0000-7000-8000-000000000023", "班次管理", "/health/shifts", "ClockCircleOutlined", 20),
("b0000003-0000-7000-8000-000000000024", "用药记录", "/health/medications", "MedicineBoxOutlined", 21),
("b0000003-0000-7000-8000-000000000025", "BLE 网关", "/health/ble-gateways", "WifiOutlined", 22),
("b0000003-0000-7000-8000-000000000026", "危急值阈值", "/health/critical-value-thresholds","SafetyCertificateOutlined", 23),
("b0000003-0000-7000-8000-000000000027", "诊断记录", "/health/diagnoses", "FileSearchOutlined", 24),
("b0000003-0000-7000-8000-000000000028", "家庭健康代理", "/health/family-proxy", "TeamOutlined", 25),
("b0000003-0000-7000-8000-000000000029", "知情同意", "/health/consents", "AuditOutlined", 26),
("b0000003-0000-7000-8000-000000000030", "实时监控", "/health/realtime-monitor", "MonitorOutlined", 27),
("b0000003-0000-7000-8000-000000000031", "OAuth 合作方", "/health/oauth-clients", "ApiOutlined", 28),
("b0000003-0000-7000-8000-000000000032", "随访模板管理", "/health/follow-up-templates", "FormOutlined", 29),
];
for &(id, title, path, icon, sort) in menus {
let sql = format!(
r#"
INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order,
visible, menu_type, created_at, updated_at, created_by, updated_by, version)
SELECT
'{id}'::uuid,
t.id,
(SELECT m.id FROM menus m WHERE m.path = '/health' AND m.tenant_id = t.id LIMIT 1),
'{title}',
'{path}',
'{icon}',
{sort},
true, 'page',
NOW(), NOW(),
(SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1),
(SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM menus m WHERE m.path = '{path}' AND m.tenant_id = t.id
)
"#
);
db.execute_unprepared(&sql).await?;
}
// 为所有健康模块权限绑定 admin 角色(幂等)
db.execute_unprepared(
r#"
INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version)
SELECT r.id, p.id, t.id, r.id, r.id, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code LIKE 'health.%' AND p.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.permission_id = p.id AND rp.role_id = r.id
)
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let paths = [
"/health/care-plans",
"/health/shifts",
"/health/medications",
"/health/ble-gateways",
"/health/critical-value-thresholds",
"/health/diagnoses",
"/health/family-proxy",
"/health/consents",
"/health/realtime-monitor",
"/health/oauth-clients",
"/health/follow-up-templates",
];
for path in &paths {
db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'"))
.await
.ok();
}
Ok(())
}
}

View File

@@ -808,8 +808,7 @@ async fn sync_module_permissions(
}
}
if total_new > 0 {
// 将新权限分配给 admin 角色
// 每次启动都确保 admin 角色拥有所有模块权限(防止权限-角色关联缺失)
db.execute(sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version)
@@ -821,7 +820,8 @@ async fn sync_module_permissions(
[system_user_id.into(), tenant_id.into()],
)).await?;
tracing::info!(total_new, "Module permissions synced to database");
if total_new > 0 {
tracing::info!(total_new, "New module permissions synced and bound to admin role");
}
Ok(())