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

@@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
// ── 告警规则 DTO ──
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateAlertRuleRequest {
pub name: String,
pub description: Option<String>,
pub device_type: String,
pub condition_type: String,
pub condition_params: serde_json::Value,
pub severity: Option<String>,
pub apply_tags: Option<serde_json::Value>,
pub notify_roles: Option<serde_json::Value>,
pub cooldown_minutes: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAlertRuleRequest {
pub name: Option<String>,
pub description: Option<String>,
pub condition_params: Option<serde_json::Value>,
pub severity: Option<String>,
pub apply_tags: Option<serde_json::Value>,
pub notify_roles: Option<serde_json::Value>,
pub cooldown_minutes: Option<i32>,
pub version: i32,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AlertRuleResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub device_type: String,
pub condition_type: String,
pub condition_params: serde_json::Value,
pub severity: String,
pub is_active: bool,
pub apply_tags: Option<serde_json::Value>,
pub notify_roles: serde_json::Value,
pub cooldown_minutes: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
// ── 告警记录 DTO ──
#[derive(Debug, Deserialize, ToSchema)]
pub struct AcknowledgeAlertRequest {
pub version: i32,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AlertResponse {
pub id: Uuid,
pub patient_id: Uuid,
pub rule_id: Uuid,
pub severity: String,
pub title: String,
pub detail: Option<serde_json::Value>,
pub status: String,
pub acknowledged_by: Option<Uuid>,
pub acknowledged_at: Option<DateTime<Utc>>,
pub resolved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub version: i32,
}

View File

@@ -1,4 +1,5 @@
pub mod appointment_dto;
pub mod alert_dto;
pub mod article_dto;
pub mod consent_dto;
pub mod consultation_dto;

View File

@@ -0,0 +1,36 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "alert_rules")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub device_type: String,
pub condition_type: String,
pub condition_params: serde_json::Value,
pub severity: String,
pub is_active: bool,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub apply_tags: Option<serde_json::Value>,
pub notify_roles: serde_json::Value,
pub cooldown_minutes: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,58 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "alerts")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub rule_id: Uuid,
pub severity: String,
pub title: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub acknowledged_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub acknowledged_at: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::alert_rules::Entity",
from = "Column::RuleId",
to = "super::alert_rules::Column::Id"
)]
AlertRule,
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::alert_rules::Entity> for Entity {
fn to() -> RelationDef {
Relation::AlertRule.def()
}
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,39 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "device_readings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
pub device_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
pub raw_value: serde_json::Value,
pub measured_at: DateTimeUtc,
pub created_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,3 +1,5 @@
pub mod alert_rules;
pub mod alerts;
pub mod appointment;
pub mod article;
pub mod article_article_tag;
@@ -9,6 +11,7 @@ pub mod consent;
pub mod consultation_message;
pub mod consultation_session;
pub mod daily_monitoring;
pub mod device_readings;
pub mod diagnosis;
pub mod dialysis_record;
pub mod doctor_profile;
@@ -23,7 +26,7 @@ pub mod patient_doctor_relation;
pub mod patient_family_member;
pub mod patient_tag;
pub mod patient_tag_relation;
pub mod vital_signs;
pub mod patient_devices;
pub mod points_account;
pub mod points_checkin;
pub mod points_order;
@@ -32,3 +35,5 @@ pub mod points_rule;
pub mod points_transaction;
pub mod offline_event;
pub mod offline_event_registration;
pub mod vital_signs;
pub mod vital_signs_hourly;

View File

@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "patient_devices")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub device_id: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub device_model: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub device_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub bound_at: Option<DateTimeUtc>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub last_sync_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "vital_signs_hourly")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub device_type: String,
pub hour_start: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub min_val: Option<f64>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub max_val: Option<f64>,
pub avg_val: f64,
pub sample_count: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::patient::Entity",
from = "Column::PatientId",
to = "super::patient::Column::Id"
)]
Patient,
}
impl Related<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -68,6 +68,12 @@ pub enum HealthError {
#[error("知情同意记录不存在")]
ConsentNotFound,
#[error("告警规则不存在")]
AlertRuleNotFound,
#[error("告警记录不存在")]
AlertNotFound,
#[error("状态转换无效: {0}")]
InvalidStatusTransition(String),
@@ -101,7 +107,9 @@ impl From<HealthError> for AppError {
| HealthError::OfflineEventNotFound
| HealthError::DailyMonitoringNotFound
| HealthError::ThresholdNotFound
| HealthError::ConsentNotFound => AppError::NotFound(err.to_string()),
| HealthError::ConsentNotFound
| HealthError::AlertRuleNotFound
| HealthError::AlertNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,

View File

@@ -1,4 +1,5 @@
use erp_core::events::EventBus;
use uuid::Uuid;
/// 兼容旧签名 — 不做任何实际订阅(逻辑已迁移到 on_startup
pub fn register_handlers(_bus: &EventBus) {
@@ -56,6 +57,7 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
// message.sent → 预留:后续联动咨询会话 last_message_at
let (mut msg_rx, _msg_handle) = state.event_bus.subscribe_filtered("message.".to_string());
let msg_db = state.db.clone();
tokio::spawn(async move {
loop {
match msg_rx.recv().await {
@@ -70,4 +72,36 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
}
}
});
// device.readings.synced → 触发告警引擎评估
let (mut reading_rx, _reading_handle) = state.event_bus.subscribe_filtered("device.readings.".to_string());
let eval_state = state.clone();
tokio::spawn(async move {
loop {
match reading_rx.recv().await {
Some(event) if event.event_type == "device.readings.synced" => {
let patient_id = event.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
// 对所有设备类型触发评估
for device_type in &["heart_rate", "blood_oxygen", "temperature"] {
if let Err(e) = crate::service::alert_engine::evaluate_rules(
&eval_state, event.tenant_id, pid, device_type,
).await {
tracing::error!(
patient_id = %pid,
device_type = device_type,
error = %e,
"告警评估失败"
);
}
}
}
}
Some(_) => {}
None => break,
}
}
});
}

View File

@@ -0,0 +1,100 @@
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::alert_dto::AcknowledgeAlertRequest;
use crate::service::alert_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct AlertListQuery {
pub patient_id: Option<Uuid>,
pub status: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn list_alerts<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<AlertListQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alerts.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let (items, total) = alert_service::list_alerts(
&state, ctx.tenant_id, query.patient_id, query.status.as_deref(),
page, page_size,
).await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages: total.div_ceil(page_size.max(1)),
})))
}
pub async fn acknowledge<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::acknowledge_alert(
&state, ctx.tenant_id, id, ctx.user_id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(alert)))
}
pub async fn dismiss<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::dismiss_alert(
&state, ctx.tenant_id, id, ctx.user_id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(alert)))
}
pub async fn resolve<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alerts.manage")?;
let alert = alert_service::resolve_alert(
&state, ctx.tenant_id, id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(alert)))
}

View File

@@ -0,0 +1,102 @@
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::alert_dto::{CreateAlertRuleRequest, UpdateAlertRuleRequest};
use crate::service::alert_rule_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct RuleListQuery {
pub device_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct DeactivateRequest {
pub version: i32,
}
pub async fn list_rules<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<RuleListQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let (items, total) = alert_rule_service::list_rules(
&state, ctx.tenant_id, query.device_type.as_deref(), page, page_size,
).await?;
Ok(axum::Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages: total.div_ceil(page_size.max(1)),
})))
}
pub async fn create<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
axum::Json(body): axum::Json<CreateAlertRuleRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.manage")?;
let rule = alert_rule_service::create_rule(
&state, ctx.tenant_id, ctx.user_id, body,
).await?;
Ok(axum::Json(ApiResponse::ok(rule)))
}
pub async fn update<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<UpdateAlertRuleRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.manage")?;
let rule = alert_rule_service::update_rule(
&state, ctx.tenant_id, id, ctx.user_id, body,
).await?;
Ok(axum::Json(ApiResponse::ok(rule)))
}
pub async fn deactivate<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
axum::Json(body): axum::Json<DeactivateRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.alert-rules.manage")?;
let rule = alert_rule_service::deactivate_rule(
&state, ctx.tenant_id, id, body.version,
).await?;
Ok(axum::Json(ApiResponse::ok(rule)))
}

View File

@@ -0,0 +1,92 @@
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use axum::Extension;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::service::device_reading_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize)]
pub struct PatientPath {
pub patient_id: Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReadingListQuery {
pub device_type: Option<String>,
pub hours: Option<i64>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct HourlyQuery {
pub device_type: String,
pub days: Option<i64>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn batch_create<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(path): Path<PatientPath>,
axum::Json(body): axum::Json<device_reading_service::BatchReadingRequest>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.device-readings.manage")?;
let result = device_reading_service::batch_create_readings(
&state, ctx.tenant_id, path.patient_id, body,
).await?;
Ok(axum::Json(ApiResponse::ok(result)))
}
pub async fn list_readings<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(path): Path<PatientPath>,
Query(query): Query<ReadingListQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.device-readings.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let result = device_reading_service::query_device_readings(
&state, ctx.tenant_id, path.patient_id,
query.device_type.as_deref(), query.hours, page, page_size,
).await?;
Ok(axum::Json(ApiResponse::ok(result)))
}
pub async fn list_hourly<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(path): Path<PatientPath>,
Query(query): Query<HourlyQuery>,
) -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.device-readings.list")?;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let days = query.days.unwrap_or(7);
let result = device_reading_service::query_hourly_readings(
&state, ctx.tenant_id, path.patient_id,
&query.device_type, days, page, page_size,
).await?;
Ok(axum::Json(ApiResponse::ok(result)))
}

View File

@@ -1,3 +1,5 @@
pub mod alert_handler;
pub mod alert_rule_handler;
pub mod appointment_handler;
pub mod article_category_handler;
pub mod article_handler;
@@ -6,6 +8,7 @@ pub mod consultation_handler;
pub mod consent_handler;
pub mod critical_value_threshold_handler;
pub mod daily_monitoring_handler;
pub mod device_reading_handler;
pub mod diagnosis_handler;
pub mod dialysis_handler;
pub mod doctor_handler;

View File

@@ -6,7 +6,8 @@ use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
alert_handler, alert_rule_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
health_data_handler, patient_handler, points_handler, stats_handler,
};
@@ -526,6 +527,49 @@ impl HealthModule {
"/health/consents/{consent_id}/revoke",
axum::routing::put(consent_handler::revoke_consent),
)
// 设备数据采集
.route(
"/health/patients/{patient_id}/device-readings/batch",
axum::routing::post(device_reading_handler::batch_create),
)
.route(
"/health/patients/{patient_id}/device-readings",
axum::routing::get(device_reading_handler::list_readings),
)
.route(
"/health/patients/{patient_id}/device-readings/hourly",
axum::routing::get(device_reading_handler::list_hourly),
)
// 告警路由
.route(
"/health/alerts",
axum::routing::get(alert_handler::list_alerts),
)
.route(
"/health/alerts/{id}/acknowledge",
axum::routing::put(alert_handler::acknowledge),
)
.route(
"/health/alerts/{id}/dismiss",
axum::routing::put(alert_handler::dismiss),
)
.route(
"/health/alerts/{id}/resolve",
axum::routing::put(alert_handler::resolve),
)
.route(
"/health/alert-rules",
axum::routing::get(alert_rule_handler::list_rules)
.post(alert_rule_handler::create),
)
.route(
"/health/alert-rules/{id}",
axum::routing::put(alert_rule_handler::update),
)
.route(
"/health/alert-rules/{id}/deactivate",
axum::routing::put(alert_rule_handler::deactivate),
)
}
}
@@ -740,6 +784,42 @@ impl ErpModule for HealthModule {
description: "创建积分规则、管理商品、核销订单".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.device-readings.list".into(),
name: "查看设备数据".into(),
description: "查看患者的设备采集数据".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.device-readings.manage".into(),
name: "管理设备数据".into(),
description: "提交设备采集数据".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alerts.list".into(),
name: "查看告警".into(),
description: "查看告警记录".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alerts.manage".into(),
name: "管理告警".into(),
description: "确认/处置告警".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alert-rules.list".into(),
name: "查看告警规则".into(),
description: "查看告警规则配置".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.alert-rules.manage".into(),
name: "管理告警规则".into(),
description: "创建/编辑/启停告警规则".into(),
module: "health".into(),
},
]
}

View File

@@ -0,0 +1,240 @@
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use serde_json::json;
use uuid::Uuid;
use crate::entity::{alert_rules, alerts, vital_signs_hourly};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
/// 评估所有适用规则,返回触发的告警列表
pub async fn evaluate_rules(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
) -> HealthResult<Vec<alerts::Model>> {
let rules = alert_rules::Entity::find()
.filter(alert_rules::Column::TenantId.eq(tenant_id))
.filter(alert_rules::Column::IsActive.eq(true))
.filter(alert_rules::Column::DeviceType.eq(device_type))
.filter(alert_rules::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
let mut triggered_alerts = Vec::new();
for rule in rules {
if is_in_cooldown(&state.db, tenant_id, patient_id, rule.id, rule.cooldown_minutes).await? {
continue;
}
let params = &rule.condition_params;
let condition_type = rule.condition_type.as_str();
let is_triggered = match condition_type {
"single_threshold" => evaluate_single_threshold(
&state.db, tenant_id, patient_id, device_type, params
).await?,
"consecutive" => evaluate_consecutive(
&state.db, tenant_id, patient_id, device_type, params
).await?,
"trend" => evaluate_trend(
&state.db, tenant_id, patient_id, device_type, params
).await?,
_ => false,
};
if is_triggered {
let alert = create_alert_and_notify(
&state.db, &state.event_bus, tenant_id, patient_id, &rule
).await?;
triggered_alerts.push(alert);
}
}
Ok(triggered_alerts)
}
async fn is_in_cooldown(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
rule_id: Uuid,
cooldown_minutes: i32,
) -> HealthResult<bool> {
let cooldown_start = Utc::now() - chrono::Duration::minutes(cooldown_minutes as i64);
let recent = alerts::Entity::find()
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::PatientId.eq(patient_id))
.filter(alerts::Column::RuleId.eq(rule_id))
.filter(alerts::Column::CreatedAt.gt(cooldown_start))
.filter(alerts::Column::DeletedAt.is_null())
.one(db)
.await?;
Ok(recent.is_some())
}
async fn evaluate_single_threshold(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
params: &serde_json::Value,
) -> HealthResult<bool> {
let direction = params["direction"].as_str().unwrap_or("above");
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
let latest = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.order_by_desc(vital_signs_hourly::Column::HourStart)
.one(db)
.await?;
match latest {
Some(record) => {
let val = record.avg_val;
Ok(match direction {
"above" => val > threshold,
"below" => val < threshold,
_ => false,
})
}
None => Ok(false),
}
}
async fn evaluate_consecutive(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
params: &serde_json::Value,
) -> HealthResult<bool> {
let count = params["count"].as_u64().unwrap_or(3) as u64;
let direction = params["direction"].as_str().unwrap_or("above");
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
let window_hours = params["window_hours"].as_i64();
use sea_orm::QueryOrder;
let mut query = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.order_by_desc(vital_signs_hourly::Column::HourStart);
if let Some(hours) = window_hours {
let since = Utc::now() - chrono::Duration::hours(hours);
query = query.filter(vital_signs_hourly::Column::HourStart.gt(since));
}
let records: Vec<_> = query
.limit(count)
.all(db)
.await?;
if records.len() < count as usize {
return Ok(false);
}
let all_exceed = records.iter().all(|r| {
match direction {
"above" => r.avg_val > threshold,
"below" => r.avg_val < threshold,
_ => false,
}
});
Ok(all_exceed)
}
async fn evaluate_trend(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
params: &serde_json::Value,
) -> HealthResult<bool> {
let window_hours = params["window_hours"].as_i64().unwrap_or(168);
let delta_threshold = params["delta"].as_f64().unwrap_or(20.0);
let direction = params["direction"].as_str().unwrap_or("up");
let since = Utc::now() - chrono::Duration::hours(window_hours);
use sea_orm::QueryOrder;
let records: Vec<_> = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.filter(vital_signs_hourly::Column::HourStart.gt(since))
.order_by_asc(vital_signs_hourly::Column::HourStart)
.all(db)
.await?;
if records.len() < 2 {
return Ok(false);
}
let first = records.first().unwrap().avg_val;
let last = records.last().unwrap().avg_val;
let actual_delta = last - first;
Ok(match direction {
"up" => actual_delta > delta_threshold,
"down" => actual_delta < -delta_threshold,
_ => false,
})
}
async fn create_alert_and_notify(
db: &DatabaseConnection,
event_bus: &erp_core::events::EventBus,
tenant_id: Uuid,
patient_id: Uuid,
rule: &alert_rules::Model,
) -> HealthResult<alerts::Model> {
let alert_id = Uuid::now_v7();
let alert = alerts::ActiveModel {
id: Set(alert_id),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
rule_id: Set(rule.id),
severity: Set(rule.severity.clone()),
title: Set(format!("{}触发", rule.name)),
detail: Set(Some(json!({
"rule_name": rule.name,
"condition_type": rule.condition_type,
"condition_params": rule.condition_params,
"device_type": rule.device_type,
}))),
status: Set("pending".to_string()),
acknowledged_by: Set(None),
acknowledged_at: Set(None),
resolved_at: Set(None),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
deleted_at: Set(None),
version: Set(1),
};
let alert = alert.insert(db).await?;
let event = erp_core::events::DomainEvent::new(
"alert.triggered",
tenant_id,
json!({
"alert_id": alert.id,
"patient_id": patient_id,
"rule_name": rule.name,
"severity": rule.severity,
"detail": alert.detail,
"notify_roles": rule.notify_roles,
}),
);
event_bus.publish(event, db).await;
Ok(alert)
}

View File

@@ -0,0 +1,135 @@
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::error::check_version;
use crate::entity::alert_rules;
use crate::error::{HealthError, HealthResult};
use crate::service::validation;
use crate::state::HealthState;
pub async fn list_rules(
state: &HealthState,
tenant_id: Uuid,
device_type: Option<&str>,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<alert_rules::Model>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = alert_rules::Entity::find()
.filter(alert_rules::Column::TenantId.eq(tenant_id))
.filter(alert_rules::Column::DeletedAt.is_null());
if let Some(dt) = device_type {
query = query.filter(alert_rules::Column::DeviceType.eq(dt));
}
let total = query.clone().count(&state.db).await?;
let items = query
.order_by_desc(alert_rules::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(&state.db)
.await?;
Ok((items, total))
}
pub async fn create_rule(
state: &HealthState,
tenant_id: Uuid,
user_id: Uuid,
req: crate::dto::alert_dto::CreateAlertRuleRequest,
) -> HealthResult<alert_rules::Model> {
validation::validate_device_type(&req.device_type)?;
validation::validate_condition_type(&req.condition_type)?;
if let Some(ref sev) = req.severity {
validation::validate_alert_severity(sev)?;
}
let model = alert_rules::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(req.name),
description: Set(req.description),
device_type: Set(req.device_type),
condition_type: Set(req.condition_type),
condition_params: Set(req.condition_params),
severity: Set(req.severity.unwrap_or_else(|| "warning".to_string())),
is_active: Set(true),
apply_tags: Set(req.apply_tags),
notify_roles: Set(req.notify_roles.unwrap_or(serde_json::json!([]))),
cooldown_minutes: Set(req.cooldown_minutes.unwrap_or(60)),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version: Set(1),
};
Ok(model.insert(&state.db).await?)
}
pub async fn update_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
user_id: Uuid,
req: crate::dto::alert_dto::UpdateAlertRuleRequest,
) -> HealthResult<alert_rules::Model> {
let rule = alert_rules::Entity::find_by_id(rule_id)
.filter(alert_rules::Column::TenantId.eq(tenant_id))
.filter(alert_rules::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::AlertRuleNotFound)?;
check_version(rule.version, req.version)?;
if let Some(ref sev) = req.severity {
validation::validate_alert_severity(sev)?;
}
let mut active: alert_rules::ActiveModel = rule.into();
if let Some(name) = req.name { active.name = Set(name); }
if let Some(desc) = req.description { active.description = Set(Some(desc)); }
if let Some(params) = req.condition_params { active.condition_params = Set(params); }
if let Some(sev) = req.severity { active.severity = Set(sev); }
if let Some(tags) = req.apply_tags { active.apply_tags = Set(Some(tags)); }
if let Some(roles) = req.notify_roles { active.notify_roles = Set(roles); }
if let Some(mins) = req.cooldown_minutes { active.cooldown_minutes = Set(mins); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(user_id));
active.version = Set(req.version + 1);
Ok(active.update(&state.db).await?)
}
pub async fn deactivate_rule(
state: &HealthState,
tenant_id: Uuid,
rule_id: Uuid,
version: i32,
) -> HealthResult<alert_rules::Model> {
let rule = alert_rules::Entity::find_by_id(rule_id)
.filter(alert_rules::Column::TenantId.eq(tenant_id))
.filter(alert_rules::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::AlertRuleNotFound)?;
check_version(rule.version, version)?;
let mut active: alert_rules::ActiveModel = rule.into();
active.is_active = Set(false);
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}

View File

@@ -0,0 +1,124 @@
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::ActiveValue::Set;
use sea_orm::{QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::error::check_version;
use crate::entity::alerts;
use crate::error::{HealthError, HealthResult};
use crate::service::validation;
use crate::state::HealthState;
pub async fn list_alerts(
state: &HealthState,
tenant_id: Uuid,
patient_id: Option<Uuid>,
status: Option<&str>,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<alerts::Model>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = alerts::Entity::find()
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(alerts::Column::PatientId.eq(pid));
}
if let Some(s) = status {
validation::validate_alert_status(s)?;
query = query.filter(alerts::Column::Status.eq(s));
}
let total = query.clone().count(&state.db).await?;
let items = query
.order_by_desc(alerts::Column::CreatedAt)
.limit(limit)
.offset(offset)
.all(&state.db)
.await?;
Ok((items, total))
}
pub async fn acknowledge_alert(
state: &HealthState,
tenant_id: Uuid,
alert_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::AlertNotFound)?;
validation::validate_alert_status_transition(&alert.status, "acknowledged")?;
check_version(alert.version, version)?;
let mut active: alerts::ActiveModel = alert.into();
active.status = Set("acknowledged".to_string());
active.acknowledged_by = Set(Some(user_id));
active.acknowledged_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}
pub async fn dismiss_alert(
state: &HealthState,
tenant_id: Uuid,
alert_id: Uuid,
user_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::AlertNotFound)?;
validation::validate_alert_status_transition(&alert.status, "dismissed")?;
check_version(alert.version, version)?;
let mut active: alerts::ActiveModel = alert.into();
active.status = Set("dismissed".to_string());
active.acknowledged_by = Set(Some(user_id));
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}
pub async fn resolve_alert(
state: &HealthState,
tenant_id: Uuid,
alert_id: Uuid,
version: i32,
) -> HealthResult<alerts::Model> {
let alert = alerts::Entity::find_by_id(alert_id)
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::AlertNotFound)?;
validation::validate_alert_status_transition(&alert.status, "resolved")?;
check_version(alert.version, version)?;
let mut active: alerts::ActiveModel = alert.into();
active.status = Set("resolved".to_string());
active.resolved_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(version + 1);
Ok(active.update(&state.db).await?)
}

View File

@@ -0,0 +1,429 @@
use chrono::{DateTime, Timelike, Utc};
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use erp_core::events::DomainEvent;
use erp_core::types::PaginatedResponse;
use crate::entity::{device_readings, patient, patient_devices, vital_signs_hourly};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_device_type;
use crate::state::HealthState;
// ── DTO ──
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchReadingRequest {
pub device_id: String,
pub device_model: Option<String>,
pub readings: Vec<ReadingInput>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ReadingInput {
pub device_type: String,
pub values: serde_json::Value,
pub measured_at: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BatchResult {
pub accepted: u64,
pub duplicates: u64,
pub earliest: Option<String>,
pub latest: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DeviceReadingDto {
pub id: Uuid,
pub device_id: Option<String>,
pub device_type: String,
pub device_model: Option<String>,
pub raw_value: serde_json::Value,
pub measured_at: String,
pub created_at: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct HourlyReadingDto {
pub id: Uuid,
pub device_type: String,
pub hour_start: String,
pub min_val: Option<f64>,
pub max_val: Option<f64>,
pub avg_val: f64,
pub sample_count: i32,
}
// ── 批量摄入 ──
pub async fn batch_create_readings(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: BatchReadingRequest,
) -> HealthResult<BatchResult> {
// 1. 校验患者存在
let _patient = patient::Entity::find_by_id(patient_id)
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
// 2. 校验/创建设备绑定
ensure_device_binding(
&state.db, tenant_id, patient_id,
&req.device_id, req.device_model.as_deref(),
).await?;
// 3. 解析 + 校验 readings
let mut parsed_readings = Vec::with_capacity(req.readings.len().min(500));
let mut earliest: Option<DateTime<Utc>> = None;
let mut latest: Option<DateTime<Utc>> = None;
if req.readings.len() > 500 {
return Err(HealthError::Validation("单次最多提交 500 条记录".into()));
}
for r in &req.readings {
validate_device_type(&r.device_type)?;
let measured_at: DateTime<Utc> = r.measured_at.parse()
.map_err(|_| HealthError::Validation("measured_at 格式无效,需要 ISO 8601".into()))?;
if measured_at > Utc::now() {
return Err(HealthError::Validation("measured_at 不能是未来时间".into()));
}
earliest = earliest.map_or(Some(measured_at), |e| Some(e.min(measured_at)));
latest = latest.map_or(Some(measured_at), |l| Some(l.max(measured_at)));
parsed_readings.push((r, measured_at));
}
if parsed_readings.is_empty() {
return Err(HealthError::Validation("readings 不能为空".into()));
}
// 4. 批量插入
let total = parsed_readings.len() as u64;
let inserted = batch_insert_readings(
&state.db, tenant_id, patient_id,
&req.device_id, req.device_model.as_deref(),
&parsed_readings,
).await?;
// 5. 降采样 upsert
upsert_hourly_aggregates(
&state.db, tenant_id, patient_id, &parsed_readings,
).await?;
// 6. 发布 EventBus 事件
let event = DomainEvent::new(
"device.readings.synced",
tenant_id,
serde_json::json!({
"patient_id": patient_id,
"count": inserted,
"device_model": req.device_model,
"date_range": {
"from": earliest.map(|t| t.to_rfc3339()),
"to": latest.map(|t| t.to_rfc3339()),
}
}),
);
state.event_bus.publish(event, &state.db).await;
Ok(BatchResult {
accepted: inserted,
duplicates: total.saturating_sub(inserted),
earliest: earliest.map(|t| t.to_rfc3339()),
latest: latest.map(|t| t.to_rfc3339()),
})
}
async fn ensure_device_binding(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_id: &str,
device_model: Option<&str>,
) -> HealthResult<()> {
let existing = patient_devices::Entity::find()
.filter(patient_devices::Column::TenantId.eq(tenant_id))
.filter(patient_devices::Column::PatientId.eq(patient_id))
.filter(patient_devices::Column::DeviceId.eq(device_id))
.filter(patient_devices::Column::DeletedAt.is_null())
.one(db)
.await?;
if existing.is_none() {
let binding = patient_devices::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
device_id: Set(device_id.to_string()),
device_model: Set(device_model.map(String::from)),
device_type: Set(None),
bound_at: Set(Some(Utc::now())),
last_sync_at: Set(Some(Utc::now())),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
};
binding.insert(db).await?;
} else {
// 更新最后同步时间
let mut active: patient_devices::ActiveModel = existing.unwrap().into();
active.last_sync_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
}
Ok(())
}
async fn batch_insert_readings(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_id: &str,
device_model: Option<&str>,
readings: &[(&ReadingInput, DateTime<Utc>)],
) -> HealthResult<u64> {
let mut inserted: u64 = 0;
for (r, measured_at) in readings {
let id = Uuid::now_v7();
let model = device_readings::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
device_id: Set(Some(device_id.to_string())),
device_type: Set(r.device_type.clone()),
device_model: Set(device_model.map(String::from)),
raw_value: Set(r.values.clone()),
measured_at: Set(*measured_at),
created_at: Set(Utc::now()),
deleted_at: Set(None),
};
match model.insert(db).await {
Ok(_) => inserted += 1,
Err(e) => {
// 唯一约束冲突(重复数据)→ 跳过
let err_str = e.to_string();
if !err_str.contains("duplicate") && !err_str.contains("unique") {
return Err(HealthError::DbError(err_str));
}
}
}
}
Ok(inserted)
}
async fn upsert_hourly_aggregates(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
readings: &[(&ReadingInput, DateTime<Utc>)],
) -> HealthResult<()> {
// 按 (device_type, hour_start) 分组
let mut groups: HashMap<(String, DateTime<Utc>), Vec<f64>> = HashMap::new();
for (r, measured_at) in readings {
// 尝试从 values 中提取数值用于聚合
let hour_start = measured_at
.with_minute(0)
.and_then(|t| t.with_second(0))
.and_then(|t| t.with_nanosecond(0))
.unwrap_or(*measured_at);
if let Some(val) = extract_numeric_value(&r.values) {
let key = (r.device_type.clone(), hour_start);
groups.entry(key).or_default().push(val);
}
}
for ((device_type, hour_start), values) in groups {
let min_val = values.iter().cloned().reduce(f64::min);
let max_val = values.iter().cloned().reduce(f64::max);
let avg_val = values.iter().sum::<f64>() / values.len() as f64;
let sample_count = values.len() as i32;
// 尝试查找已存在的聚合记录
let existing = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(&device_type))
.filter(vital_signs_hourly::Column::HourStart.eq(hour_start))
.one(db)
.await?;
if let Some(rec) = existing {
// 合并:重新计算聚合
let total_count = rec.sample_count + sample_count;
let combined_avg = (rec.avg_val * rec.sample_count as f64 + avg_val * sample_count as f64)
/ total_count as f64;
let combined_min = rec.min_val.map_or(min_val, |m| min_val.map_or(Some(m), |v| Some(m.min(v)))).or(min_val);
let combined_max = rec.max_val.map_or(max_val, |m| max_val.map_or(Some(m), |v| Some(m.max(v)))).or(max_val);
let mut active: vital_signs_hourly::ActiveModel = rec.into();
active.min_val = Set(combined_min);
active.max_val = Set(combined_max);
active.avg_val = Set(combined_avg);
active.sample_count = Set(total_count);
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
} else {
let model = vital_signs_hourly::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
device_type: Set(device_type),
hour_start: Set(hour_start),
min_val: Set(min_val),
max_val: Set(max_val),
avg_val: Set(avg_val),
sample_count: Set(sample_count),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
version: Set(1),
};
model.insert(db).await?;
}
}
Ok(())
}
fn extract_numeric_value(values: &serde_json::Value) -> Option<f64> {
match values {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::Object(map) => {
// 尝试常见字段名
for key in &["value", "heart_rate", "bpm", "spo2", "steps", "temperature", "avg"] {
if let Some(v) = map.get(*key) {
if let Some(n) = v.as_f64() {
return Some(n);
}
}
}
// 取第一个数值字段
for v in map.values() {
if let Some(n) = v.as_f64() {
return Some(n);
}
}
None
}
_ => None,
}
}
// ── 查询 ──
pub async fn query_device_readings(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
device_type: Option<&str>,
hours: Option<i64>,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<DeviceReadingDto>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = device_readings::Entity::find()
.filter(device_readings::Column::TenantId.eq(tenant_id))
.filter(device_readings::Column::PatientId.eq(patient_id))
.filter(device_readings::Column::DeletedAt.is_null());
if let Some(dt) = device_type {
query = query.filter(device_readings::Column::DeviceType.eq(dt));
}
if let Some(h) = hours {
let since = Utc::now() - chrono::Duration::hours(h);
query = query.filter(device_readings::Column::MeasuredAt.gt(since));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(device_readings::Column::MeasuredAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let items: Vec<DeviceReadingDto> = models.into_iter().map(|m| DeviceReadingDto {
id: m.id,
device_id: m.device_id,
device_type: m.device_type,
device_model: m.device_model,
raw_value: m.raw_value,
measured_at: m.measured_at.to_rfc3339(),
created_at: m.created_at.to_rfc3339(),
}).collect();
Ok(PaginatedResponse {
data: items,
total,
page,
page_size: limit,
total_pages: total.div_ceil(limit.max(1)),
})
}
pub async fn query_hourly_readings(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
days: i64,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<HourlyReadingDto>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let since = Utc::now() - chrono::Duration::days(days);
let query = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.filter(vital_signs_hourly::Column::HourStart.gt(since));
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(vital_signs_hourly::Column::HourStart)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let items: Vec<HourlyReadingDto> = models.into_iter().map(|m| HourlyReadingDto {
id: m.id,
device_type: m.device_type,
hour_start: m.hour_start.to_rfc3339(),
min_val: m.min_val,
max_val: m.max_val,
avg_val: m.avg_val,
sample_count: m.sample_count,
}).collect();
Ok(PaginatedResponse {
data: items,
total,
page,
page_size: limit,
total_pages: total.div_ceil(limit.max(1)),
})
}

View File

@@ -1,3 +1,6 @@
pub mod alert_engine;
pub mod alert_rule_service;
pub mod alert_service;
pub mod appointment_service;
pub mod article_category_service;
pub mod article_service;
@@ -6,6 +9,7 @@ pub mod consultation_service;
pub mod consent_service;
pub mod critical_value_threshold_service;
pub mod daily_monitoring_service;
pub mod device_reading_service;
pub mod diagnosis_service;
pub mod dialysis_service;
pub mod doctor_service;

View File

@@ -1,10 +1,11 @@
//! 租户初始化种子数据 — 创建默认标签
//! 租户初始化种子数据 — 创建默认标签和告警规则
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DatabaseConnection};
use serde_json::json;
use uuid::Uuid;
use crate::entity::patient_tag;
use crate::entity::{alert_rules, patient_tag};
const DEFAULT_TAGS: &[(&str, &str, &str)] = &[
("高血压", "#E74C3C", "高血压患者标签"),
@@ -43,7 +44,80 @@ pub async fn seed_tenant_health(
active.insert(db).await?;
}
tracing::info!(tenant_id = %tenant_id, "Health module default data seeded successfully");
// 默认告警规则
let default_rules: &[(&str, Option<&str>, &str, &str, serde_json::Value, &str, i32)] = &[
(
"心率过高",
Some("心率超过 100 次/分钟时触发告警"),
"heart_rate",
"single_threshold",
json!({"direction": "above", "value": 100}),
"warning",
60,
),
(
"心率过低",
Some("心率低于 50 次/分钟时触发告警"),
"heart_rate",
"single_threshold",
json!({"direction": "below", "value": 50}),
"critical",
60,
),
(
"血氧过低",
Some("血氧饱和度低于 93% 时触发告警"),
"blood_oxygen",
"single_threshold",
json!({"direction": "below", "value": 93}),
"critical",
30,
),
(
"体温过高",
Some("体温超过 37.5°C 时触发告警"),
"temperature",
"single_threshold",
json!({"direction": "above", "value": 37.5}),
"warning",
60,
),
(
"心率持续偏高",
Some("连续 3 次心率超过 100 次/分钟"),
"heart_rate",
"consecutive",
json!({"direction": "above", "value": 100, "count": 3}),
"urgent",
120,
),
];
for (name, description, device_type, condition_type, condition_params, severity, cooldown) in
default_rules
{
let active = alert_rules::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
description: Set(description.map(|s| s.to_string())),
device_type: Set(device_type.to_string()),
condition_type: Set(condition_type.to_string()),
condition_params: Set(condition_params.clone()),
severity: Set(severity.to_string()),
is_active: Set(true),
apply_tags: Set(None),
notify_roles: Set(json!([])),
cooldown_minutes: Set(*cooldown),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
};
active.insert(db).await?;
}
Ok(())
}
@@ -73,6 +147,10 @@ pub async fn soft_delete_tenant_data(
"patient",
"patient_tag",
"doctor_profile",
"alert_rules",
"alerts",
"patient_devices",
"vital_signs_hourly",
];
for table in tables_to_soft_delete {

View File

@@ -229,6 +229,57 @@ pub fn validate_follow_up_status_transition(current: &str, new: &str) -> HealthR
}
}
/// device_reading.device_type
pub fn validate_device_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "device_type", [
"heart_rate", "blood_oxygen", "steps", "sleep", "temperature", "stress",
]);
Ok(())
}
/// alert_rule.condition_type
pub fn validate_condition_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "condition_type", [
"single_threshold", "consecutive", "trend",
]);
Ok(())
}
/// alert.severity
pub fn validate_alert_severity(value: &str) -> HealthResult<()> {
validate_enum!(value, "alert_severity", [
"info", "warning", "critical", "urgent",
]);
Ok(())
}
/// alert.status
pub fn validate_alert_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "alert_status", [
"pending", "acknowledged", "resolved", "dismissed",
]);
Ok(())
}
/// 告警状态转换校验: pending→acknowledged/dismissed, acknowledged→resolved/dismissed
pub fn validate_alert_status_transition(current: &str, next: &str) -> HealthResult<()> {
if current == next {
return Ok(());
}
let allowed = match current {
"pending" => matches!(next, "acknowledged" | "dismissed"),
"acknowledged" => matches!(next, "resolved" | "dismissed"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"alert.status: 不允许从 '{}' 转换到 '{}'", current, next
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -436,4 +487,58 @@ mod tests {
fn fu_completed_to_any_fails() { assert!(validate_follow_up_status_transition("completed", "pending").is_err()); }
#[test]
fn fu_same_status_ok() { assert!(validate_follow_up_status_transition("pending", "pending").is_ok()); }
// --- device_type ---
#[test]
fn device_type_heart_rate() { assert!(validate_device_type("heart_rate").is_ok()); }
#[test]
fn device_type_blood_oxygen() { assert!(validate_device_type("blood_oxygen").is_ok()); }
#[test]
fn device_type_steps() { assert!(validate_device_type("steps").is_ok()); }
#[test]
fn device_type_invalid() { assert!(validate_device_type("blood_pressure").is_err()); }
// --- condition_type ---
#[test]
fn condition_single_threshold() { assert!(validate_condition_type("single_threshold").is_ok()); }
#[test]
fn condition_consecutive() { assert!(validate_condition_type("consecutive").is_ok()); }
#[test]
fn condition_trend() { assert!(validate_condition_type("trend").is_ok()); }
#[test]
fn condition_invalid() { assert!(validate_condition_type("moving_avg").is_err()); }
// --- alert_severity ---
#[test]
fn severity_info() { assert!(validate_alert_severity("info").is_ok()); }
#[test]
fn severity_urgent() { assert!(validate_alert_severity("urgent").is_ok()); }
#[test]
fn severity_invalid() { assert!(validate_alert_severity("emergency").is_err()); }
// --- alert_status ---
#[test]
fn alert_status_pending() { assert!(validate_alert_status("pending").is_ok()); }
#[test]
fn alert_status_resolved() { assert!(validate_alert_status("resolved").is_ok()); }
#[test]
fn alert_status_invalid() { assert!(validate_alert_status("open").is_err()); }
// --- alert_status_transition ---
#[test]
fn alert_pending_to_acknowledged() { assert!(validate_alert_status_transition("pending", "acknowledged").is_ok()); }
#[test]
fn alert_pending_to_dismissed() { assert!(validate_alert_status_transition("pending", "dismissed").is_ok()); }
#[test]
fn alert_pending_to_resolved_fails() { assert!(validate_alert_status_transition("pending", "resolved").is_err()); }
#[test]
fn alert_acknowledged_to_resolved() { assert!(validate_alert_status_transition("acknowledged", "resolved").is_ok()); }
#[test]
fn alert_acknowledged_to_dismissed() { assert!(validate_alert_status_transition("acknowledged", "dismissed").is_ok()); }
#[test]
fn alert_acknowledged_to_pending_fails() { assert!(validate_alert_status_transition("acknowledged", "pending").is_err()); }
#[test]
fn alert_resolved_to_any_fails() { assert!(validate_alert_status_transition("resolved", "pending").is_err()); }
#[test]
fn alert_same_status_ok() { assert!(validate_alert_status_transition("pending", "pending").is_ok()); }
}

View File

@@ -11,15 +11,18 @@ use crate::message_state::MessageState;
/// SSE 消息推送端点。
///
/// 客户端连接后监听 `message.sent` 事件,仅推送当前用户的消息。
/// 使用 EventBus 的 filtered subscriber 按前缀过滤事件。
/// 监听所有事件,按类型分发为不同 SSE event
/// - `message.sent` → SSE event: `message`
/// - `alert.triggered` → SSE event: `alert`
/// - `device.readings.synced` → SSE event: `vital_update`
pub async fn message_stream(
axum::extract::State(state): axum::extract::State<MessageState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
let user_id = ctx.user_id;
let tenant_id = ctx.tenant_id;
let (mut rx, _handle) = state.event_bus.subscribe_filtered("message.sent".to_string());
// 空前缀 = 订阅所有事件
let (mut rx, _handle) = state.event_bus.subscribe_filtered(String::new());
let sse_stream = async_stream::stream! {
loop {
@@ -28,19 +31,38 @@ pub async fn message_stream(
if event.tenant_id != tenant_id {
continue;
}
let is_recipient = event.payload.get("recipient_id")
.and_then(|v: &serde_json::Value| v.as_str())
.map(|s| s == user_id.to_string())
.unwrap_or(false);
if !is_recipient {
continue;
}
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("message")
.data(data));
match event.event_type.as_str() {
"message.sent" => {
let is_recipient = event.payload.get("recipient_id")
.and_then(|v: &serde_json::Value| v.as_str())
.map(|s| s == user_id.to_string())
.unwrap_or(false);
if !is_recipient {
continue;
}
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("message")
.data(data));
}
"alert.triggered" => {
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("alert")
.data(data));
}
"device.readings.synced" => {
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("vital_update")
.data(data));
}
_ => {}
}
}
None => {
break;

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,
}