feat(health): 危急值告警 service — 创建/确认/升级扫描/列表查询
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

- handle_critical_alert_event: 消费事件创建告警记录
- acknowledge_alert: 医生确认 + 创建响应记录
- scan_escalation: 30min→L1, 60min→L2 分级升级
- list_pending_alerts / get_alert: 查询接口
This commit is contained in:
iven
2026-04-28 11:39:38 +08:00
parent 80b99dba46
commit b7b09c0727
3 changed files with 227 additions and 1 deletions

View File

@@ -80,6 +80,9 @@ pub enum HealthError {
#[error("随访模板不存在")]
FollowUpTemplateNotFound,
#[error("危急值告警不存在")]
CriticalAlertNotFound,
#[error("状态转换无效: {0}")]
InvalidStatusTransition(String),
@@ -117,7 +120,8 @@ impl From<HealthError> for AppError {
| HealthError::AlertRuleNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound => AppError::NotFound(err.to_string()),
| HealthError::FollowUpTemplateNotFound
| HealthError::CriticalAlertNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,

View File

@@ -0,0 +1,221 @@
//! 危急值告警 Service — 创建告警、确认、升级
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use crate::entity::{critical_alert, critical_alert_response};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
/// 消费 health_data.critical_alert 事件,创建告警记录
pub async fn handle_critical_alert_event(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
alert_type: &str,
metric_name: &str,
metric_value: &str,
threshold_value: &str,
created_by: Option<Uuid>,
) -> HealthResult<Uuid> {
let id = Uuid::now_v7();
let now = Utc::now();
let alert = critical_alert::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
alert_type: Set(alert_type.to_string()),
metric_name: Set(metric_name.to_string()),
metric_value: Set(metric_value.to_string()),
threshold_value: Set(threshold_value.to_string()),
severity: Set("critical".to_string()),
status: Set("pending".to_string()),
acknowledged_by: Set(None),
acknowledged_at: Set(None),
escalation_level: Set(0),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(created_by),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
};
critical_alert::Entity::insert(alert)
.exec(&state.db)
.await?;
Ok(id)
}
/// 医生确认告警
pub async fn acknowledge_alert(
state: &HealthState,
tenant_id: Uuid,
alert_id: Uuid,
responder_id: Uuid,
notes: Option<String>,
) -> HealthResult<()> {
let alert = critical_alert::Entity::find_by_id(alert_id)
.one(&state.db)
.await?
.ok_or(HealthError::CriticalAlertNotFound)?;
if alert.tenant_id != tenant_id {
return Err(HealthError::CriticalAlertNotFound);
}
if alert.status != "pending" && alert.status != "escalated" {
return Err(HealthError::InvalidStatusTransition(format!(
"告警状态为 {},无法确认",
alert.status
)));
}
let now = Utc::now();
let mut active: critical_alert::ActiveModel = alert.into();
active.status = Set("acknowledged".to_string());
active.acknowledged_by = Set(Some(responder_id));
active.acknowledged_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(Some(responder_id));
active.version = Set(active.version.unwrap() + 1);
critical_alert::Entity::update(active)
.exec(&state.db)
.await?;
// 创建响应记录
let resp_id = Uuid::now_v7();
let response = critical_alert_response::ActiveModel {
id: Set(resp_id),
tenant_id: Set(tenant_id),
alert_id: Set(alert_id),
responder_id: Set(responder_id),
response_type: Set("acknowledge".to_string()),
notes: Set(notes),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(responder_id)),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
};
critical_alert_response::Entity::insert(response)
.exec(&state.db)
.await?;
Ok(())
}
/// 升级扫描 — 由定时任务每分钟调用
pub async fn scan_escalation(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<Vec<Uuid>> {
let now = Utc::now();
let mut escalated = Vec::new();
// Level 1: pending/escalated 且 created_at < 30min → escalation_level = 1
let threshold_l1 = now - chrono::Duration::minutes(30);
let alerts_l1 = critical_alert::Entity::find()
.filter(critical_alert::Column::TenantId.eq(tenant_id))
.filter(
critical_alert::Column::Status
.is_in(vec!["pending".to_string(), "escalated".to_string()]),
)
.filter(critical_alert::Column::CreatedAt.lt(threshold_l1))
.filter(critical_alert::Column::EscalationLevel.lt(1))
.filter(critical_alert::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for alert in alerts_l1 {
let mut active: critical_alert::ActiveModel = alert.clone().into();
active.escalation_level = Set(1);
active.status = Set("escalated".to_string());
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
critical_alert::Entity::update(active)
.exec(&state.db)
.await?;
escalated.push(alert.id);
}
// Level 2: pending/escalated 且 created_at < 60min → escalation_level = 2
let threshold_l2 = now - chrono::Duration::minutes(60);
let alerts_l2 = critical_alert::Entity::find()
.filter(critical_alert::Column::TenantId.eq(tenant_id))
.filter(
critical_alert::Column::Status
.is_in(vec!["pending".to_string(), "escalated".to_string()]),
)
.filter(critical_alert::Column::CreatedAt.lt(threshold_l2))
.filter(critical_alert::Column::EscalationLevel.lt(2))
.filter(critical_alert::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for alert in alerts_l2 {
let mut active: critical_alert::ActiveModel = alert.clone().into();
active.escalation_level = Set(2);
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
critical_alert::Entity::update(active)
.exec(&state.db)
.await?;
if !escalated.contains(&alert.id) {
escalated.push(alert.id);
}
}
Ok(escalated)
}
/// 查询待处理告警列表
pub async fn list_pending_alerts(
state: &HealthState,
tenant_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<(Vec<critical_alert::Model>, u64)> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = critical_alert::Entity::find()
.filter(critical_alert::Column::TenantId.eq(tenant_id))
.filter(critical_alert::Column::DeletedAt.is_null())
.filter(
critical_alert::Column::Status
.is_in(vec!["pending".to_string(), "escalated".to_string()]),
);
let total = query
.clone()
.count(&state.db)
.await?;
let items = query
.order_by(critical_alert::Column::CreatedAt, sea_orm::Order::Desc)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
Ok((items, total))
}
/// 查询告警详情
pub async fn get_alert(
state: &HealthState,
tenant_id: Uuid,
alert_id: Uuid,
) -> HealthResult<critical_alert::Model> {
let alert = critical_alert::Entity::find_by_id(alert_id)
.one(&state.db)
.await?
.ok_or(HealthError::CriticalAlertNotFound)?;
if alert.tenant_id != tenant_id {
return Err(HealthError::CriticalAlertNotFound);
}
Ok(alert)
}

View File

@@ -7,6 +7,7 @@ pub mod article_service;
pub mod article_tag_service;
pub mod consultation_service;
pub mod consent_service;
pub mod critical_alert_service;
pub mod critical_value_threshold_service;
pub mod daily_monitoring_service;
pub mod device_reading_service;