diff --git a/docs/superpowers/plans/2026-04-26-realtime-vital-signs-pipeline.md b/docs/superpowers/plans/2026-04-26-realtime-vital-signs-pipeline.md index d822599..06a5a0b 100644 --- a/docs/superpowers/plans/2026-04-26-realtime-vital-signs-pipeline.md +++ b/docs/superpowers/plans/2026-04-26-realtime-vital-signs-pipeline.md @@ -1047,14 +1047,968 @@ git push --- -## Phase 2 实施计划(概要,Phase 1 完成后细化) +## Phase 2 实施计划:告警引擎 + SSE 推送 -Phase 2 任务基于 Phase 1 的基础设施,在 `module.rs` 的 `on_startup()` 中: +> **前置条件:** Phase 1 全部完成(Task 1-22),数据库表已创建,Entity/Service/Handler 已注册。 -1. 注册 `device.readings.synced` 事件订阅 → 调用 `alert_engine::evaluate_rules()` -2. 实现 SSE 推送的医生匹配逻辑(查询患者的 attending_doctor) -3. Web 前端告警通知组件 + 体征实时看板 -4. 告警规则管理页面(Ant Design ProTable) +### 新建文件 + +``` +crates/erp-health/src/dto/ + alert_dto.rs # 告警规则 + 告警记录 DTO + +crates/erp-health/src/service/ + alert_engine.rs # 规则评估核心 + alert_service.rs # 告警 CRUD + alert_rule_service.rs # 规则 CRUD + +crates/erp-health/src/handler/ + alert_handler.rs # 告警列表 + 确认/处置 + alert_rule_handler.rs # 规则管理 CRUD + +apps/web/src/api/health/ + alerts.ts # 告警 + 规则 API(Phase 1 Task 21 已创建骨架) + +apps/web/src/stores/ + alert.ts # 告警 SSE store + +apps/web/src/pages/health/ + AlertList.tsx # 告警列表页 + AlertRuleList.tsx # 规则管理页 +``` + +### 修改文件 + +``` +crates/erp-health/src/error.rs # 新增 AlertRuleNotFound / AlertNotFound +crates/erp-health/src/dto/mod.rs # 导出 alert_dto +crates/erp-health/src/service/mod.rs # 导出 alert_engine/alert_service/alert_rule_service +crates/erp-health/src/handler/mod.rs # 导出 alert_handler/alert_rule_handler +crates/erp-health/src/module.rs # 注册告警路由 + 事件订阅 +crates/erp-health/src/event.rs # 新增 device.readings.synced 订阅 +crates/erp-message/src/handler/sse_handler.rs # SSE 扩展(如 Phase 1 未完成) +apps/web/src/stores/message.ts # 新增 alert/vital_update 事件监听 +``` + +--- + +## Chunk 9: 错误类型 + DTO(Task 23-24) + +### Task 23: HealthError 新增告警相关变体 + +**Files:** +- Modify: `crates/erp-health/src/error.rs` + +- [ ] **Step 1: 添加新变体** + +在 `error.rs` 的 HealthError 枚举中追加(遵循 `XxxNotFound` 命名模式): + +```rust +#[error("告警规则不存在")] +AlertRuleNotFound, + +#[error("告警记录不存在")] +AlertNotFound, +``` + +- [ ] **Step 2: 更新 From for AppError 的 match** + +在 `From for AppError` 实现的 or-chain 中追加: + +```rust +HealthError::AlertRuleNotFound +| HealthError::AlertNotFound => AppError::NotFound(err.to_string()), +``` + +在 `From for HealthError` 的匹配中无需修改(使用 `HealthError::DbError(e.to_string())` 兜底)。 + +- [ ] **Step 3: 验证 `cargo check -p erp-health`** + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-health/src/error.rs +git commit -m "feat(health): HealthError 新增 AlertRuleNotFound/AlertNotFound 变体" +``` + +--- + +### Task 24: alert_dto — 告警 DTO + +**Files:** +- Create: `crates/erp-health/src/dto/alert_dto.rs` +- Modify: `crates/erp-health/src/dto/mod.rs` + +- [ ] **Step 1: 创建 DTO 文件** + +遵循 `patient_dto.rs` 模式(`#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]`): + +```rust +// dto/alert_dto.rs +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, + pub device_type: String, + pub condition_type: String, + pub condition_params: serde_json::Value, + pub severity: Option, // 默认 "warning" + pub apply_tags: Option, + pub notify_roles: Option, + pub cooldown_minutes: Option, // 默认 60 +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateAlertRuleRequest { + pub name: Option, + pub description: Option, + pub condition_params: Option, + pub severity: Option, + pub apply_tags: Option, + pub notify_roles: Option, + pub cooldown_minutes: Option, + pub version: i32, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct AlertRuleResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + 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, + pub notify_roles: serde_json::Value, + pub cooldown_minutes: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + 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: serde_json::Value, + pub status: String, + pub acknowledged_by: Option, + pub acknowledged_at: Option>, + pub resolved_at: Option>, + pub created_at: DateTime, + pub version: i32, +} +``` + +- [ ] **Step 2: 在 dto/mod.rs 添加 `pub mod alert_dto;`** + +- [ ] **Step 3: `cargo check` + 提交** + +```bash +git add crates/erp-health/src/dto/alert_dto.rs crates/erp-health/src/dto/mod.rs +git commit -m "feat(health): alert_dto — 告警规则+告警记录 DTO" +``` + +--- + +## Chunk 10: 告警引擎核心(Task 25-26) + +### Task 25: alert_engine — 规则评估核心 + +**Files:** +- Create: `crates/erp-health/src/service/alert_engine.rs` +- Modify: `crates/erp-health/src/service/mod.rs` + +- [ ] **Step 1: 创建告警引擎** + +```rust +// service/alert_engine.rs +use crate::entity::{alert_rules, alerts, vital_signs_hourly}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; +use erp_core::events::DomainEvent; +use sea_orm::*; +use serde_json::json; +use uuid::Uuid; +use chrono::Utc; + +/// 评估所有适用规则,返回触发的告警列表 +pub async fn evaluate_rules( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, +) -> HealthResult> { + // 1. 加载该租户激活的规则 + 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 { + // 2. 检查冷却期 + if is_in_cooldown(&state.db, tenant_id, patient_id, rule.id, rule.cooldown_minutes).await? { + continue; + } + + // 3. 解析条件参数 + 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) +} +``` + +- [ ] **Step 2: 实现 is_in_cooldown** + +查询 `alerts` 表中同一规则同一患者、created_at 在冷却期内的记录。存在则跳过。 + +```rust +async fn is_in_cooldown( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + rule_id: Uuid, + cooldown_minutes: i32, +) -> HealthResult { + 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()) +} +``` + +- [ ] **Step 3: 实现 evaluate_single_threshold** + +查询 `vital_signs_hourly` 最新一条记录,检查 avg_val 是否超出阈值。 + +```rust +async fn evaluate_single_threshold( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + 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), + } +} +``` + +- [ ] **Step 4: 实现 evaluate_consecutive** + +查询 `vital_signs_hourly` 最近 N 条记录,检查是否全部超出阈值(连续性)。 + +```rust +async fn evaluate_consecutive( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + 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(); + + 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) +} +``` + +- [ ] **Step 5: 实现 evaluate_trend** + +查询窗口内数据,计算首尾差值判断趋势。 + +```rust +async fn evaluate_trend( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + 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); + 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, + }) +} +``` + +- [ ] **Step 6: 实现 create_alert_and_notify** + +生成告警记录 + 发布 EventBus 事件。 + +```rust +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 { + 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(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?; + + // 发布 alert.triggered 事件 + let event = 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) +} +``` + +- [ ] **Step 7: 在 mod.rs 添加 `pub mod alert_engine;`** + +- [ ] **Step 8: `cargo check` + 提交** + +```bash +git add crates/erp-health/src/service/alert_engine.rs crates/erp-health/src/service/mod.rs +git commit -m "feat(health): alert_engine — 三层规则评估(阈值/连续/趋势)+ 冷却期 + 事件发布" +``` + +--- + +### Task 26: alert_service + alert_rule_service — CRUD + +**Files:** +- Create: `crates/erp-health/src/service/alert_service.rs` +- Create: `crates/erp-health/src/service/alert_rule_service.rs` + +- [ ] **Step 1: alert_service.rs — 告警 CRUD** + +```rust +// service/alert_service.rs +use crate::entity::alerts; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation; +use crate::state::HealthState; +use sea_orm::*; +use uuid::Uuid; + +pub async fn list_alerts( + state: &HealthState, + tenant_id: Uuid, + patient_id: Option, + status: Option<&str>, + page: u64, + page_size: u64, +) -> HealthResult<(Vec, 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 { + 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")?; + + 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(chrono::Utc::now())); + active.version = Set(version); + active.updated_at = Set(chrono::Utc::now()); + + 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 { + // 同 acknowledge 模式,目标状态为 "dismissed" + // 校验 pending/acknowledged → dismissed 转换合法性 + // ... +} +``` + +- [ ] **Step 2: alert_rule_service.rs — 规则 CRUD** + +实现 `list_rules()`, `create_rule()`, `update_rule()`, `deactivate_rule()`。 +- `create_rule` 校验 device_type、condition_type、severity 枚举合法性 +- `update_rule` 检查 version 乐观锁 +- `deactivate_rule` 设置 `is_active = false` + +- [ ] **Step 3: 在 mod.rs 导出 + `cargo check` + 提交** + +```bash +git add crates/erp-health/src/service/alert_service.rs crates/erp-health/src/service/alert_rule_service.rs crates/erp-health/src/service/mod.rs +git commit -m "feat(health): alert_service/alert_rule_service — 告警+规则 CRUD" +``` + +--- + +## Chunk 11: Handler + 路由 + 事件订阅(Task 27-28) + +### Task 27: alert_handler + alert_rule_handler + +**Files:** +- Create: `crates/erp-health/src/handler/alert_handler.rs` +- Create: `crates/erp-health/src/handler/alert_rule_handler.rs` + +- [ ] **Step 1: alert_handler.rs** + +遵循 `health_data_handler.rs` 的泛型签名模式: + +```rust +use axum::{extract::{State, Extension, Path, Query}, Json}; +use axum::response::IntoResponse; +use erp_core::rbac::require_permission; +use erp_core::types::{TenantContext, ApiResponse, PaginatedResponse}; +use crate::state::HealthState; +use crate::service::alert_service; +use serde::Deserialize; +use std::ops::Deref; + +#[derive(Debug, Deserialize)] +pub struct AlertListQuery { + pub patient_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_alerts( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.list")?; + let tenant_id = ctx.tenant_id; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + + let (items, total) = alert_service::list_alerts( + &state, tenant_id, query.patient_id, query.status.as_deref(), + page, page_size, + ).await?; + + Ok(Json(ApiResponse::ok(PaginatedResponse { + items, + total, + page, + page_size, + total_pages: total.div_ceil(page_size.max(1)), + }))) +} + +pub async fn acknowledge( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(body): Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.manage")?; + let user_id = ctx.user_id; + let alert = alert_service::acknowledge_alert( + &state, ctx.tenant_id, id, user_id, body.version, + ).await?; + Ok(Json(ApiResponse::ok(alert))) +} +``` + +- [ ] **Step 2: alert_rule_handler.rs** + +同模式实现 `list_rules`, `create_rule`, `update_rule`, `deactivate_rule`。 + +- [ ] **Step 3: 在 handler/mod.rs 导出 + `cargo check` + 提交** + +```bash +git add crates/erp-health/src/handler/alert_handler.rs crates/erp-health/src/handler/alert_rule_handler.rs crates/erp-health/src/handler/mod.rs +git commit -m "feat(health): alert_handler/alert_rule_handler — 告警+规则端点" +``` + +--- + +### Task 28: 路由注册 + 事件订阅 + +**Files:** +- Modify: `crates/erp-health/src/module.rs` +- Modify: `crates/erp-health/src/event.rs` + +- [ ] **Step 1: 在 module.rs protected_routes() 注册告警路由** + +追加到路由链末尾: + +```rust + // 告警路由 + .route("/health/alerts", get(alert_handler::list_alerts)) + .route("/health/alerts/{id}/acknowledge", put(alert_handler::acknowledge)) + .route("/health/alerts/{id}/dismiss", put(alert_handler::dismiss)) + .route("/health/alert-rules", get(alert_rule_handler::list_rules).post(alert_rule_handler::create)) + .route("/health/alert-rules/{id}", put(alert_rule_handler::update)) + .route("/health/alert-rules/{id}/deactivate", put(alert_rule_handler::deactivate)) +``` + +- [ ] **Step 2: 在 event.rs 新增 device.readings.synced 事件订阅** + +在 `register_handlers_with_state()` 中新增: + +```rust +// 订阅 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 { + if let Err(e) = crate::service::alert_engine::evaluate_rules( + &eval_state, event.tenant_id, pid, "heart_rate", + ).await { + tracing::error!("告警评估失败: {:?}", e); + } + } + } + Some(_) => {} + None => break, + } + } +}); +``` + +**注意:** 首版只对 `heart_rate` 类型触发评估。后续需要根据 readings 中的 device_type 动态选择评估的指标类型(可以从事件的 payload 中读取,或查询最近写入的 device_type 列表)。 + +- [ ] **Step 3: `cargo check` + `cargo test --workspace` + 提交** + +```bash +git add crates/erp-health/src/module.rs crates/erp-health/src/event.rs +git commit -m "feat(health): 注册告警路由 + device.readings.synced 事件订阅触发评估" +``` + +--- + +## Chunk 12: SSE 扩展 + 前端告警(Task 29-31) + +### Task 29: SSE Handler 扩展(如 Phase 1 未完成) + +**Files:** +- Modify: `crates/erp-message/src/handler/sse_handler.rs` + +- [ ] **Step 1: 改造 SSE 为多事件类型** + +将 `subscribe_filtered("message.sent")` 改为 `subscribe_filtered("")`(空前缀匹配所有事件),在 stream 中按 event_type 分发: + +```rust +// 核心分发逻辑 +loop { + match rx.recv().await { + Some(event) => { + if event.tenant_id != tenant_id { continue; } + + // 消息通知(已有) + if event.event_type == "message.sent" { + let recipient = event.payload.get("recipient_id") + .and_then(|v| v.as_str()); + if recipient == Some(&user_id.to_string()) { + let data = serde_json::to_string(&event.payload).unwrap_or_default(); + yield Ok(Event::default().event("message").data(data)); + } + } + + // 告警通知(新增) + if event.event_type == "alert.triggered" { + let notify_roles = event.payload.get("notify_roles") + .and_then(|v| v.as_array()); + // 简化:推送给同租户的所有在线用户(前端根据角色过滤) + // 或查询用户的角色列表匹配 notify_roles + let data = serde_json::to_string(&event.payload).unwrap_or_default(); + yield Ok(Event::default().event("alert").data(data)); + } + + // 体征更新(新增) + if event.event_type == "device.readings.synced" { + let data = serde_json::to_string(&event.payload).unwrap_or_default(); + yield Ok(Event::default().event("vital_update").data(data)); + } + } + Err(Lagged(n)) => tracing::warn!("SSE lagged {n} events"), + Err(_) => break, + } +} +``` + +- [ ] **Step 2: `cargo check` + 提交** + +```bash +git add crates/erp-message/src/handler/sse_handler.rs +git commit -m "feat: SSE 多事件类型 — message/alert/vital_update" +``` + +--- + +### Task 30: 前端告警 SSE Store + API + +**Files:** +- Modify: `apps/web/src/stores/message.ts`(或新建 `apps/web/src/stores/alert.ts`) +- Modify: `apps/web/src/api/health/alerts.ts`(Phase 1 Task 21 已创建骨架) + +- [ ] **Step 1: 扩展 message.ts 的 SSE 监听** + +在 `connectSSE()` 方法中添加新事件监听器: + +```typescript +// 在 message.ts 的 connectSSE 中,es.addEventListener('message', ...) 之后添加: + +es.addEventListener('alert', (e) => { + try { + const data = JSON.parse(e.data); + // 显示告警通知(antd notification) + // 只对有 health.alerts.list 权限的用户显示 + notification.warning({ + message: '健康告警', + description: data.rule_name || '规则触发', + duration: 0, // 不自动关闭 + }); + } catch {} +}); + +es.addEventListener('vital_update', (e) => { + try { + const data = JSON.parse(e.data); + // 如果正在查看该患者,刷新体征图表 + // 用自定义事件或 Zustand store 触发刷新 + } catch {} +}); +``` + +- [ ] **Step 2: 完善 alerts.ts API** + +确保包含所有 Phase 2 需要的 API 函数。 + +- [ ] **Step 3: `pnpm build` + 提交** + +```bash +git add apps/web/src/stores/message.ts apps/web/src/api/health/alerts.ts +git commit -m "feat(web): 告警 SSE 监听 + notification 弹窗" +``` + +--- + +### Task 31: Web 端告警列表页 + 规则管理页 + +**Files:** +- Create: `apps/web/src/pages/health/AlertList.tsx` +- Create: `apps/web/src/pages/health/AlertRuleList.tsx` + +- [ ] **Step 1: AlertList.tsx** + +遵循 `PatientList.tsx` 模式(antd Table + useState + useCallback): + +```typescript +// 核心结构 +const [alerts, setAlerts] = useState([]); +const [total, setTotal] = useState(0); +const [loading, setLoading] = useState(false); +const [page, setPage] = useState(1); +const [statusFilter, setStatusFilter] = useState(); + +const fetchAlerts = useCallback(async () => { + setLoading(true); + try { + const res = await listAlerts({ patient_id: selectedPatientId, status: statusFilter, page, page_size: 20 }); + setAlerts(res.items); + setTotal(res.total); + } finally { + setLoading(false); + } +}, [page, statusFilter]); + +// 表格列:患者姓名、规则名称、严重程度(Tag)、状态(Tag)、触发时间、操作 +// 操作列:确认按钮(pending→acknowledged)、忽略按钮 +// 状态 Tag 颜色:pending=orange, acknowledged=blue, resolved=green, dismissed=default +``` + +- [ ] **Step 2: AlertRuleList.tsx** + +```typescript +// 规则管理页 +// 列:规则名称、指标类型、条件类型、严重程度、启用状态(Switch)、冷却时间、操作 +// 操作列:编辑(Modal)、启用/禁用(Switch) +// 新建按钮:Modal 表单(规则名称、指标类型 Select、条件类型 Select、参数 JSON 编辑器、严重程度 Select) +``` + +- [ ] **Step 3: 在路由中注册新页面** + +在 `apps/web/src/App.tsx` 或对应的路由配置文件中注册这两个页面路由。 + +- [ ] **Step 4: 在侧边栏菜单中添加入口** + +- [ ] **Step 5: `pnpm build` + 提交** + +```bash +git add apps/web/src/pages/health/AlertList.tsx apps/web/src/pages/health/AlertRuleList.tsx +git commit -m "feat(web): 告警列表页 + 规则管理页" +``` + +--- + +## Chunk 13: 端到端验证 + 种子数据(Task 32-33) + +### Task 32: 告警规则种子数据 + +**Files:** +- Modify: 可在 `module.rs` 的 `on_startup` 中添加默认规则创建 + +- [ ] **Step 1: 创建默认告警规则** + +为每个租户初始化几条基础规则(仅在首次启动时创建): + +```rust +// 在 on_startup 中,遍历已有租户,为没有规则的租户创建默认规则 +let default_rules = vec![ + ("心率持续过高", "heart_rate", "consecutive", json!({"count":3,"direction":"above","value":100,"window_hours":1}), "warning", 60), + ("心率过速", "heart_rate", "single_threshold", json!({"direction":"above","value":150}), "critical", 30), + ("心率过缓", "heart_rate", "single_threshold", json!({"direction":"below","value":50}), "critical", 30), + ("血氧过低", "blood_oxygen", "single_threshold", json!({"direction":"below","value":94}), "urgent", 15), +]; +``` + +- [ ] **Step 2: 提交** + +```bash +git add crates/erp-health/src/module.rs +git commit -m "feat(health): 默认告警规则种子数据" +``` + +--- + +### Task 33: Phase 2 端到端集成验证 + +- [ ] **Step 1: `cargo check --workspace`** + +- [ ] **Step 2: `cargo test --workspace`** + +- [ ] **Step 3: 启动后端,验证新路由** + +用 Swagger UI 测试: +1. `POST /health/alert-rules` — 创建规则 +2. `GET /health/alert-rules` — 查询规则列表 +3. `POST /health/patients/{id}/device-readings/batch` — 提交体征数据(触发告警评估) +4. `GET /health/alerts` — 查看告警是否生成 +5. `PUT /health/alerts/{id}/acknowledge` — 确认告警 + +- [ ] **Step 4: 前端验证** + +1. 打开告警列表页,确认数据加载正常 +2. 打开规则管理页,创建/编辑规则 +3. 提交体征数据,观察 SSE 告警弹窗 + +- [ ] **Step 5: `pnpm build` 前端生产构建** + +- [ ] **Step 6: 最终提交 + 推送** + +```bash +git add -A +git commit -m "feat(health): Phase 2 完成 — 告警引擎+SSE推送+前端页面" +git push +``` ---