feat(health): AI 主动巡检定时任务 — 每日扫描异常患者触发 AI 分析

- 新增 start_ai_patrol 定时任务(启动延迟 10 分钟 + 每 24 小时执行)
- 新增 get_patrol_candidates 函数:查询最近 7 天有未处理告警的患者
- 每个候选患者发布 ai.patrol.requested 事件(含 patient_id/doctor_id/reason)
- AI 模块可订阅此事件执行自动化分析(erp-ai 侧消费)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iven
2026-05-20 12:30:04 +08:00
parent 17114d492e
commit 5f34e5715a
2 changed files with 91 additions and 0 deletions

View File

@@ -134,6 +134,49 @@ impl HealthModule {
})
}
/// 启动 AI 主动巡检(每 24 小时运行一次),扫描有异常数据的患者并发布 AI 分析请求事件
pub fn start_ai_patrol(
db: sea_orm::DatabaseConnection,
event_bus: erp_core::events::EventBus,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// 首次延迟 10 分钟启动,等待其他服务就绪
tokio::time::sleep(std::time::Duration::from_secs(600)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
loop {
tokio::select! {
_ = interval.tick() => {
match crate::service::stats_service::get_patrol_candidates(&db).await {
Ok(patients) => {
tracing::info!(count = patients.len(), "AI 主动巡检:发现待分析患者");
for (tenant_id, patient_id, doctor_id, reason) in &patients {
let patrol_event = erp_core::events::DomainEvent::new(
"ai.patrol.requested",
*tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"patient_id": patient_id.to_string(),
"doctor_id": doctor_id.map(|d| d.to_string()),
"source": "daily_patrol",
"reason": reason,
})),
);
event_bus.publish(patrol_event, &db).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "AI 主动巡检:获取待分析患者失败");
}
}
}
_ = tokio::signal::ctrl_c() => {
tracing::info!("AI 主动巡检任务收到关闭信号,正在停止");
break;
}
}
}
})
}
pub fn public_routes<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
@@ -473,6 +516,10 @@ impl ErpModule for HealthModule {
let _daily_agg_handle = Self::start_daily_aggregation(ctx.db.clone());
tracing::info!(module = "health", "Daily aggregation task started");
// 启动 AI 主动巡检(每天扫描异常患者,发布 ai.patrol.requested 事件)
let _patrol_handle = Self::start_ai_patrol(ctx.db.clone(), ctx.event_bus.clone());
tracing::info!(module = "health", "AI patrol task started");
Ok(())
}

View File

@@ -31,3 +31,47 @@ pub use dashboard::get_module_status;
pub use dashboard::get_points_recent_activity;
pub use dashboard::get_system_health;
pub use dashboard::get_user_activity;
// ── AI 主动巡检 ──
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use uuid::Uuid;
/// 获取 AI 主动巡检候选患者:最近 7 天有异常告警但未做 AI 分析的患者
pub async fn get_patrol_candidates(
db: &DatabaseConnection,
) -> Result<Vec<(Uuid, Uuid, Option<Uuid>, String)>, sea_orm::DbErr> {
let seven_days_ago = chrono::Utc::now() - chrono::Duration::days(7);
// 查找最近 7 天有告警记录的患者(去重,每个患者取最新一条)
let alerts = crate::entity::alerts::Entity::find()
.filter(crate::entity::alerts::Column::CreatedAt.gte(seven_days_ago))
.filter(crate::entity::alerts::Column::DeletedAt.is_null())
.filter(crate::entity::alerts::Column::Status.is_in(["active", "acknowledged", "new"]))
.order_by_desc(crate::entity::alerts::Column::CreatedAt)
.all(db)
.await?;
// 按患者去重,保留最新告警
let mut seen = std::collections::HashSet::new();
let mut candidates = Vec::new();
for alert in alerts {
if seen.contains(&alert.patient_id) {
continue;
}
seen.insert(alert.patient_id);
// 查找管床医生
let doctor = crate::entity::patient_doctor_relation::Entity::find()
.filter(crate::entity::patient_doctor_relation::Column::PatientId.eq(alert.patient_id))
.filter(crate::entity::patient_doctor_relation::Column::TenantId.eq(alert.tenant_id))
.filter(crate::entity::patient_doctor_relation::Column::DeletedAt.is_null())
.one(db)
.await?
.map(|r| r.doctor_id);
let reason = format!("告警未处理: {} (severity: {})", alert.title, alert.severity);
candidates.push((alert.tenant_id, alert.patient_id, doctor, reason));
}
Ok(candidates)
}