新增 11 个 Task 覆盖:HealthError 变体、DTO、告警引擎核心 (三层规则评估+冷却期)、CRUD Service、Handler 路由注册、 事件订阅、SSE 多事件扩展、前端告警 SSE store、 告警列表页+规则管理页、种子数据、端到端验证。
63 KiB
实时体征采集与智能告警系统 — 实施计划
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现"健康手环 → 小程序 BLE → REST API → PostgreSQL 分区表 → 降采样聚合 → 规则告警 → SSE 推送"的完整闭环。
Architecture: 小程序通过 BLE 适配器抽象层采集手环数据,批量提交到 HMS REST API。后端分快速路径(校验+存储+降采样+事件)和异步路径(告警评估)。数据存储采用分区表(原始)+ 聚合表(小时级)双轨制。告警引擎基于 PostgreSQL 窗口函数实现滑动窗口规则评估。
Tech Stack: Rust/Axum/SeaORM/PostgreSQL(后端)+ Taro 4.2/React 18/微信 BLE API(小程序)+ React 19/Ant Design(Web 前端)
Spec: docs/superpowers/specs/2026-04-26-realtime-vital-signs-pipeline-design.md
File Structure
新建文件
crates/erp-server/migration/src/
m20260426_000073_create_device_readings.rs # 分区表迁移
m20260426_000074_create_vital_signs_hourly.rs # 降采样表迁移
m20260426_000075_create_patient_devices.rs # 设备绑定表迁移
m20260426_000076_create_alert_rules.rs # 告警规则表迁移
m20260426_000077_create_alerts.rs # 告警记录表迁移
crates/erp-health/src/entity/
device_readings.rs # 原始设备数据 Entity
vital_signs_hourly.rs # 小时聚合 Entity
patient_devices.rs # 设备绑定 Entity
alert_rules.rs # 告警规则 Entity
alerts.rs # 告警记录 Entity
crates/erp-health/src/service/
device_reading_service.rs # 摄入+降采样+查询 service
alert_engine.rs # 告警引擎核心
alert_service.rs # 告警 CRUD service
crates/erp-health/src/handler/
device_reading_handler.rs # 设备数据摄入+查询 handler
alert_handler.rs # 告警 handler
alert_rule_handler.rs # 规则管理 handler
apps/miniprogram/src/services/
ble/ # BLE 采集模块目录
types.ts # 统一类型定义
BLEManager.ts # 连接管理器
adapters/
XiaomiBandAdapter.ts # 小米手环适配器
device-sync.ts # 设备数据同步 API
apps/miniprogram/src/pages/
device-sync/ # 设备同步页面
index.tsx
index.config.ts
index.scss
apps/web/src/api/health/
deviceReadings.ts # 设备数据 API
alerts.ts # 告警 API
修改文件
crates/erp-server/migration/src/lib.rs # 注册新迁移
crates/erp-health/src/entity/mod.rs # 导出新 Entity
crates/erp-health/src/service/mod.rs # 导出新 service
crates/erp-health/src/handler/mod.rs # 导出新 handler
crates/erp-health/src/module.rs # 注册路由+权限+事件订阅
crates/erp-health/src/service/validation.rs # 新增枚举校验
crates/erp-message/src/handler/sse_handler.rs # SSE 扩展
apps/web/src/stores/message.ts # 前端 SSE 监听扩展
Chunk 1: 数据库迁移(Task 1-5)
Task 1: device_readings 分区表迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建迁移文件
// m20260426_000073_create_device_readings.rs
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.exec_stmt(
Table::create()
.table(Alias::new("device_readings"))
.engine("Incompatible") // 占位,用 raw SQL
.to_owned(),
).await.ok(); // 忽略,下面用 raw SQL
let sql = r#"
CREATE TABLE IF NOT EXISTS device_readings (
id UUID PRIMARY KEY 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 DROP CONSTRAINT IF EXISTS device_readings_pkey;"
).await.ok();
// 用 (device_id, device_type, measured_at) 作为分区键下的唯一约束
// 注意:分区表的唯一索引必须包含分区键 measured_at
manager.get_connection().execute_unprepared(
"ALTER TABLE device_readings ADD PRIMARY KEY (id, measured_at);"
).await?;
// 去重索引 — 分区表唯一索引必须包含分区键,此处已包含 measured_at
// 但 tenant_id/patient_id 不是分区键,不能用 UNIQUE INDEX
// 改用应用层去重(batch_insert 用 ON CONFLICT DO NOTHING)
// 不创建全局唯一索引
// 核心查询索引
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
}
}
- Step 2: 注册迁移到 lib.rs
在 migration/src/lib.rs 中:
- 添加
mod m20260426_000073_create_device_readings; - 在
migrations()的vec![]末尾追加Box::new(m20260426_000073_create_device_readings::Migration),
- Step 3: 验证编译
Run: cargo check
Expected: 编译通过
- Step 4: 运行迁移
Run: cd crates/erp-server && cargo run(启动后端自动执行迁移)
Expected: 日志中看到 apply migration m20260426_000073_create_device_readings
- Step 5: 验证表结构
Run: D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp -c "\d device_readings"
Expected: 表存在,含分区信息
- Step 6: 提交
git add crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs crates/erp-server/migration/src/lib.rs
git commit -m "feat(db): 创建 device_readings 分区表 — 原始设备数据存储"
Task 2: vital_signs_hourly 降采样表迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260426_000074_create_vital_signs_hourly.rs -
Modify:
crates/erp-server/migration/src/lib.rs -
Step 1: 创建迁移文件
// m20260426_000074_create_vital_signs_hourly.rs
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
}
}
- Step 2: 注册迁移 + 编译 + 迁移 + 验证 + 提交
同 Task 1 的 Step 2-6 模式。提交信息:feat(db): 创建 vital_signs_hourly 降采样表
Task 3: patient_devices 设备绑定表迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260426_000075_create_patient_devices.rs -
Step 1: 创建迁移文件
标准 HMS 表结构(id, tenant_id, patient_id, device_id, device_model, device_type, bound_at, created_at, updated_at, created_by, updated_by, deleted_at, version)。含 UNIQUE (tenant_id, patient_id, device_id) 约束。
遵循 Task 2 的 Table::create() 模式。
- Step 2-6: 注册 + 编译 + 迁移 + 验证 + 提交
提交信息:feat(db): 创建 patient_devices 设备绑定表
Task 4: alert_rules 告警规则表迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260426_000076_create_alert_rules.rs -
Step 1: 创建迁移文件
字段:id, tenant_id, name, description, device_type, condition_type, condition_params(JSONB), severity, is_active, apply_tags(JSONB), notify_roles(JSONB), created_at, updated_at, created_by, updated_by, deleted_at, version。
- Step 2-6: 注册 + 编译 + 迁移 + 验证 + 提交
提交信息:feat(db): 创建 alert_rules 告警规则表
Task 5: alerts 告警记录表迁移
Files:
-
Create:
crates/erp-server/migration/src/m20260426_000077_create_alerts.rs -
Step 1: 创建迁移文件
字段:id, tenant_id, patient_id, rule_id(FK→alert_rules ON DELETE RESTRICT), severity, title, detail(JSONB), status, acknowledged_by, acknowledged_at, resolved_at, created_at, updated_at, deleted_at, version。
索引:idx_alerts_tenant_patient(tenant_id, patient_id, created_at DESC), idx_alerts_status(tenant_id, status, created_at DESC)。
- Step 2-6: 注册 + 编译 + 迁移 + 验证 + 提交
提交信息:feat(db): 创建 alerts 告警记录表
Chunk 2: Entity + Validation(Task 6-10)
Task 6: device_readings Entity
Files:
-
Create:
crates/erp-health/src/entity/device_readings.rs -
Modify:
crates/erp-health/src/entity/mod.rs -
Step 1: 创建 Entity 文件
遵循 vital_signs.rs 模式:
-
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -
#[sea_orm(table_name = "device_readings")] -
所有字段用
Option<T>包裹可空字段 -
不含
updated_at/updated_by/version(不可变数据) -
主键:
#[sea_orm(primary_key, auto_increment = false)]id: Uuid -
Relation为空#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -
Step 2: 在 mod.rs 中导出
添加 pub mod device_readings;
-
Step 3: 验证
cargo check通过 -
Step 4: 提交
git add crates/erp-health/src/entity/device_readings.rs crates/erp-health/src/entity/mod.rs
git commit -m "feat(health): device_readings Entity — 原始设备数据"
Task 7: vital_signs_hourly Entity
Files:
- Create:
crates/erp-health/src/entity/vital_signs_hourly.rs - Modify:
crates/erp-health/src/entity/mod.rs
同 Task 6 模式,含标准字段(id, tenant_id, created_at, updated_at, version)。不含 deleted_at(聚合数据不软删除)。
提交信息:feat(health): vital_signs_hourly Entity — 小时降采样
Task 8: patient_devices Entity
Files:
- Create:
crates/erp-health/src/entity/patient_devices.rs
标准 HMS Entity 模式,含所有标准字段。
提交信息:feat(health): patient_devices Entity — 设备绑定
Task 9: alert_rules + alerts Entity
Files:
- Create:
crates/erp-health/src/entity/alert_rules.rs - Create:
crates/erp-health/src/entity/alerts.rs
alerts 的 Relation 中添加 belongs_to 关联到 alert_rules。
提交信息:feat(health): alert_rules + alerts Entity — 告警规则与记录
Task 10: 新增枚举校验
Files:
-
Modify:
crates/erp-health/src/service/validation.rs -
Step 1: 添加枚举校验函数
在 validation.rs 中新增:
// 使用 validate_enum! 宏添加以下校验
validate_enum!(validate_device_type, "device_type", &[
"heart_rate", "blood_oxygen", "steps", "sleep", "temperature", "stress"
]);
validate_enum!(validate_condition_type, "condition_type", &[
"single_threshold", "consecutive", "trend"
]);
validate_enum!(validate_alert_severity, "alert_severity", &[
"info", "warning", "critical", "urgent"
]);
validate_enum!(validate_alert_status, "alert_status", &[
"pending", "acknowledged", "resolved", "dismissed"
]);
validate_enum!(validate_direction, "direction", &[
"above", "below", "up", "down"
]);
// 告警状态转换校验
pub fn validate_alert_status_transition(current: &str, next: &str) -> HealthResult<()> {
match current {
"pending" => matches!(next, "acknowledged" | "dismissed")
.then_some(())
.ok_or_else(|| HealthError::InvalidStatusTransition(
format!("alert: {} -> {}", current, next)
)),
"acknowledged" => matches!(next, "resolved" | "dismissed")
.then_some(())
.ok_or_else(|| HealthError::InvalidStatusTransition(
format!("alert: {} -> {}", current, next)
)),
_ => Err(HealthError::InvalidStatusTransition(
format!("alert: terminal state {} cannot transition", current)
)),
}
}
- Step 2: 添加单元测试
在 #[cfg(test)] mod tests 中新增对应的测试用例。
-
Step 3: 运行测试
cargo test -p erp-health -- validation -
Step 4: 提交
git add crates/erp-health/src/service/validation.rs
git commit -m "feat(health): 设备类型/告警枚举校验 + 状态转换验证"
Chunk 3: Service 层 — 数据摄入与降采样(Task 11-12)
Task 11: device_reading_service — 数据摄入 + 降采样
Files:
-
Create:
crates/erp-health/src/service/device_reading_service.rs -
Modify:
crates/erp-health/src/service/mod.rs -
Step 1: 创建 service 文件骨架
// device_reading_service.rs
use crate::entity::{device_readings, vital_signs_hourly, patient_devices, patient};
use crate::error::{HealthResult, HealthError};
use crate::state::HealthState;
use erp_core::events::{EventBus, DomainEvent};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc, Timelike};
use std::collections::HashMap;
// ── 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, // ISO 8601
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BatchResult {
pub accepted: u64,
pub duplicates: u64,
pub earliest: Option<String>,
pub latest: Option<String>,
}
// ── 核心函数 ──
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_else(|| 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;
for r in &req.readings {
// 校验 device_type 枚举
crate::service::validation::validate_device_type(&r.device_type)?;
// 解析 measured_at
let measured_at: DateTime<Utc> = r.measured_at.parse()
.map_err(|_| HealthError::Validation("measured_at 格式无效".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));
}
// 4. 批量插入(ON CONFLICT DO NOTHING)
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()),
}
}),
);
// publish 返回 (),fire-and-forget(outbox 持久化在 publish 内部处理)
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()),
})
}
- Step 2: 实现 ensure_device_binding
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), // 由 readings 中推断
bound_at: Set(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?;
}
Ok(())
}
- Step 3: 实现 batch_insert_readings(raw SQL 批量插入)
使用 INSERT ... ON CONFLICT DO NOTHING 实现。由于 SeaORM 不原生支持分区表的批量 upsert,这里用 raw SQL。
- Step 4: 实现 upsert_hourly_aggregates
按 (device_type, hour_start) 分组,计算 min/max/avg/count,执行 INSERT ... ON CONFLICT DO UPDATE。
- Step 5: 在 mod.rs 中导出
添加 pub mod device_reading_service;
- Step 6:
cargo check通过 + 提交
git add crates/erp-health/src/service/device_reading_service.rs crates/erp-health/src/service/mod.rs
git commit -m "feat(health): device_reading_service — 批量摄入+降采样+事件发布"
Task 12: device_reading_service — 查询 API
Files:
- Modify:
crates/erp-health/src/service/device_reading_service.rs
在同一个 service 文件中添加查询函数:
pub async fn query_device_readings(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
device_type: Option<&str>,
hours: Option<i64>,
) -> HealthResult<Vec<DeviceReadingDto>> { ... }
pub async fn query_hourly_readings(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
days: i64,
) -> HealthResult<Vec<HourlyReadingDto>> { ... }
遵循现有 health_data_service.rs 的分页查询模式。提交信息:feat(health): 设备数据查询 API — 原始+降采样
Chunk 4: Handler 层 + 路由注册(Task 13-14)
Task 13: device_reading_handler
Files:
-
Create:
crates/erp-health/src/handler/device_reading_handler.rs -
Modify:
crates/erp-health/src/handler/mod.rs -
Step 1: 创建 handler
遵循 health_data_handler.rs 模式:
use axum::{extract::{State, Extension, Path, Query}, Json};
use erp_core::rbac::require_permission;
use erp_core::types::TenantContext;
use erp_core::types::ApiResponse;
use crate::state::HealthState;
use crate::service::device_reading_service;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct BatchReadingPath {
pub patient_id: uuid::Uuid,
}
pub async fn batch_create<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(path): Path<BatchReadingPath>,
Json(body): Json<device_reading_service::BatchReadingRequest>,
) -> Result<Json<ApiResponse<device_reading_service::BatchResult>>, AppError>
where HealthState: std::ops::Deref<Target = ...>, // FromRef 模式
{
require_permission(&ctx, "health.device-readings.manage")?;
let tenant_id = ctx.tenant_id();
let result = device_reading_service::batch_create_readings(
&state, tenant_id, path.patient_id, body,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
// 查询原始数据
pub async fn list_readings<S>(...) { ... }
// 查询降采样数据
pub async fn list_hourly<S>(...) { ... }
- Step 2: 在 mod.rs 中导出
添加 pub mod device_reading_handler;
- Step 3:
cargo check+ 提交
git add crates/erp-health/src/handler/device_reading_handler.rs crates/erp-health/src/handler/mod.rs
git commit -m "feat(health): device_reading_handler — 批量摄入+查询端点"
Task 14: 路由注册 + 权限 + 事件订阅
Files:
-
Modify:
crates/erp-health/src/module.rs -
Step 1: 注册路由
在 protected_routes() 中添加:
// 设备数据路由
.nest("/health", Router::new()
// ... 现有路由
.route("/patients/{patient_id}/device-readings/batch",
post(device_reading_handler::batch_create))
.route("/patients/{patient_id}/device-readings",
get(device_reading_handler::list_readings))
.route("/patients/{patient_id}/device-readings/hourly",
get(device_reading_handler::list_hourly))
// 告警路由在 Phase 2 Task 16 中注册
)
- Step 2: 注册权限
在 permissions() 中添加:
PermissionDescriptor {
code: "health.device-readings.list".into(),
name: "查看设备数据".into(),
description: Some("查看患者的设备采集数据".into()),
},
PermissionDescriptor {
code: "health.device-readings.manage".into(),
name: "管理设备数据".into(),
description: Some("提交设备采集数据".into()),
},
PermissionDescriptor {
code: "health.alerts.list".into(),
name: "查看告警".into(),
description: Some("查看告警记录".into()),
},
PermissionDescriptor {
code: "health.alerts.manage".into(),
name: "管理告警".into(),
description: Some("确认/处置告警".into()),
},
PermissionDescriptor {
code: "health.alert-rules.list".into(),
name: "查看告警规则".into(),
description: Some("查看告警规则配置".into()),
},
PermissionDescriptor {
code: "health.alert-rules.manage".into(),
name: "管理告警规则".into(),
description: Some("创建/编辑/启停告警规则".into()),
},
- Step 3: 注册事件订阅
在 register_event_handlers() 或 on_startup() 中添加对 device.readings.synced 事件的订阅(Phase 2 告警引擎消费)。
- Step 4:
cargo check+cargo test --workspace+ 提交
git add crates/erp-health/src/module.rs
git commit -m "feat(health): 注册设备数据路由+权限+事件订阅"
Chunk 5: 告警引擎(Task 15-16)
Task 15: alert_engine — 规则评估核心
Files:
-
Create:
crates/erp-health/src/service/alert_engine.rs -
Step 1: 创建告警引擎
核心函数 evaluate_rules(db, tenant_id, patient_id, device_type, event_bus) -> Vec<Alert>:
load_active_rules()— 查询alert_rules表- 遍历规则,检查冷却期(查询最近一条同规则同患者的 alert)
- 按条件类型调用
evaluate_single()/evaluate_consecutive()/evaluate_trend() - 匹配则
create_alert()+event_bus.publish("alert.triggered", ...)
滑动窗口查询使用 raw SQL 查询 vital_signs_hourly 表。
- Step 2: 在 mod.rs 导出 +
cargo check+ 提交
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 16: alert_service + alert_rule_service — CRUD
Files:
-
Create:
crates/erp-health/src/service/alert_service.rs -
Create:
crates/erp-health/src/handler/alert_handler.rs -
Create:
crates/erp-health/src/handler/alert_rule_handler.rs -
Step 1: alert_service
-
list_alerts()— 分页查询 alerts 表 -
acknowledge_alert()— 状态转换 pending→acknowledged -
resolve_alert()— 状态转换 acknowledged→resolved -
dismiss_alert()— 状态转换 pending/acknowledged→dismissed -
Step 2: alert_rule_service
-
list_rules()— 分页查询 alert_rules -
create_rule()— 校验 + 插入 -
update_rule()— 乐观锁更新 -
deactivate_rule()— 设置 is_active=false -
Step 3: handlers
遵循 device_reading_handler.rs 模式。
-
Step 4: 在 module.rs 中注册路由(如果 Task 14 未包含则在此注册)
-
Step 5:
cargo check+cargo test+ 提交
git add crates/erp-health/src/service/alert_service.rs crates/erp-health/src/handler/alert_handler.rs crates/erp-health/src/handler/alert_rule_handler.rs
git commit -m "feat(health): 告警 CRUD — 规则管理+告警确认+处置"
Chunk 6: SSE 扩展(Task 17)
Task 17: SSE Handler 扩展 + 前端监听
Files:
-
Modify:
crates/erp-message/src/handler/sse_handler.rs -
Modify:
apps/web/src/stores/message.ts -
Step 1: 改造 SSE Handler
将 subscribe_filtered("message.sent") 改为 subscribe() 全量订阅,在 stream 中按 event_type 分发:
-
message.sent→ SSE event:message(已有逻辑) -
alert.triggered→ SSE event:alert(检查 payload 中的 attending_doctor 匹配当前用户) -
device.readings.synced→ SSE event:vital_update(同上) -
Step 2: 前端 message store 扩展
在 connectSSE() 中添加 eventSource.addEventListener('alert', ...) 和 eventSource.addEventListener('vital_update', ...)。
- Step 3:
cargo check+pnpm build+ 提交
git add crates/erp-message/src/handler/sse_handler.rs apps/web/src/stores/message.ts
git commit -m "feat: SSE 推送扩展 — 告警+体征更新事件"
Chunk 7: 小程序 BLE 模块(Task 18-20)
Task 18: BLE 类型 + BLEManager
Files:
-
Create:
apps/miniprogram/src/services/ble/types.ts -
Create:
apps/miniprogram/src/services/ble/BLEManager.ts -
Step 1: 创建 types.ts
定义 NormalizedReading, DeviceType, DeviceAdapter, BLEDevice, BLEConnection, SyncResult 接口。
- Step 2: 创建 BLEManager.ts
实现设备注册、扫描、连接、断开、同步的主入口。使用 Taro BLE API。
- Step 3: 提交
git add apps/miniprogram/src/services/ble/
git commit -m "feat(miniprogram): BLE 类型定义 + 连接管理器"
Task 19: 小米手环 Adapter
Files:
-
Create:
apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts -
Step 1: 实现 Adapter
实现 DeviceAdapter 接口,支持 Mi Band 7/8 的心率数据读取。
首版可使用标准 Heart Rate Service UUID (0x180D) + Heart Rate Measurement Characteristic (0x2A37)。
- Step 2: 提交
git add apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts
git commit -m "feat(miniprogram): 小米手环 BLE 适配器 — 心率读取"
Task 20: 设备同步页面 + API
Files:
-
Create:
apps/miniprogram/src/services/device-sync.ts -
Create:
apps/miniprogram/src/pages/device-sync/index.tsx -
Create:
apps/miniprogram/src/pages/device-sync/index.config.ts -
Step 1: 创建 device-sync.ts API 层
封装 POST /health/patients/{id}/device-readings/batch 调用。
- Step 2: 创建设备同步页面
包含:设备扫描列表、连接状态、同步进度、同步结果展示。
-
Step 3: 在 app.config.ts 中注册页面
-
Step 4: 提交
git add apps/miniprogram/src/services/device-sync.ts apps/miniprogram/src/pages/device-sync/
git commit -m "feat(miniprogram): 设备同步页面 — 扫描+连接+数据上传"
Chunk 8: Web 前端 + 集成验证(Task 21-22)
Task 21: Web 端 API + 告警通知 UI
Files:
-
Create:
apps/web/src/api/health/deviceReadings.ts -
Create:
apps/web/src/api/health/alerts.ts -
Step 1: 创建 API 文件
deviceReadings.ts:queryReadings(), queryHourlyReadings()
alerts.ts:listAlerts(), acknowledgeAlert(), listAlertRules(), createAlertRule()
- Step 2: 提交
git add apps/web/src/api/health/deviceReadings.ts apps/web/src/api/health/alerts.ts
git commit -m "feat(web): 设备数据+告警 API 层"
Task 22: 端到端集成验证
- Step 1:
cargo check --workspace
Expected: 编译通过,0 错误
- Step 2:
cargo test --workspace
Expected: 所有测试通过
- Step 3: 启动后端
Run: cd crates/erp-server && cargo run
Expected: 服务启动,日志中看到新路由注册
- Step 4: Swagger 验证
访问 http://localhost:3000/api/docs/openapi.json,确认新端点出现在文档中
- Step 5: API 手动测试
用 Swagger UI 测试:
POST /health/patients/{id}/device-readings/batch— 提交测试数据GET /health/patients/{id}/device-readings— 查询原始数据GET /health/patients/{id}/device-readings/hourly— 查询降采样
- Step 6: 前端构建验证
Run: cd apps/web && pnpm build
Expected: 构建通过
- Step 7: 最终提交
git add -A
git commit -m "feat(health): 实时体征采集 Phase 1 — 完整链路验证通过"
git push
Phase 2 实施计划:告警引擎 + SSE 推送
前置条件: Phase 1 全部完成(Task 1-22),数据库表已创建,Entity/Service/Handler 已注册。
新建文件
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 命名模式):
#[error("告警规则不存在")]
AlertRuleNotFound,
#[error("告警记录不存在")]
AlertNotFound,
- Step 2: 更新 From for AppError 的 match
在 From<HealthError> for AppError 实现的 or-chain 中追加:
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: 提交
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)]):
// 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+ 提交
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: 创建告警引擎
// 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 在冷却期内的记录。存在则跳过。
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 是否超出阈值。
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 条记录,检查是否全部超出阈值(连续性)。
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
查询窗口内数据,计算首尾差值判断趋势。
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 事件。
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+ 提交
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
// 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+ 提交
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 的泛型签名模式:
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+ 提交
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() 注册告警路由
追加到路由链末尾:
// 告警路由
.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() 中新增:
// 订阅 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+ 提交
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 分发:
// 核心分发逻辑
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+ 提交
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() 方法中添加新事件监听器:
// 在 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+ 提交
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):
// 核心结构
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
// 规则管理页
// 列:规则名称、指标类型、条件类型、严重程度、启用状态(Switch)、冷却时间、操作
// 操作列:编辑(Modal)、启用/禁用(Switch)
// 新建按钮:Modal 表单(规则名称、指标类型 Select、条件类型 Select、参数 JSON 编辑器、严重程度 Select)
- Step 3: 在路由中注册新页面
在 apps/web/src/App.tsx 或对应的路由配置文件中注册这两个页面路由。
-
Step 4: 在侧边栏菜单中添加入口
-
Step 5:
pnpm build+ 提交
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: 创建默认告警规则
为每个租户初始化几条基础规则(仅在首次启动时创建):
// 在 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: 提交
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 测试:
POST /health/alert-rules— 创建规则GET /health/alert-rules— 查询规则列表POST /health/patients/{id}/device-readings/batch— 提交体征数据(触发告警评估)GET /health/alerts— 查看告警是否生成PUT /health/alerts/{id}/acknowledge— 确认告警
- Step 4: 前端验证
- 打开告警列表页,确认数据加载正常
- 打开规则管理页,创建/编辑规则
- 提交体征数据,观察 SSE 告警弹窗
-
Step 5:
pnpm build前端生产构建 -
Step 6: 最终提交 + 推送
git add -A
git commit -m "feat(health): Phase 2 完成 — 告警引擎+SSE推送+前端页面"
git push
Phase 3 实施计划(概要)
- 更多 BLE Adapter(华为/OPPO 手环)
- 降采样后台修正任务(每 6 小时)
- 数据清理任务(自动 DROP 超期分区)
- 周/月趋势对比看板