Files
hms/docs/superpowers/plans/2026-04-26-realtime-vital-signs-pipeline.md
iven 125d2479ea
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
docs: Phase 2 实施计划 — 告警引擎+SSE推送+前端页面 (Task 23-33)
新增 11 个 Task 覆盖:HealthError 变体、DTO、告警引擎核心
(三层规则评估+冷却期)、CRUD Service、Handler 路由注册、
事件订阅、SSE 多事件扩展、前端告警 SSE store、
告警列表页+规则管理页、种子数据、端到端验证。
2026-04-26 22:49:17 +08:00

63 KiB
Raw Blame History

实时体征采集与智能告警系统 — 实施计划

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 DesignWeb 前端)

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 中:

  1. 添加 mod m20260426_000073_create_device_readings;
  2. 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 + ValidationTask 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

alertsRelation 中添加 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-forgetoutbox 持久化在 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_readingsraw 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>

  1. load_active_rules() — 查询 alert_rules
  2. 遍历规则,检查冷却期(查询最近一条同规则同患者的 alert
  3. 按条件类型调用 evaluate_single() / evaluate_consecutive() / evaluate_trend()
  4. 匹配则 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.tsqueryReadings(), queryHourlyReadings() alerts.tslistAlerts(), 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 测试:

  1. POST /health/patients/{id}/device-readings/batch — 提交测试数据
  2. GET /health/patients/{id}/device-readings — 查询原始数据
  3. 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                   # 告警 + 规则 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 命名模式):

#[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.tsPhase 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.rson_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 测试:

  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: 最终提交 + 推送

git add -A
git commit -m "feat(health): Phase 2 完成 — 告警引擎+SSE推送+前端页面"
git push

Phase 3 实施计划(概要)

  1. 更多 BLE Adapter华为/OPPO 手环)
  2. 降采样后台修正任务(每 6 小时)
  3. 数据清理任务(自动 DROP 超期分区)
  4. 周/月趋势对比看板