docs: Phase 2 实施计划 — 告警引擎+SSE推送+前端页面 (Task 23-33)
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

新增 11 个 Task 覆盖:HealthError 变体、DTO、告警引擎核心
(三层规则评估+冷却期)、CRUD Service、Handler 路由注册、
事件订阅、SSE 多事件扩展、前端告警 SSE store、
告警列表页+规则管理页、种子数据、端到端验证。
This commit is contained in:
iven
2026-04-26 22:49:17 +08:00
parent d93cddc035
commit 125d2479ea

View File

@@ -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 # 告警 + 规则 APIPhase 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: 错误类型 + DTOTask 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<HealthError> for AppError 的 match**
`From<HealthError> for AppError` 实现的 or-chain 中追加:
```rust
HealthError::AlertRuleNotFound
| HealthError::AlertNotFound => AppError::NotFound(err.to_string()),
```
`From<sea_orm::DbErr> 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<String>,
pub device_type: String,
pub condition_type: String,
pub condition_params: serde_json::Value,
pub severity: Option<String>, // 默认 "warning"
pub apply_tags: Option<serde_json::Value>,
pub notify_roles: Option<serde_json::Value>,
pub cooldown_minutes: Option<i32>, // 默认 60
}
#[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: 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,
}
```
- [ ] **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<Vec<alerts::Model>> {
// 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<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())
}
```
- [ ] **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<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),
}
}
```
- [ ] **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<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();
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<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);
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<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(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<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")?;
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<alerts::Model> {
// 同 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<uuid::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, erp_core::error::AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<crate::dto::alert_dto::AcknowledgeAlertRequest>,
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
HealthState: FromRef<S>,
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<AlertResponse[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string | undefined>();
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
```
---