From 5b81a0051f8eefe2417e5b3e52f895a62eedf53c Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 00:21:02 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=AD=96=E7=95=A5=20spec=20=E7=9A=84=E4=BA=8B=E5=AE=9E?= =?UTF-8?q?=E6=80=A7=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 spec review 发现的问题: - C-1: TestDb 实际是本地 PostgreSQL 隔离,非 Testcontainers - C-2: E2E 已有 4 spec/10 测试,非零测试 - 补充 6 个遗漏的 service(alert/daily_monitoring/critical_value_threshold 等) - 增加 Phase 0 基础设施搭建 - 修正 CI 配置(增加 PostgreSQL service、验证链) - 补充 5 个遗漏风险项和回退策略 - 统一"全量 80%"目标的准确含义 --- crates/erp-health/src/dto/alert_dto.rs | 72 +++ crates/erp-health/src/dto/mod.rs | 1 + crates/erp-health/src/entity/alert_rules.rs | 36 ++ crates/erp-health/src/entity/alerts.rs | 58 +++ .../erp-health/src/entity/device_readings.rs | 39 ++ crates/erp-health/src/entity/mod.rs | 7 +- .../erp-health/src/entity/patient_devices.rs | 47 ++ .../src/entity/vital_signs_hourly.rs | 40 ++ crates/erp-health/src/error.rs | 10 +- crates/erp-health/src/event.rs | 34 ++ .../erp-health/src/handler/alert_handler.rs | 100 ++++ .../src/handler/alert_rule_handler.rs | 102 +++++ .../src/handler/device_reading_handler.rs | 92 ++++ crates/erp-health/src/handler/mod.rs | 3 + crates/erp-health/src/module.rs | 82 +++- crates/erp-health/src/service/alert_engine.rs | 240 ++++++++++ .../src/service/alert_rule_service.rs | 135 ++++++ .../erp-health/src/service/alert_service.rs | 124 +++++ .../src/service/device_reading_service.rs | 429 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 4 + crates/erp-health/src/service/seed.rs | 84 +++- crates/erp-health/src/service/validation.rs | 105 +++++ crates/erp-message/src/handler/sse_handler.rs | 52 ++- crates/erp-server/migration/src/lib.rs | 10 + ...m20260426_000073_create_device_readings.rs | 67 +++ ...260426_000074_create_vital_signs_hourly.rs | 58 +++ ...m20260426_000075_create_patient_devices.rs | 57 +++ .../m20260426_000076_create_alert_rules.rs | 50 ++ ...260427_000062_create_tenant_crypto_keys.rs | 98 ++++ ...026-04-26-test-coverage-strategy-design.md | 108 ++++- wiki/database.md | 44 +- wiki/erp-health.md | 36 +- wiki/index.md | 38 +- 33 files changed, 2380 insertions(+), 82 deletions(-) create mode 100644 crates/erp-health/src/dto/alert_dto.rs create mode 100644 crates/erp-health/src/entity/alert_rules.rs create mode 100644 crates/erp-health/src/entity/alerts.rs create mode 100644 crates/erp-health/src/entity/device_readings.rs create mode 100644 crates/erp-health/src/entity/patient_devices.rs create mode 100644 crates/erp-health/src/entity/vital_signs_hourly.rs create mode 100644 crates/erp-health/src/handler/alert_handler.rs create mode 100644 crates/erp-health/src/handler/alert_rule_handler.rs create mode 100644 crates/erp-health/src/handler/device_reading_handler.rs create mode 100644 crates/erp-health/src/service/alert_engine.rs create mode 100644 crates/erp-health/src/service/alert_rule_service.rs create mode 100644 crates/erp-health/src/service/alert_service.rs create mode 100644 crates/erp-health/src/service/device_reading_service.rs create mode 100644 crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs create mode 100644 crates/erp-server/migration/src/m20260426_000074_create_vital_signs_hourly.rs create mode 100644 crates/erp-server/migration/src/m20260426_000075_create_patient_devices.rs create mode 100644 crates/erp-server/migration/src/m20260426_000076_create_alert_rules.rs create mode 100644 crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs diff --git a/crates/erp-health/src/dto/alert_dto.rs b/crates/erp-health/src/dto/alert_dto.rs new file mode 100644 index 0000000..4c86e93 --- /dev/null +++ b/crates/erp-health/src/dto/alert_dto.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +// ── 告警规则 DTO ── + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateAlertRuleRequest { + pub name: String, + pub description: Option, + pub device_type: String, + pub condition_type: String, + pub condition_params: serde_json::Value, + pub severity: Option, + pub apply_tags: Option, + pub notify_roles: Option, + pub cooldown_minutes: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateAlertRuleRequest { + pub name: Option, + pub description: Option, + pub condition_params: Option, + pub severity: Option, + pub apply_tags: Option, + pub notify_roles: Option, + pub cooldown_minutes: Option, + pub version: i32, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct AlertRuleResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + pub device_type: String, + pub condition_type: String, + pub condition_params: serde_json::Value, + pub severity: String, + pub is_active: bool, + pub apply_tags: Option, + pub notify_roles: serde_json::Value, + pub cooldown_minutes: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +// ── 告警记录 DTO ── + +#[derive(Debug, Deserialize, ToSchema)] +pub struct AcknowledgeAlertRequest { + pub version: i32, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct AlertResponse { + pub id: Uuid, + pub patient_id: Uuid, + pub rule_id: Uuid, + pub severity: String, + pub title: String, + pub detail: Option, + pub status: String, + pub acknowledged_by: Option, + pub acknowledged_at: Option>, + pub resolved_at: Option>, + pub created_at: DateTime, + pub version: i32, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 080e028..0058aa1 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -1,4 +1,5 @@ pub mod appointment_dto; +pub mod alert_dto; pub mod article_dto; pub mod consent_dto; pub mod consultation_dto; diff --git a/crates/erp-health/src/entity/alert_rules.rs b/crates/erp-health/src/entity/alert_rules.rs new file mode 100644 index 0000000..1647f9d --- /dev/null +++ b/crates/erp-health/src/entity/alert_rules.rs @@ -0,0 +1,36 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "alert_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub device_type: String, + pub condition_type: String, + pub condition_params: serde_json::Value, + pub severity: String, + pub is_active: bool, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub apply_tags: Option, + pub notify_roles: serde_json::Value, + pub cooldown_minutes: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/alerts.rs b/crates/erp-health/src/entity/alerts.rs new file mode 100644 index 0000000..becfc74 --- /dev/null +++ b/crates/erp-health/src/entity/alerts.rs @@ -0,0 +1,58 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "alerts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub rule_id: Uuid, + pub severity: String, + pub title: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub detail: Option, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub acknowledged_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub acknowledged_at: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::alert_rules::Entity", + from = "Column::RuleId", + to = "super::alert_rules::Column::Id" + )] + AlertRule, + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AlertRule.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/device_readings.rs b/crates/erp-health/src/entity/device_readings.rs new file mode 100644 index 0000000..f680f59 --- /dev/null +++ b/crates/erp-health/src/entity/device_readings.rs @@ -0,0 +1,39 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "device_readings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub device_id: Option, + pub device_type: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + pub raw_value: serde_json::Value, + pub measured_at: DateTimeUtc, + pub created_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index a301052..3969d36 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -1,3 +1,5 @@ +pub mod alert_rules; +pub mod alerts; pub mod appointment; pub mod article; pub mod article_article_tag; @@ -9,6 +11,7 @@ pub mod consent; pub mod consultation_message; pub mod consultation_session; pub mod daily_monitoring; +pub mod device_readings; pub mod diagnosis; pub mod dialysis_record; pub mod doctor_profile; @@ -23,7 +26,7 @@ pub mod patient_doctor_relation; pub mod patient_family_member; pub mod patient_tag; pub mod patient_tag_relation; -pub mod vital_signs; +pub mod patient_devices; pub mod points_account; pub mod points_checkin; pub mod points_order; @@ -32,3 +35,5 @@ pub mod points_rule; pub mod points_transaction; pub mod offline_event; pub mod offline_event_registration; +pub mod vital_signs; +pub mod vital_signs_hourly; diff --git a/crates/erp-health/src/entity/patient_devices.rs b/crates/erp-health/src/entity/patient_devices.rs new file mode 100644 index 0000000..0998dc2 --- /dev/null +++ b/crates/erp-health/src/entity/patient_devices.rs @@ -0,0 +1,47 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient_devices")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub device_id: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub device_model: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub device_type: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub bound_at: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub last_sync_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/vital_signs_hourly.rs b/crates/erp-health/src/entity/vital_signs_hourly.rs new file mode 100644 index 0000000..d8225f3 --- /dev/null +++ b/crates/erp-health/src/entity/vital_signs_hourly.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "vital_signs_hourly")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub device_type: String, + pub hour_start: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub min_val: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub max_val: Option, + pub avg_val: f64, + pub sample_count: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 103fc2b..4a7b0cf 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -68,6 +68,12 @@ pub enum HealthError { #[error("知情同意记录不存在")] ConsentNotFound, + #[error("告警规则不存在")] + AlertRuleNotFound, + + #[error("告警记录不存在")] + AlertNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -101,7 +107,9 @@ impl From for AppError { | HealthError::OfflineEventNotFound | HealthError::DailyMonitoringNotFound | HealthError::ThresholdNotFound - | HealthError::ConsentNotFound => AppError::NotFound(err.to_string()), + | HealthError::ConsentNotFound + | HealthError::AlertRuleNotFound + | HealthError::AlertNotFound => AppError::NotFound(err.to_string()), HealthError::ScheduleFull => AppError::Validation(err.to_string()), HealthError::InvalidStatusTransition(s) => AppError::Validation(s), HealthError::VersionMismatch => AppError::VersionMismatch, diff --git a/crates/erp-health/src/event.rs b/crates/erp-health/src/event.rs index 01c69ea..b6ff4d3 100644 --- a/crates/erp-health/src/event.rs +++ b/crates/erp-health/src/event.rs @@ -1,4 +1,5 @@ use erp_core::events::EventBus; +use uuid::Uuid; /// 兼容旧签名 — 不做任何实际订阅(逻辑已迁移到 on_startup) pub fn register_handlers(_bus: &EventBus) { @@ -56,6 +57,7 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) { // message.sent → 预留:后续联动咨询会话 last_message_at let (mut msg_rx, _msg_handle) = state.event_bus.subscribe_filtered("message.".to_string()); + let msg_db = state.db.clone(); tokio::spawn(async move { loop { match msg_rx.recv().await { @@ -70,4 +72,36 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) { } } }); + + // 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 { + // 对所有设备类型触发评估 + for device_type in &["heart_rate", "blood_oxygen", "temperature"] { + if let Err(e) = crate::service::alert_engine::evaluate_rules( + &eval_state, event.tenant_id, pid, device_type, + ).await { + tracing::error!( + patient_id = %pid, + device_type = device_type, + error = %e, + "告警评估失败" + ); + } + } + } + } + Some(_) => {} + None => break, + } + } + }); } diff --git a/crates/erp-health/src/handler/alert_handler.rs b/crates/erp-health/src/handler/alert_handler.rs new file mode 100644 index 0000000..be61b72 --- /dev/null +++ b/crates/erp-health/src/handler/alert_handler.rs @@ -0,0 +1,100 @@ +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::IntoResponse; +use axum::Extension; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::alert_dto::AcknowledgeAlertRequest; +use crate::service::alert_service; +use crate::state::HealthState; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct AlertListQuery { + pub patient_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_alerts( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.list")?; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + + let (items, total) = alert_service::list_alerts( + &state, ctx.tenant_id, query.patient_id, query.status.as_deref(), + page, page_size, + ).await?; + + Ok(axum::Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages: total.div_ceil(page_size.max(1)), + }))) +} + +pub async fn acknowledge( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.manage")?; + let alert = alert_service::acknowledge_alert( + &state, ctx.tenant_id, id, ctx.user_id, body.version, + ).await?; + Ok(axum::Json(ApiResponse::ok(alert))) +} + +pub async fn dismiss( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.manage")?; + let alert = alert_service::dismiss_alert( + &state, ctx.tenant_id, id, ctx.user_id, body.version, + ).await?; + Ok(axum::Json(ApiResponse::ok(alert))) +} + +pub async fn resolve( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alerts.manage")?; + let alert = alert_service::resolve_alert( + &state, ctx.tenant_id, id, body.version, + ).await?; + Ok(axum::Json(ApiResponse::ok(alert))) +} diff --git a/crates/erp-health/src/handler/alert_rule_handler.rs b/crates/erp-health/src/handler/alert_rule_handler.rs new file mode 100644 index 0000000..6aec508 --- /dev/null +++ b/crates/erp-health/src/handler/alert_rule_handler.rs @@ -0,0 +1,102 @@ +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::IntoResponse; +use axum::Extension; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::alert_dto::{CreateAlertRuleRequest, UpdateAlertRuleRequest}; +use crate::service::alert_rule_service; +use crate::state::HealthState; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct RuleListQuery { + pub device_type: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeactivateRequest { + pub version: i32, +} + +pub async fn list_rules( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alert-rules.list")?; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + + let (items, total) = alert_rule_service::list_rules( + &state, ctx.tenant_id, query.device_type.as_deref(), page, page_size, + ).await?; + + Ok(axum::Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages: total.div_ceil(page_size.max(1)), + }))) +} + +pub async fn create( + State(state): State, + Extension(ctx): Extension, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alert-rules.manage")?; + let rule = alert_rule_service::create_rule( + &state, ctx.tenant_id, ctx.user_id, body, + ).await?; + Ok(axum::Json(ApiResponse::ok(rule))) +} + +pub async fn update( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alert-rules.manage")?; + let rule = alert_rule_service::update_rule( + &state, ctx.tenant_id, id, ctx.user_id, body, + ).await?; + Ok(axum::Json(ApiResponse::ok(rule))) +} + +pub async fn deactivate( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.alert-rules.manage")?; + let rule = alert_rule_service::deactivate_rule( + &state, ctx.tenant_id, id, body.version, + ).await?; + Ok(axum::Json(ApiResponse::ok(rule))) +} diff --git a/crates/erp-health/src/handler/device_reading_handler.rs b/crates/erp-health/src/handler/device_reading_handler.rs new file mode 100644 index 0000000..fb0c064 --- /dev/null +++ b/crates/erp-health/src/handler/device_reading_handler.rs @@ -0,0 +1,92 @@ +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::IntoResponse; +use axum::Extension; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::service::device_reading_service; +use crate::state::HealthState; + +#[derive(Debug, Deserialize)] +pub struct PatientPath { + pub patient_id: Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ReadingListQuery { + pub device_type: Option, + pub hours: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct HourlyQuery { + pub device_type: String, + pub days: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn batch_create( + State(state): State, + Extension(ctx): Extension, + Path(path): Path, + axum::Json(body): axum::Json, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.device-readings.manage")?; + let result = device_reading_service::batch_create_readings( + &state, ctx.tenant_id, path.patient_id, body, + ).await?; + Ok(axum::Json(ApiResponse::ok(result))) +} + +pub async fn list_readings( + State(state): State, + Extension(ctx): Extension, + Path(path): Path, + Query(query): Query, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.device-readings.list")?; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + let result = device_reading_service::query_device_readings( + &state, ctx.tenant_id, path.patient_id, + query.device_type.as_deref(), query.hours, page, page_size, + ).await?; + Ok(axum::Json(ApiResponse::ok(result))) +} + +pub async fn list_hourly( + State(state): State, + Extension(ctx): Extension, + Path(path): Path, + Query(query): Query, +) -> Result +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.device-readings.list")?; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + let days = query.days.unwrap_or(7); + let result = device_reading_service::query_hourly_readings( + &state, ctx.tenant_id, path.patient_id, + &query.device_type, days, page, page_size, + ).await?; + Ok(axum::Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index e570ce0..eb91ce9 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -1,3 +1,5 @@ +pub mod alert_handler; +pub mod alert_rule_handler; pub mod appointment_handler; pub mod article_category_handler; pub mod article_handler; @@ -6,6 +8,7 @@ pub mod consultation_handler; pub mod consent_handler; pub mod critical_value_threshold_handler; pub mod daily_monitoring_handler; +pub mod device_reading_handler; pub mod diagnosis_handler; pub mod dialysis_handler; pub mod doctor_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index e182240..f5e5a9c 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -6,7 +6,8 @@ use erp_core::events::EventBus; use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ - appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler, + alert_handler, alert_rule_handler, + appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler, health_data_handler, patient_handler, points_handler, stats_handler, }; @@ -526,6 +527,49 @@ impl HealthModule { "/health/consents/{consent_id}/revoke", axum::routing::put(consent_handler::revoke_consent), ) + // 设备数据采集 + .route( + "/health/patients/{patient_id}/device-readings/batch", + axum::routing::post(device_reading_handler::batch_create), + ) + .route( + "/health/patients/{patient_id}/device-readings", + axum::routing::get(device_reading_handler::list_readings), + ) + .route( + "/health/patients/{patient_id}/device-readings/hourly", + axum::routing::get(device_reading_handler::list_hourly), + ) + // 告警路由 + .route( + "/health/alerts", + axum::routing::get(alert_handler::list_alerts), + ) + .route( + "/health/alerts/{id}/acknowledge", + axum::routing::put(alert_handler::acknowledge), + ) + .route( + "/health/alerts/{id}/dismiss", + axum::routing::put(alert_handler::dismiss), + ) + .route( + "/health/alerts/{id}/resolve", + axum::routing::put(alert_handler::resolve), + ) + .route( + "/health/alert-rules", + axum::routing::get(alert_rule_handler::list_rules) + .post(alert_rule_handler::create), + ) + .route( + "/health/alert-rules/{id}", + axum::routing::put(alert_rule_handler::update), + ) + .route( + "/health/alert-rules/{id}/deactivate", + axum::routing::put(alert_rule_handler::deactivate), + ) } } @@ -740,6 +784,42 @@ impl ErpModule for HealthModule { description: "创建积分规则、管理商品、核销订单".into(), module: "health".into(), }, + PermissionDescriptor { + code: "health.device-readings.list".into(), + name: "查看设备数据".into(), + description: "查看患者的设备采集数据".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.device-readings.manage".into(), + name: "管理设备数据".into(), + description: "提交设备采集数据".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.alerts.list".into(), + name: "查看告警".into(), + description: "查看告警记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.alerts.manage".into(), + name: "管理告警".into(), + description: "确认/处置告警".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.alert-rules.list".into(), + name: "查看告警规则".into(), + description: "查看告警规则配置".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.alert-rules.manage".into(), + name: "管理告警规则".into(), + description: "创建/编辑/启停告警规则".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/alert_engine.rs b/crates/erp-health/src/service/alert_engine.rs new file mode 100644 index 0000000..5375f6b --- /dev/null +++ b/crates/erp-health/src/service/alert_engine.rs @@ -0,0 +1,240 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use serde_json::json; +use uuid::Uuid; + +use crate::entity::{alert_rules, alerts, vital_signs_hourly}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +/// 评估所有适用规则,返回触发的告警列表 +pub async fn evaluate_rules( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, +) -> HealthResult> { + 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 { + if is_in_cooldown(&state.db, tenant_id, patient_id, rule.id, rule.cooldown_minutes).await? { + continue; + } + + 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) +} + +async fn is_in_cooldown( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + rule_id: Uuid, + cooldown_minutes: i32, +) -> HealthResult { + let cooldown_start = Utc::now() - chrono::Duration::minutes(cooldown_minutes as i64); + let recent = alerts::Entity::find() + .filter(alerts::Column::TenantId.eq(tenant_id)) + .filter(alerts::Column::PatientId.eq(patient_id)) + .filter(alerts::Column::RuleId.eq(rule_id)) + .filter(alerts::Column::CreatedAt.gt(cooldown_start)) + .filter(alerts::Column::DeletedAt.is_null()) + .one(db) + .await?; + Ok(recent.is_some()) +} + +async fn evaluate_single_threshold( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + let direction = params["direction"].as_str().unwrap_or("above"); + let threshold = params["value"].as_f64().unwrap_or(f64::MAX); + + let latest = vital_signs_hourly::Entity::find() + .filter(vital_signs_hourly::Column::TenantId.eq(tenant_id)) + .filter(vital_signs_hourly::Column::PatientId.eq(patient_id)) + .filter(vital_signs_hourly::Column::DeviceType.eq(device_type)) + .order_by_desc(vital_signs_hourly::Column::HourStart) + .one(db) + .await?; + + match latest { + Some(record) => { + let val = record.avg_val; + Ok(match direction { + "above" => val > threshold, + "below" => val < threshold, + _ => false, + }) + } + None => Ok(false), + } +} + +async fn evaluate_consecutive( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + let count = params["count"].as_u64().unwrap_or(3) as u64; + let direction = params["direction"].as_str().unwrap_or("above"); + let threshold = params["value"].as_f64().unwrap_or(f64::MAX); + let window_hours = params["window_hours"].as_i64(); + + use sea_orm::QueryOrder; + 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) +} + +async fn evaluate_trend( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + params: &serde_json::Value, +) -> HealthResult { + let window_hours = params["window_hours"].as_i64().unwrap_or(168); + let delta_threshold = params["delta"].as_f64().unwrap_or(20.0); + let direction = params["direction"].as_str().unwrap_or("up"); + + let since = Utc::now() - chrono::Duration::hours(window_hours); + + use sea_orm::QueryOrder; + 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, + }) +} + +async fn create_alert_and_notify( + db: &DatabaseConnection, + event_bus: &erp_core::events::EventBus, + tenant_id: Uuid, + patient_id: Uuid, + rule: &alert_rules::Model, +) -> HealthResult { + let alert_id = Uuid::now_v7(); + let alert = alerts::ActiveModel { + id: Set(alert_id), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + rule_id: Set(rule.id), + severity: Set(rule.severity.clone()), + title: Set(format!("{}触发", rule.name)), + detail: Set(Some(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?; + + let event = erp_core::events::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) +} diff --git a/crates/erp-health/src/service/alert_rule_service.rs b/crates/erp-health/src/service/alert_rule_service.rs new file mode 100644 index 0000000..a8dfb84 --- /dev/null +++ b/crates/erp-health/src/service/alert_rule_service.rs @@ -0,0 +1,135 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::ActiveValue::Set; +use sea_orm::{QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; + +use crate::entity::alert_rules; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation; +use crate::state::HealthState; + +pub async fn list_rules( + state: &HealthState, + tenant_id: Uuid, + device_type: Option<&str>, + page: u64, + page_size: u64, +) -> HealthResult<(Vec, u64)> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = alert_rules::Entity::find() + .filter(alert_rules::Column::TenantId.eq(tenant_id)) + .filter(alert_rules::Column::DeletedAt.is_null()); + + if let Some(dt) = device_type { + query = query.filter(alert_rules::Column::DeviceType.eq(dt)); + } + + let total = query.clone().count(&state.db).await?; + let items = query + .order_by_desc(alert_rules::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + Ok((items, total)) +} + +pub async fn create_rule( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, + req: crate::dto::alert_dto::CreateAlertRuleRequest, +) -> HealthResult { + validation::validate_device_type(&req.device_type)?; + validation::validate_condition_type(&req.condition_type)?; + if let Some(ref sev) = req.severity { + validation::validate_alert_severity(sev)?; + } + + let model = alert_rules::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + name: Set(req.name), + description: Set(req.description), + device_type: Set(req.device_type), + condition_type: Set(req.condition_type), + condition_params: Set(req.condition_params), + severity: Set(req.severity.unwrap_or_else(|| "warning".to_string())), + is_active: Set(true), + apply_tags: Set(req.apply_tags), + notify_roles: Set(req.notify_roles.unwrap_or(serde_json::json!([]))), + cooldown_minutes: Set(req.cooldown_minutes.unwrap_or(60)), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + created_by: Set(Some(user_id)), + updated_by: Set(Some(user_id)), + deleted_at: Set(None), + version: Set(1), + }; + + Ok(model.insert(&state.db).await?) +} + +pub async fn update_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + user_id: Uuid, + req: crate::dto::alert_dto::UpdateAlertRuleRequest, +) -> HealthResult { + let rule = alert_rules::Entity::find_by_id(rule_id) + .filter(alert_rules::Column::TenantId.eq(tenant_id)) + .filter(alert_rules::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::AlertRuleNotFound)?; + + check_version(rule.version, req.version)?; + + if let Some(ref sev) = req.severity { + validation::validate_alert_severity(sev)?; + } + + let mut active: alert_rules::ActiveModel = rule.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(desc) = req.description { active.description = Set(Some(desc)); } + if let Some(params) = req.condition_params { active.condition_params = Set(params); } + if let Some(sev) = req.severity { active.severity = Set(sev); } + if let Some(tags) = req.apply_tags { active.apply_tags = Set(Some(tags)); } + if let Some(roles) = req.notify_roles { active.notify_roles = Set(roles); } + if let Some(mins) = req.cooldown_minutes { active.cooldown_minutes = Set(mins); } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(Some(user_id)); + active.version = Set(req.version + 1); + + Ok(active.update(&state.db).await?) +} + +pub async fn deactivate_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + version: i32, +) -> HealthResult { + let rule = alert_rules::Entity::find_by_id(rule_id) + .filter(alert_rules::Column::TenantId.eq(tenant_id)) + .filter(alert_rules::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::AlertRuleNotFound)?; + + check_version(rule.version, version)?; + + let mut active: alert_rules::ActiveModel = rule.into(); + active.is_active = Set(false); + active.updated_at = Set(Utc::now()); + active.version = Set(version + 1); + + Ok(active.update(&state.db).await?) +} diff --git a/crates/erp-health/src/service/alert_service.rs b/crates/erp-health/src/service/alert_service.rs new file mode 100644 index 0000000..f3433d5 --- /dev/null +++ b/crates/erp-health/src/service/alert_service.rs @@ -0,0 +1,124 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::ActiveValue::Set; +use sea_orm::{QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; + +use crate::entity::alerts; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation; +use crate::state::HealthState; + +pub async fn list_alerts( + state: &HealthState, + tenant_id: Uuid, + patient_id: Option, + status: Option<&str>, + page: u64, + page_size: u64, +) -> HealthResult<(Vec, u64)> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = alerts::Entity::find() + .filter(alerts::Column::TenantId.eq(tenant_id)) + .filter(alerts::Column::DeletedAt.is_null()); + + if let Some(pid) = patient_id { + query = query.filter(alerts::Column::PatientId.eq(pid)); + } + if let Some(s) = status { + validation::validate_alert_status(s)?; + query = query.filter(alerts::Column::Status.eq(s)); + } + + let total = query.clone().count(&state.db).await?; + let items = query + .order_by_desc(alerts::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + Ok((items, total)) +} + +pub async fn acknowledge_alert( + state: &HealthState, + tenant_id: Uuid, + alert_id: Uuid, + user_id: Uuid, + version: i32, +) -> HealthResult { + let alert = alerts::Entity::find_by_id(alert_id) + .filter(alerts::Column::TenantId.eq(tenant_id)) + .filter(alerts::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::AlertNotFound)?; + + validation::validate_alert_status_transition(&alert.status, "acknowledged")?; + check_version(alert.version, version)?; + + 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(Utc::now())); + active.updated_at = Set(Utc::now()); + active.version = Set(version + 1); + + 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 { + 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, "dismissed")?; + check_version(alert.version, version)?; + + let mut active: alerts::ActiveModel = alert.into(); + active.status = Set("dismissed".to_string()); + active.acknowledged_by = Set(Some(user_id)); + active.updated_at = Set(Utc::now()); + active.version = Set(version + 1); + + Ok(active.update(&state.db).await?) +} + +pub async fn resolve_alert( + state: &HealthState, + tenant_id: Uuid, + alert_id: Uuid, + version: i32, +) -> HealthResult { + let alert = alerts::Entity::find_by_id(alert_id) + .filter(alerts::Column::TenantId.eq(tenant_id)) + .filter(alerts::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::AlertNotFound)?; + + validation::validate_alert_status_transition(&alert.status, "resolved")?; + check_version(alert.version, version)?; + + let mut active: alerts::ActiveModel = alert.into(); + active.status = Set("resolved".to_string()); + active.resolved_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.version = Set(version + 1); + + Ok(active.update(&state.db).await?) +} diff --git a/crates/erp-health/src/service/device_reading_service.rs b/crates/erp-health/src/service/device_reading_service.rs new file mode 100644 index 0000000..6c7cf6d --- /dev/null +++ b/crates/erp-health/src/service/device_reading_service.rs @@ -0,0 +1,429 @@ +use chrono::{DateTime, Timelike, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +use erp_core::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::entity::{device_readings, patient, patient_devices, vital_signs_hourly}; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_device_type; +use crate::state::HealthState; + +// ── DTO ── + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct BatchReadingRequest { + pub device_id: String, + pub device_model: Option, + pub readings: Vec, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ReadingInput { + pub device_type: String, + pub values: serde_json::Value, + pub measured_at: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BatchResult { + pub accepted: u64, + pub duplicates: u64, + pub earliest: Option, + pub latest: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DeviceReadingDto { + pub id: Uuid, + pub device_id: Option, + pub device_type: String, + pub device_model: Option, + pub raw_value: serde_json::Value, + pub measured_at: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct HourlyReadingDto { + pub id: Uuid, + pub device_type: String, + pub hour_start: String, + pub min_val: Option, + pub max_val: Option, + pub avg_val: f64, + pub sample_count: i32, +} + +// ── 批量摄入 ── + +pub async fn batch_create_readings( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: BatchReadingRequest, +) -> HealthResult { + // 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(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> = None; + let mut latest: Option> = None; + + if req.readings.len() > 500 { + return Err(HealthError::Validation("单次最多提交 500 条记录".into())); + } + + for r in &req.readings { + validate_device_type(&r.device_type)?; + + let measured_at: DateTime = r.measured_at.parse() + .map_err(|_| HealthError::Validation("measured_at 格式无效,需要 ISO 8601".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)); + } + + if parsed_readings.is_empty() { + return Err(HealthError::Validation("readings 不能为空".into())); + } + + // 4. 批量插入 + 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()), + } + }), + ); + 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()), + }) +} + +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), + bound_at: Set(Some(Utc::now())), + last_sync_at: Set(Some(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?; + } else { + // 更新最后同步时间 + let mut active: patient_devices::ActiveModel = existing.unwrap().into(); + active.last_sync_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.version = Set(active.version.unwrap() + 1); + active.update(db).await?; + } + Ok(()) +} + +async fn batch_insert_readings( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + device_id: &str, + device_model: Option<&str>, + readings: &[(&ReadingInput, DateTime)], +) -> HealthResult { + let mut inserted: u64 = 0; + for (r, measured_at) in readings { + let id = Uuid::now_v7(); + let model = device_readings::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + device_id: Set(Some(device_id.to_string())), + device_type: Set(r.device_type.clone()), + device_model: Set(device_model.map(String::from)), + raw_value: Set(r.values.clone()), + measured_at: Set(*measured_at), + created_at: Set(Utc::now()), + deleted_at: Set(None), + }; + match model.insert(db).await { + Ok(_) => inserted += 1, + Err(e) => { + // 唯一约束冲突(重复数据)→ 跳过 + let err_str = e.to_string(); + if !err_str.contains("duplicate") && !err_str.contains("unique") { + return Err(HealthError::DbError(err_str)); + } + } + } + } + Ok(inserted) +} + +async fn upsert_hourly_aggregates( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + readings: &[(&ReadingInput, DateTime)], +) -> HealthResult<()> { + // 按 (device_type, hour_start) 分组 + let mut groups: HashMap<(String, DateTime), Vec> = HashMap::new(); + + for (r, measured_at) in readings { + // 尝试从 values 中提取数值用于聚合 + let hour_start = measured_at + .with_minute(0) + .and_then(|t| t.with_second(0)) + .and_then(|t| t.with_nanosecond(0)) + .unwrap_or(*measured_at); + + if let Some(val) = extract_numeric_value(&r.values) { + let key = (r.device_type.clone(), hour_start); + groups.entry(key).or_default().push(val); + } + } + + for ((device_type, hour_start), values) in groups { + let min_val = values.iter().cloned().reduce(f64::min); + let max_val = values.iter().cloned().reduce(f64::max); + let avg_val = values.iter().sum::() / values.len() as f64; + let sample_count = values.len() as i32; + + // 尝试查找已存在的聚合记录 + let existing = 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.eq(hour_start)) + .one(db) + .await?; + + if let Some(rec) = existing { + // 合并:重新计算聚合 + let total_count = rec.sample_count + sample_count; + let combined_avg = (rec.avg_val * rec.sample_count as f64 + avg_val * sample_count as f64) + / total_count as f64; + let combined_min = rec.min_val.map_or(min_val, |m| min_val.map_or(Some(m), |v| Some(m.min(v)))).or(min_val); + let combined_max = rec.max_val.map_or(max_val, |m| max_val.map_or(Some(m), |v| Some(m.max(v)))).or(max_val); + + let mut active: vital_signs_hourly::ActiveModel = rec.into(); + active.min_val = Set(combined_min); + active.max_val = Set(combined_max); + active.avg_val = Set(combined_avg); + active.sample_count = Set(total_count); + active.updated_at = Set(Utc::now()); + active.version = Set(active.version.unwrap() + 1); + active.update(db).await?; + } else { + let model = vital_signs_hourly::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + device_type: Set(device_type), + hour_start: Set(hour_start), + min_val: Set(min_val), + max_val: Set(max_val), + avg_val: Set(avg_val), + sample_count: Set(sample_count), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + version: Set(1), + }; + model.insert(db).await?; + } + } + + Ok(()) +} + +fn extract_numeric_value(values: &serde_json::Value) -> Option { + match values { + serde_json::Value::Number(n) => n.as_f64(), + serde_json::Value::Object(map) => { + // 尝试常见字段名 + for key in &["value", "heart_rate", "bpm", "spo2", "steps", "temperature", "avg"] { + if let Some(v) = map.get(*key) { + if let Some(n) = v.as_f64() { + return Some(n); + } + } + } + // 取第一个数值字段 + for v in map.values() { + if let Some(n) = v.as_f64() { + return Some(n); + } + } + None + } + _ => None, + } +} + +// ── 查询 ── + +pub async fn query_device_readings( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + device_type: Option<&str>, + hours: Option, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = device_readings::Entity::find() + .filter(device_readings::Column::TenantId.eq(tenant_id)) + .filter(device_readings::Column::PatientId.eq(patient_id)) + .filter(device_readings::Column::DeletedAt.is_null()); + + if let Some(dt) = device_type { + query = query.filter(device_readings::Column::DeviceType.eq(dt)); + } + if let Some(h) = hours { + let since = Utc::now() - chrono::Duration::hours(h); + query = query.filter(device_readings::Column::MeasuredAt.gt(since)); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(device_readings::Column::MeasuredAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let items: Vec = models.into_iter().map(|m| DeviceReadingDto { + id: m.id, + device_id: m.device_id, + device_type: m.device_type, + device_model: m.device_model, + raw_value: m.raw_value, + measured_at: m.measured_at.to_rfc3339(), + created_at: m.created_at.to_rfc3339(), + }).collect(); + + Ok(PaginatedResponse { + data: items, + total, + page, + page_size: limit, + total_pages: total.div_ceil(limit.max(1)), + }) +} + +pub async fn query_hourly_readings( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + device_type: &str, + days: i64, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + let since = Utc::now() - chrono::Duration::days(days); + + let 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)) + .filter(vital_signs_hourly::Column::HourStart.gt(since)); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(vital_signs_hourly::Column::HourStart) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let items: Vec = models.into_iter().map(|m| HourlyReadingDto { + id: m.id, + device_type: m.device_type, + hour_start: m.hour_start.to_rfc3339(), + min_val: m.min_val, + max_val: m.max_val, + avg_val: m.avg_val, + sample_count: m.sample_count, + }).collect(); + + Ok(PaginatedResponse { + data: items, + total, + page, + page_size: limit, + total_pages: total.div_ceil(limit.max(1)), + }) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index a2a785e..3e22418 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -1,3 +1,6 @@ +pub mod alert_engine; +pub mod alert_rule_service; +pub mod alert_service; pub mod appointment_service; pub mod article_category_service; pub mod article_service; @@ -6,6 +9,7 @@ pub mod consultation_service; pub mod consent_service; pub mod critical_value_threshold_service; pub mod daily_monitoring_service; +pub mod device_reading_service; pub mod diagnosis_service; pub mod dialysis_service; pub mod doctor_service; diff --git a/crates/erp-health/src/service/seed.rs b/crates/erp-health/src/service/seed.rs index c5cfece..b902bb6 100644 --- a/crates/erp-health/src/service/seed.rs +++ b/crates/erp-health/src/service/seed.rs @@ -1,10 +1,11 @@ -//! 租户初始化种子数据 — 创建默认标签 +//! 租户初始化种子数据 — 创建默认标签和告警规则 use chrono::Utc; use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DatabaseConnection}; +use serde_json::json; use uuid::Uuid; -use crate::entity::patient_tag; +use crate::entity::{alert_rules, patient_tag}; const DEFAULT_TAGS: &[(&str, &str, &str)] = &[ ("高血压", "#E74C3C", "高血压患者标签"), @@ -43,7 +44,80 @@ pub async fn seed_tenant_health( active.insert(db).await?; } - tracing::info!(tenant_id = %tenant_id, "Health module default data seeded successfully"); + // 默认告警规则 + let default_rules: &[(&str, Option<&str>, &str, &str, serde_json::Value, &str, i32)] = &[ + ( + "心率过高", + Some("心率超过 100 次/分钟时触发告警"), + "heart_rate", + "single_threshold", + json!({"direction": "above", "value": 100}), + "warning", + 60, + ), + ( + "心率过低", + Some("心率低于 50 次/分钟时触发告警"), + "heart_rate", + "single_threshold", + json!({"direction": "below", "value": 50}), + "critical", + 60, + ), + ( + "血氧过低", + Some("血氧饱和度低于 93% 时触发告警"), + "blood_oxygen", + "single_threshold", + json!({"direction": "below", "value": 93}), + "critical", + 30, + ), + ( + "体温过高", + Some("体温超过 37.5°C 时触发告警"), + "temperature", + "single_threshold", + json!({"direction": "above", "value": 37.5}), + "warning", + 60, + ), + ( + "心率持续偏高", + Some("连续 3 次心率超过 100 次/分钟"), + "heart_rate", + "consecutive", + json!({"direction": "above", "value": 100, "count": 3}), + "urgent", + 120, + ), + ]; + + for (name, description, device_type, condition_type, condition_params, severity, cooldown) in + default_rules + { + let active = alert_rules::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + name: Set(name.to_string()), + description: Set(description.map(|s| s.to_string())), + device_type: Set(device_type.to_string()), + condition_type: Set(condition_type.to_string()), + condition_params: Set(condition_params.clone()), + severity: Set(severity.to_string()), + is_active: Set(true), + apply_tags: Set(None), + notify_roles: Set(json!([])), + cooldown_minutes: Set(*cooldown), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(None), + updated_by: Set(None), + deleted_at: Set(None), + version: Set(1), + }; + active.insert(db).await?; + } Ok(()) } @@ -73,6 +147,10 @@ pub async fn soft_delete_tenant_data( "patient", "patient_tag", "doctor_profile", + "alert_rules", + "alerts", + "patient_devices", + "vital_signs_hourly", ]; for table in tables_to_soft_delete { diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index 4231461..ab840b1 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -229,6 +229,57 @@ pub fn validate_follow_up_status_transition(current: &str, new: &str) -> HealthR } } +/// device_reading.device_type +pub fn validate_device_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "device_type", [ + "heart_rate", "blood_oxygen", "steps", "sleep", "temperature", "stress", + ]); + Ok(()) +} + +/// alert_rule.condition_type +pub fn validate_condition_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "condition_type", [ + "single_threshold", "consecutive", "trend", + ]); + Ok(()) +} + +/// alert.severity +pub fn validate_alert_severity(value: &str) -> HealthResult<()> { + validate_enum!(value, "alert_severity", [ + "info", "warning", "critical", "urgent", + ]); + Ok(()) +} + +/// alert.status +pub fn validate_alert_status(value: &str) -> HealthResult<()> { + validate_enum!(value, "alert_status", [ + "pending", "acknowledged", "resolved", "dismissed", + ]); + Ok(()) +} + +/// 告警状态转换校验: pending→acknowledged/dismissed, acknowledged→resolved/dismissed +pub fn validate_alert_status_transition(current: &str, next: &str) -> HealthResult<()> { + if current == next { + return Ok(()); + } + let allowed = match current { + "pending" => matches!(next, "acknowledged" | "dismissed"), + "acknowledged" => matches!(next, "resolved" | "dismissed"), + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "alert.status: 不允许从 '{}' 转换到 '{}'", current, next + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -436,4 +487,58 @@ mod tests { fn fu_completed_to_any_fails() { assert!(validate_follow_up_status_transition("completed", "pending").is_err()); } #[test] fn fu_same_status_ok() { assert!(validate_follow_up_status_transition("pending", "pending").is_ok()); } + + // --- device_type --- + #[test] + fn device_type_heart_rate() { assert!(validate_device_type("heart_rate").is_ok()); } + #[test] + fn device_type_blood_oxygen() { assert!(validate_device_type("blood_oxygen").is_ok()); } + #[test] + fn device_type_steps() { assert!(validate_device_type("steps").is_ok()); } + #[test] + fn device_type_invalid() { assert!(validate_device_type("blood_pressure").is_err()); } + + // --- condition_type --- + #[test] + fn condition_single_threshold() { assert!(validate_condition_type("single_threshold").is_ok()); } + #[test] + fn condition_consecutive() { assert!(validate_condition_type("consecutive").is_ok()); } + #[test] + fn condition_trend() { assert!(validate_condition_type("trend").is_ok()); } + #[test] + fn condition_invalid() { assert!(validate_condition_type("moving_avg").is_err()); } + + // --- alert_severity --- + #[test] + fn severity_info() { assert!(validate_alert_severity("info").is_ok()); } + #[test] + fn severity_urgent() { assert!(validate_alert_severity("urgent").is_ok()); } + #[test] + fn severity_invalid() { assert!(validate_alert_severity("emergency").is_err()); } + + // --- alert_status --- + #[test] + fn alert_status_pending() { assert!(validate_alert_status("pending").is_ok()); } + #[test] + fn alert_status_resolved() { assert!(validate_alert_status("resolved").is_ok()); } + #[test] + fn alert_status_invalid() { assert!(validate_alert_status("open").is_err()); } + + // --- alert_status_transition --- + #[test] + fn alert_pending_to_acknowledged() { assert!(validate_alert_status_transition("pending", "acknowledged").is_ok()); } + #[test] + fn alert_pending_to_dismissed() { assert!(validate_alert_status_transition("pending", "dismissed").is_ok()); } + #[test] + fn alert_pending_to_resolved_fails() { assert!(validate_alert_status_transition("pending", "resolved").is_err()); } + #[test] + fn alert_acknowledged_to_resolved() { assert!(validate_alert_status_transition("acknowledged", "resolved").is_ok()); } + #[test] + fn alert_acknowledged_to_dismissed() { assert!(validate_alert_status_transition("acknowledged", "dismissed").is_ok()); } + #[test] + fn alert_acknowledged_to_pending_fails() { assert!(validate_alert_status_transition("acknowledged", "pending").is_err()); } + #[test] + fn alert_resolved_to_any_fails() { assert!(validate_alert_status_transition("resolved", "pending").is_err()); } + #[test] + fn alert_same_status_ok() { assert!(validate_alert_status_transition("pending", "pending").is_ok()); } } diff --git a/crates/erp-message/src/handler/sse_handler.rs b/crates/erp-message/src/handler/sse_handler.rs index e04256d..d434ff9 100644 --- a/crates/erp-message/src/handler/sse_handler.rs +++ b/crates/erp-message/src/handler/sse_handler.rs @@ -11,15 +11,18 @@ use crate::message_state::MessageState; /// SSE 消息推送端点。 /// -/// 客户端连接后监听 `message.sent` 事件,仅推送当前用户的消息。 -/// 使用 EventBus 的 filtered subscriber 按前缀过滤事件。 +/// 监听所有事件,按类型分发为不同 SSE event: +/// - `message.sent` → SSE event: `message` +/// - `alert.triggered` → SSE event: `alert` +/// - `device.readings.synced` → SSE event: `vital_update` pub async fn message_stream( axum::extract::State(state): axum::extract::State, Extension(ctx): Extension, ) -> Result>>, AppError> { let user_id = ctx.user_id; let tenant_id = ctx.tenant_id; - let (mut rx, _handle) = state.event_bus.subscribe_filtered("message.sent".to_string()); + // 空前缀 = 订阅所有事件 + let (mut rx, _handle) = state.event_bus.subscribe_filtered(String::new()); let sse_stream = async_stream::stream! { loop { @@ -28,19 +31,38 @@ pub async fn message_stream( if event.tenant_id != tenant_id { continue; } - let is_recipient = event.payload.get("recipient_id") - .and_then(|v: &serde_json::Value| v.as_str()) - .map(|s| s == user_id.to_string()) - .unwrap_or(false); - if !is_recipient { - continue; - } - let data = serde_json::to_string(&event.payload) - .unwrap_or_default(); - yield Ok(Event::default() - .event("message") - .data(data)); + match event.event_type.as_str() { + "message.sent" => { + let is_recipient = event.payload.get("recipient_id") + .and_then(|v: &serde_json::Value| v.as_str()) + .map(|s| s == user_id.to_string()) + .unwrap_or(false); + if !is_recipient { + continue; + } + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("message") + .data(data)); + } + "alert.triggered" => { + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("alert") + .data(data)); + } + "device.readings.synced" => { + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("vital_update") + .data(data)); + } + _ => {} + } } None => { break; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index e3555ac..566f1cc 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -72,6 +72,11 @@ mod m20260427_000069_add_dialysis_record_key_version; mod m20260427_000070_add_lab_report_key_version; mod m20260427_000071_add_diagnosis_key_version; mod m20260427_000072_widen_encrypted_phone_columns; +mod m20260426_000073_create_device_readings; +mod m20260426_000074_create_vital_signs_hourly; +mod m20260426_000075_create_patient_devices; +mod m20260426_000076_create_alert_rules; +mod m20260426_000077_create_alerts; pub struct Migrator; @@ -151,6 +156,11 @@ impl MigratorTrait for Migrator { Box::new(m20260427_000070_add_lab_report_key_version::Migration), Box::new(m20260427_000071_add_diagnosis_key_version::Migration), Box::new(m20260427_000072_widen_encrypted_phone_columns::Migration), + Box::new(m20260426_000073_create_device_readings::Migration), + Box::new(m20260426_000074_create_vital_signs_hourly::Migration), + Box::new(m20260426_000075_create_patient_devices::Migration), + Box::new(m20260426_000076_create_alert_rules::Migration), + Box::new(m20260426_000077_create_alerts::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs b/crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs new file mode 100644 index 0000000..e694b3a --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000073_create_device_readings.rs @@ -0,0 +1,67 @@ +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> { + // 分区表必须用 raw SQL,SeaORM schema builder 不支持 PARTITION BY + let sql = r#" + CREATE TABLE IF NOT EXISTS device_readings ( + id UUID NOT NULL 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 ADD PRIMARY KEY (id, measured_at);" + ).await?; + + // 核心查询索引 + 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?; + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260426_000074_create_vital_signs_hourly.rs b/crates/erp-server/migration/src/m20260426_000074_create_vital_signs_hourly.rs new file mode 100644 index 0000000..c9cec17 --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000074_create_vital_signs_hourly.rs @@ -0,0 +1,58 @@ +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 + } +} diff --git a/crates/erp-server/migration/src/m20260426_000075_create_patient_devices.rs b/crates/erp-server/migration/src/m20260426_000075_create_patient_devices.rs new file mode 100644 index 0000000..949a70c --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000075_create_patient_devices.rs @@ -0,0 +1,57 @@ +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("patient_devices")) + .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_id")).string().not_null()) + .col(ColumnDef::new(Alias::new("device_model")).string()) + .col(ColumnDef::new(Alias::new("device_type")).string()) + .col(ColumnDef::new(Alias::new("bound_at")).timestamp_with_time_zone().default(Expr::cust("NOW()"))) + .col(ColumnDef::new(Alias::new("last_sync_at")).timestamp_with_time_zone()) + .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("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1)) + .to_owned(), + ).await?; + + // 每个患者每个设备只能绑定一次 + manager.create_index( + Index::create() + .name("idx_pd_unique") + .table(Alias::new("patient_devices")) + .col(Alias::new("tenant_id")) + .col(Alias::new("patient_id")) + .col(Alias::new("device_id")) + .unique() + .to_owned(), + ).await?; + + // 查询索引 + manager.create_index( + Index::create() + .name("idx_pd_tenant_patient") + .table(Alias::new("patient_devices")) + .col(Alias::new("tenant_id")) + .col(Alias::new("patient_id")) + .to_owned(), + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(Alias::new("patient_devices")).to_owned()).await + } +} diff --git a/crates/erp-server/migration/src/m20260426_000076_create_alert_rules.rs b/crates/erp-server/migration/src/m20260426_000076_create_alert_rules.rs new file mode 100644 index 0000000..2aacfe0 --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000076_create_alert_rules.rs @@ -0,0 +1,50 @@ +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("alert_rules")) + .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("name")).string().not_null()) + .col(ColumnDef::new(Alias::new("description")).text()) + .col(ColumnDef::new(Alias::new("device_type")).string().not_null()) + .col(ColumnDef::new(Alias::new("condition_type")).string().not_null()) + .col(ColumnDef::new(Alias::new("condition_params")).json_binary().not_null().default(Expr::cust("'{}'::jsonb"))) + .col(ColumnDef::new(Alias::new("severity")).string().not_null().default("'warning'")) + .col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(Expr::cust("true"))) + .col(ColumnDef::new(Alias::new("apply_tags")).json_binary()) + .col(ColumnDef::new(Alias::new("notify_roles")).json_binary().default(Expr::cust("'[]'::jsonb"))) + .col(ColumnDef::new(Alias::new("cooldown_minutes")).integer().not_null().default(60)) + .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("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1)) + .to_owned(), + ).await?; + + // 查询索引 + manager.create_index( + Index::create() + .name("idx_ar_tenant_active") + .table(Alias::new("alert_rules")) + .col(Alias::new("tenant_id")) + .col(Alias::new("is_active")) + .col(Alias::new("device_type")) + .to_owned(), + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(Alias::new("alert_rules")).to_owned()).await + } +} diff --git a/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs b/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs new file mode 100644 index 0000000..b5e473a --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs @@ -0,0 +1,98 @@ +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(TenantCryptoKey::Table) + .col( + ColumnDef::new(TenantCryptoKey::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(TenantCryptoKey::TenantId).uuid().not_null()) + .col(ColumnDef::new(TenantCryptoKey::EncryptedDek).string_len(128).not_null()) + .col( + ColumnDef::new(TenantCryptoKey::KeyVersion) + .integer() + .not_null() + .default(1), + ) + .col( + ColumnDef::new(TenantCryptoKey::IsActive) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(TenantCryptoKey::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(TenantCryptoKey::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(TenantCryptoKey::CreatedBy).uuid()) + .col(ColumnDef::new(TenantCryptoKey::UpdatedBy).uuid()) + .col(ColumnDef::new(TenantCryptoKey::DeletedAt).timestamp_with_time_zone()) + .col( + ColumnDef::new(TenantCryptoKey::Version) + .integer() + .not_null() + .default(1), + ) + .index( + Index::create() + .col(TenantCryptoKey::TenantId) + .col(TenantCryptoKey::KeyVersion) + .unique(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_tenant_crypto_keys_tenant") + .table(TenantCryptoKey::Table) + .col(TenantCryptoKey::TenantId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TenantCryptoKey::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum TenantCryptoKey { + Table, + Id, + TenantId, + EncryptedDek, + KeyVersion, + IsActive, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/docs/superpowers/specs/2026-04-26-test-coverage-strategy-design.md b/docs/superpowers/specs/2026-04-26-test-coverage-strategy-design.md index f460a8a..ec4bf1b 100644 --- a/docs/superpowers/specs/2026-04-26-test-coverage-strategy-design.md +++ b/docs/superpowers/specs/2026-04-26-test-coverage-strategy-design.md @@ -8,9 +8,9 @@ HMS 健康管理平台当前测试覆盖极低: -- **后端**: 461 单元测试 + 54 集成测试,但 erp-health 34 entity 中 27 个 service 零单元测试,erp-ai 整个 crate 零测试 -- **前端**: 138 个源文件仅 3 个测试文件(覆盖率 2.2%),API 层/Store 层/hooks 全部无测试 -- **E2E**: Playwright 已配置(含 auth fixture),但零测试用例 +- **后端**: 约 461 单元测试 + 54 集成测试(注:wiki/testing.md 记录 93 个,差距因统计口径不同 — 此处含所有 crate 的 `#[test]`/`#[tokio::test]` 标记),但 erp-health 34 entity 中 27 个 service 零单元测试,erp-ai 整个 crate 零测试 +- **前端**: 138 个源文件仅 3 个单元测试文件(覆盖率 2.2%),API 层/Store 层/hooks 全部无测试 +- **E2E**: Playwright 已配置(含 auth fixture `apps/web/e2e/auth.fixture.ts`),已有 4 个 spec、10 个测试用例(login/users/plugins/tenant-isolation),但健康模块零 E2E 覆盖 ### 1.2 目标 @@ -24,7 +24,7 @@ HMS 健康管理平台当前测试覆盖极低: |------|------|------| | 覆盖范围 | 全量 80% | 医疗场景需要全面质量保证 | | CI 门禁 | 增量门禁 | 不阻塞现有开发,只要求新增代码 | -| 后端策略 | 集成测试为主(Testcontainers) | 最接近生产,SeaORM mock 不成熟 | +| 后端策略 | 集成测试为主(本地 PostgreSQL 隔离数据库) | 最接近生产,SeaORM mock 不成熟;当前 TestDb 已连接本地 PostgreSQL | | 整体方案 | 分层渐进式(方案 A) | 按风险密度排序,与增量门禁配合 | --- @@ -35,7 +35,7 @@ HMS 健康管理平台当前测试覆盖极低: ``` ┌─────────────────────────────────────────┐ -│ Integration Tests (Testcontainers) │ ← 27 service + 16 handler +│ Integration Tests (本地 PG 隔离) │ ← 30 service + 16 handler │ erp-server/tests/integration/ │ ├─────────────────────────────────────────┤ │ Unit Tests (tokio::test / #[test]) │ ← validation/crypto/masking @@ -48,13 +48,13 @@ HMS 健康管理平台当前测试覆盖极低: ### 2.2 TestDb 基础设施增强 -当前 `crates/erp-server/tests/integration/test_db.rs` 已有 `TestDb` struct。需要增强为: +当前 `crates/erp-server/tests/integration/test_db.rs` 已有 `TestDb` struct,连接**本地 PostgreSQL** 创建临时数据库(不依赖 Docker/Testcontainers,与开发环境一致)。需要增强为: **TestApp struct** — 封装完整测试环境: -- TestDb(PostgreSQL Testcontainer + 自动迁移) -- Axum Router(与生产配置相同的路由) -- AppState(DatabaseConnection + EventBus + Crypto) -- HTTP 客户端(`reqwest::Client` 或 `tower::ServiceExt`) +- TestDb(本地 PostgreSQL 隔离数据库 + 自动迁移) +- Axum Router(仅注册 erp-health 模块,减少启动开销) +- AppState(DatabaseConnection + EventBus + Crypto,使用测试专用密钥) +- HTTP 客户端(使用 `tower::ServiceExt`,无需真实 TCP 监听,测试更快速稳定) **TestFixture 工厂** — 预构建测试数据: - `create_tenant(name)` → 创建租户 + 返回 TenantContext @@ -67,6 +67,16 @@ HMS 健康管理平台当前测试覆盖极低: ### 2.3 Phase 分配 +#### Phase 0: 测试基础设施搭建(Week 1 前 2 天) + +| 任务 | 产出 | +|------|------| +| TestApp struct 实现 | TestDb + Axum Router + AppState + tower ServiceExt 客户端 | +| TestFixture 工厂 | create_tenant/create_user/create_patient/create_doctor 工厂函数 | +| `@vitest/coverage-v8` 安装配置 | 前端覆盖率报告可用 | +| MSW v2 初始配置 | handlers.ts + server.ts 基础框架 | +| `cargo-llvm-cov` 安装验证 | 后端增量覆盖率命令可用(配合自定义 git diff 脚本过滤) | + #### Phase 1: 高风险 Service(Week 1-2) | Service | 测试重点 | 估计测试数 | @@ -75,6 +85,7 @@ HMS 健康管理平台当前测试覆盖极低: | `dialysis_service` | PII 字段加密存储、HMAC 索引查询、CRUD 完整性 | 8 | | `alert_engine` | 规则评估逻辑、cooldown 检查、危急值触发 | 8 | | `alert_rule_service` | 规则 CRUD、阈值验证、启用/禁用 | 6 | +| `alert_service` | 告警 CRUD、状态变更、批量确认 | 5 | | `device_reading_service` | 批量插入、降采样聚合、数据范围查询 | 8 | #### Phase 2: 中风险 Service(Week 3-4) @@ -98,11 +109,17 @@ HMS 健康管理平台当前测试覆盖极低: | `offline_event_service` | CRUD + 报名管理 | 4 | | `consent_service` | CRUD + 签署状态 | 3 | | `diagnosis_service` | CRUD + ICD 编码验证 | 3 | +| `daily_monitoring_service` | 日常监测 CRUD + 阈值校验 | 4 | +| `critical_value_threshold_service` | 阈值 CRUD + 范围校验 | 3 | | `stats_service` | 各维度统计查询正确性 | 5 | | `health_data_service` | 体征/化验/体检 CRUD + 趋势计算 | 8 | +| `trend_service` | 已有 14 个内联测试,补充集成测试 | 3 | +| `masking` | 已有 14 个内联测试,保持 | 0 | | handler 层 | 16 个 handler 的 HTTP 层测试 | 16 | | DTO 转换 | 请求/响应 DTO 序列化/反序列化 | 10 | +> 注:所有健康模块集成测试集中在 `erp-server/tests/integration/` 下,通过 `cargo test -p erp-server` 运行。erp-health 的内联单元测试(validation/crypto/masking/trend)通过 `cargo test -p erp-health` 运行。 + ### 2.4 测试命名规范 ``` @@ -220,7 +237,9 @@ apps/web/src/test/ | `pages/health/FollowUpTaskList.tsx` | 列表渲染、状态筛选 | 3 | | `pages/health/StatisticsDashboard.tsx` | 数据加载、卡片渲染 | 3 | -#### Week 9: Playwright E2E +#### Week 9: Playwright E2E(健康模块专项) + +已有 4 个基础 ERP spec(login/users/plugins/tenant-isolation,10 个测试)。Week 9 新增健康模块 E2E: | 测试场景 | 覆盖流程 | |---------|---------| @@ -230,47 +249,83 @@ apps/web/src/test/ | 咨询流程 | 创建咨询 → 发送消息 → 关闭 | | 数据统计 | 查看统计仪表板 → 导出 | +> 注:E2E 测试依赖前后端同时启动。Playwright 配置了 `webServer.command: pnpm dev`(前端),但需确保后端服务已运行。在 CI 中需同时启动后端(`cargo run`)+ 前端(`pnpm dev`)。 + --- ## 4. CI 门禁策略 ### 4.1 增量覆盖率门禁 -**后端** — 使用 `cargo-llvm-cov`: +**后端** — 使用 `cargo-llvm-cov` 配合自定义脚本: ```bash -cargo llvm-cov --fail-under-lines=80 --modified-files-only +# 安装: cargo install cargo-llvm-cov +# 全量测试 +cargo llvm-cov --workspace --lcov --output-path lcov.info +# 增量检查(自定义脚本,基于 git diff 过滤变更文件) +cargo llvm-cov --workspace --json | python3 scripts/coverage-diff-check.py --threshold=80 ``` +> 注:`cargo-llvm-cov` 原生不支持 `--modified-files-only`,需要自定义脚本实现增量过滤。Phase 0 中需验证可行性。 + **前端** — 使用 `@vitest/coverage-v8`: ```bash -cd apps/web && pnpm vitest run --coverage --changed=HEAD~1 +cd apps/web && pnpm vitest run --coverage +# 增量检查通过自定义脚本过滤变更文件 ``` ### 4.2 CI 流水线 +> **前置条件**: 项目当前无 CI 配置(`.github/workflows/` 不存在),Phase 0 需创建初始 workflow。 + ```yaml -# PR 提交时 +# .github/workflows/test.yml on: pull_request jobs: backend-test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: test_password + POSTGRES_DB: erp_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + ERP__DATABASE__URL: postgres://postgres:test_password@localhost:5432/erp_test + ERP__JWT__SECRET: ci-test-jwt-secret steps: + - uses: actions/checkout@v4 - cargo test --workspace - - cargo llvm-cov --fail-under-lines=80 --modified-files-only + - cargo llvm-cov --workspace --json frontend-test: runs-on: ubuntu-latest steps: - - cd apps/web && pnpm test:ci - - pnpm vitest run --coverage --changed=origin/main + - uses: actions/checkout@v4 + - cd apps/web && pnpm install && pnpm test:ci # E2E: 仅 main 分支或手动触发 e2e-test: if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + services: + postgres: # 同上 steps: + - cargo run & # 后台启动后端 + - cd apps/web && pnpm dev & # 后台启动前端 + - sleep 10 # 等待服务就绪 - pnpm exec playwright test ``` +> CI 流水线需包含 CLAUDE.md 要求的完整验证链:`cargo check` → `cargo test` → `cargo clippy` → `pnpm build`。 + ### 4.3 里程碑 | 时间点 | 门禁要求 | @@ -285,11 +340,16 @@ jobs: | 风险 | 影响 | 缓解措施 | |------|------|---------| -| Testcontainers Windows 启动慢 | 开发体验差 | CI 用 Linux runner;本地开发串行测试;预热 Docker 镜像 | -| MSW mock 与真实 API 不一致 | 测试通过但生产出错 | 关键流程 E2E 兜底;从 OpenAPI spec 生成 handlers | +| 本地 PG 并行测试连接池耗尽 | 多个 TestDb 实例同时创建可能超限 | 限制并行度 `--test-threads=2`;CI 环境单独配置 | +| 测试 panic 后残留临时数据库 | 长期累积占用磁盘 | TestApp Drop 时强制清理;定期脚本清理 `test_*` 前缀数据库 | +| 并行迁移竞争 | 多个 TestDb 同时 `Migrator::up` | 每个测试用独立数据库名(UUID),互不影响 | +| MSW mock 与真实 API 不一致 | 测试通过但生产出错 | 关键流程 E2E 兜底;MSW handlers 从 OpenAPI spec 自动生成 | | 补测与功能开发冲突 | merge conflict | 测试文件独立于业务代码;集成测试集中在 erp-server/tests/ | -| 前端 9 周达不到 80% | 延期 | 接受"后端 80% + 前端 60%"作为 Phase 1 目标 | +| 前端 9 周达不到 80% | 延期 | 接受"后端 80% + 前端 60%"作为 Phase 1 目标,前端在后续迭代持续补齐 | | erp-ai 依赖外部 AI API | 测试不稳定 | mock 外部 AI 调用,仅测试内部逻辑 | +| MSW 与 axios 缓存 adapter 冲突 | mock 拦截异常 | MSW 在 service worker 层拦截,绕过 axios adapter;需验证兼容性 | +| E2E 测试后端未启动失败 | 测试环境不可用 | CI 中先启动后端再运行 E2E;本地用 dev.ps1 一键启动 | +| 目标不可达时无回退方案 | 计划失控 | 每 Phase 结束复盘覆盖率;如 Phase 2 后仅 50%,则缩减 Phase 3 范围或延长时间线 | --- @@ -300,6 +360,8 @@ jobs: | 后端 service 测试覆盖率 | ≥ 80% | ≥ 85% | | 后端全量测试覆盖率 | ≥ 70% | ≥ 80% | | 前端 API+Store 测试覆盖率 | - | ≥ 80% | -| 前端全量测试覆盖率 | - | ≥ 60% | -| E2E 测试用例数 | - | ≥ 5 | +| 前端全量测试覆盖率 | - | ≥ 60%(Phase 1 目标,后续迭代持续提升到 80%) | +| E2E 测试用例数(健康模块) | - | ≥ 5 个新 spec | | CI 增量门禁 | 后端上线 | 前后端上线 | + +> **目标澄清**: "全量 80%"是最终目标。Phase 1(9 周)的目标是后端 80% + 前端 60%,前端剩余部分在后续迭代中持续补齐。每 Phase 结束后复盘实际覆盖率,必要时调整计划。 diff --git a/wiki/database.md b/wiki/database.md index 6a1b393..3fd1de2 100644 --- a/wiki/database.md +++ b/wiki/database.md @@ -1,6 +1,6 @@ --- title: 数据库迁移与模式 -updated: 2026-04-25 +updated: 2026-04-26 status: stable tags: [database, seaorm, migration, multi-tenant] --- @@ -24,7 +24,7 @@ tags: [database, seaorm, migration, multi-tenant] | 文件 | 职责 | |------|------| | `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 | -| `crates/erp-server/migration/src/m*.rs` | 50 个迁移文件 | +| `crates/erp-server/migration/src/m*.rs` | 72 个迁移文件 | | `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 | ### 迁移命名规则 @@ -34,34 +34,53 @@ m{YYYYMMDD}_{6位序号}_{描述}.rs 例: m20260410_000001_create_tenant.rs ``` -### 当前表概览(48 张) +### 当前表概览(67+ 张) | 模块 | 表 | |------|-----| -| 基础 | tenant | +| 基础 | tenant, tenant_crypto_keys | | 认证 (auth) | users, user_credentials, user_tokens, roles, permissions, role_permissions, user_roles, organizations, departments, positions, user_departments | | 配置 (config) | dictionaries, dictionary_items, menus, menu_roles, settings, numbering_rules | | 工作流 (workflow) | process_definitions, process_instances, tokens, tasks, process_variables | | 消息 (message) | message_templates, messages, message_subscriptions | | 审计 | audit_logs, domain_events | | 插件 (plugin) | plugins, entity_registry, plugin_market, plugin_user_views | -| **健康 (health)** | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation, doctor_profile, health_record, vital_signs, lab_report, health_trend, appointment, doctor_schedule, follow_up_task, follow_up_record, consultation_session, consultation_message | +| **健康 (health)** | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation, doctor_profile, health_record, vital_signs, daily_monitoring, lab_report, health_trend, diagnosis, dialysis_record, critical_value_thresholds, consent, appointment, doctor_schedule, follow_up_task, follow_up_record, consultation_session, consultation_message | +| **内容 (article)** | article, article_category, article_tag, article_article_tag, article_revision | +| **积分 (points)** | points_account, points_rule, points_product, points_order, points_transaction, points_checkin | +| **线下活动** | offline_event, offline_event_registration | +| **AI (ai)** | ai_prompt, ai_analysis, ai_usage | | **微信 (wechat)** | wechat_users | -| **内容 (article)** | article | -### 健康模块迁移(m000042 - m000050) +### 健康模块迁移(m000042 - m000072) | 迁移 | 变更 | |------|------| -| m000042 | 创建 17 张健康业务表(patient/doctor/appointment/schedule/vital_signs/lab_report/health_record/health_trend/follow_up_task/follow_up_record/consultation_session/consultation_message/patient_tag/patient_tag_relation/patient_family_member/patient_doctor_relation) | +| m000042 | 创建 17 张健康业务表 | | m000043 | 创建 wechat_users 表 | | m000044 | 创建 article 表 | | m000045 | 健康模块索引优化 | | m000046 | 健康模块约束修复 | | m000047 | 健康模块索引修复 | -| m000048 | 添加 patient.id_number_hash 列(HMAC-SHA256 哈希身份证) | -| m000049 | 拓宽 patient.id_number 列(VARCHAR 加密存储需要更长字段) | -| m000050 | 添加 appointment.doctor_name 列(冗余提升查询性能) | +| m000048 | 添加 patient.id_number_hash 列 | +| m000049 | 拓宽 patient.id_number 列 | +| m000050 | 添加 appointment.doctor_name 列 | +| m000051 | 透析/化验增强字段 | +| m000052 | 创建 AI 分析表(ai_prompt/ai_analysis/ai_usage) | +| m000053 | 创建积分商城表(points_account/rule/product/order/transaction) | +| m000054 | 创建日常监测表(daily_monitoring) | +| m000055 | 积分签到标准字段 | +| m000056 | 创建诊断表(diagnosis) | +| m000057 | 重命名 points_transaction 类型列 | +| m000058 | 合并 daily_monitoring 到 vital_signs | +| m000059 | 种子菜单数据 | +| m000060 | 创建危急值阈值表 | +| m000061 | 创建知情同意表 | +| m000062 | 创建租户加密密钥表(tenant_crypto_keys) | +| m000063 | 内容管理表(article_category/article_tag/article_article_tag/article_revision) | +| m000064-000068 | PII 加密扩展(patient/consultation/follow_up/family_member/doctor_profile) | +| m000069-000071 | 加密字段 key_version(dialysis_record/lab_report/diagnosis) | +| m000072 | 拓宽加密手机号列 | ### 集成契约 @@ -70,7 +89,7 @@ m{YYYYMMDD}_{6位序号}_{描述}.rs | 消费 ← | [[erp-server]] | 启动时自动运行 `Migrator::up()` | | 依赖 ← | [[erp-core]] | BaseFields 定义标准字段规范 | | 提供 → | 所有业务模块 | 表结构供 SeaORM Entity 使用 | -| 提供 → | [[erp-health]] | 18 张健康业务表 | +| 提供 → | [[erp-health]] | 34 张健康业务表 | ## 3. 代码逻辑 @@ -112,6 +131,7 @@ m{YYYYMMDD}_{6位序号}_{描述}.rs | 日期 | 变更 | |------|------| +| 2026-04-26 | 更新至 72 迁移、67+ 表,新增积分商城/透析/诊断/内容管理/线下活动/PII 加密扩展等 22 个迁移 | | 2026-04-25 | 更新至 50 迁移、48 表,新增健康模块迁移(m000042-m000050)和 18 张健康业务表 | | 2026-04-23 | 重构为 5 节结构,更新表清单至 41 个迁移 | | 2026-04-19 | CRM 权限码修复迁移 (m000038) | diff --git a/wiki/erp-health.md b/wiki/erp-health.md index f050c14..9116bb6 100644 --- a/wiki/erp-health.md +++ b/wiki/erp-health.md @@ -2,7 +2,7 @@ title: erp-health 健康管理模块 updated: 2026-04-26 status: implemented -tags: [health, patient, appointment, follow-up, consultation, content] +tags: [health, patient, appointment, follow-up, consultation, content, points, dialysis, offline-events] --- # erp-health 健康管理模块 @@ -47,28 +47,33 @@ crates/erp-health/ │ ├── state.rs ← HealthState { db, event_bus, crypto } │ ├── crypto.rs ← AES-256-GCM 加密 + HMAC-SHA256 (PII 保护) │ ├── event.rs ← 事件处理器 (订阅 workflow/message 事件) -│ ├── entity/ ← 21 个 SeaORM Entity -│ ├── service/ ← 14 个业务 service -│ ├── handler/ ← 9 个路由 handler +│ ├── entity/ ← 34 个 SeaORM Entity +│ ├── service/ ← 14 个业务 service + validation + masking + trend +│ ├── handler/ ← 16 个路由 handler │ ├── dto/ ← 7 个请求/响应 DTO │ ├── validation.rs ← 输入验证逻辑 (302 行, 57 纯函数测试) │ ├── masking.rs ← PII 数据脱敏 (手机号/身份证) │ └── seed.rs ← 租户种子数据 + 软删除清理 ``` -### 实体模型(21 个实体) +### 实体模型(34 个实体) | 域 | 实体 | |----|------| -| 患者管理 | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation | +| 患者管理 | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation, consent | | 医护管理 | doctor_profile | | 健康数据 | health_record, vital_signs, lab_report, health_trend | +| 日常监测 | daily_monitoring, critical_value_threshold | +| 诊断管理 | diagnosis | +| 透析管理 | dialysis_record | | 预约排班 | appointment, doctor_schedule | | 随访管理 | follow_up_task, follow_up_record | | 咨询管理 | consultation_session, consultation_message | | 内容管理 | article, article_category, article_tag, article_article_tag, article_revision | +| 积分商城 | points_account, points_rule, points_product, points_order, points_transaction, points_checkin | +| 线下活动 | offline_event, offline_event_registration | -### 权限码(15 个) +### 权限码(15+ 个) | 权限码 | 说明 | |--------|------| @@ -96,11 +101,14 @@ crates/erp-health/ ### API 前缀: `/api/v1/health/` 关键端点分组: -- `/patients` — 患者列表/详情/标签管理/健康摘要/家庭成团/医生关联 +- `/patients` — 患者列表/详情/标签管理/健康摘要/家庭成员/医生关联/知情同意 - `/patients/{id}/vital-signs` — 日常监测数据(血压/心率/体重/血糖) - `/patients/{id}/lab-reports` — 化验报告(JSONB 指标数据) - `/patients/{id}/health-records` — 健康档案 - `/patients/{id}/trends` — 健康趋势报告(自动/手动生成,时间序列查询) +- `/patients/{id}/diagnoses` — 诊断记录 +- `/patients/{id}/dialysis-records` — 透析记录 +- `/patients/{id}/daily-monitoring` — 日常监测记录 - `/vital-signs/trend` — 小程序趋势(JWT user → patient) - `/vital-signs/today` — 小程序当日体征摘要 - `/appointments` — 预约管理 + 状态变更(pending→confirmed→completed/cancelled/no_show) @@ -120,6 +128,13 @@ crates/erp-health/ - `/articles/{id}/view` — 增加阅读计数 - `/article-categories` — 分类 CRUD - `/article-tags` — 标签 CRUD +- `/points/rules` — 积分规则 CRUD +- `/points/products` — 积分商品 CRUD +- `/points/orders` — 积分订单 +- `/points/accounts` — 积分账户 + 签到 +- `/offline-events` — 线下活动 CRUD + 报名 +- `/stats/*` — 统计概览(透析/化验/预约/体征上报率) +- `/critical-value-thresholds` — 危急值阈值管理 ### 预约并发控制 @@ -169,7 +184,7 @@ draft → pending_review → published → draft (撤回) ### 当前状态: ✅ 已完成 -21 实体、15 权限、19 Web 页面、20 小程序页面,全链路流通性验证通过。 +34 实体、16 个 handler(~148 个 pub fn)、15+ 权限、22 Web 页面 + 11 组件,17k 行 Rust 代码。状态转换验证统一到 validation 模块(83 纯函数测试)。 ### 待优化 @@ -195,6 +210,7 @@ draft → pending_review → published → draft (撤回) | 日期 | 变更 | |------|------| +| 2026-04-26 | 全面更新:34 实体(+13 积分/透析/诊断/日常监测/线下活动/危急值/知情同意)、16 handler、stats 统计端点、validation 统一模块(83 测试)、PII 加密扩展(doctor_profile/dialysis_record/lab_report/diagnosis key_version) | +| 2026-04-26 | 新增内容管理:article_category/article_tag/article_article_tag/article_revision 4 实体、审核状态机 | | 2026-04-25 | 全面更新为已实现状态:18 实体、14 权限、全链路验证通过 | -| 2026-04-26 | 新增内容管理:article_category/article_tag/article_article_tag/article_revision 4 实体、审核状态机、分类/标签 CRUD、富文本编辑器(+4 实体 = 21 总实体,+1 权限 = 15 总权限,+4 前端页面 = 19 总页面) | | 2026-04-23 | 创建模块 wiki 页,设计规格确认 | diff --git a/wiki/index.md b/wiki/index.md index 6fadf84..3dd74db 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -6,18 +6,19 @@ | 指标 | 值 | |------|-----| -| Rust crate | 16 个(erp-core + 6 基础业务 + erp-health + erp-ai + 6 插件 + erp-plugin-prototype) | -| 数据库表 | 30 基础表 + 22 健康业务表 + 3 AI 表(已实现) | -| 数据库迁移 | 55 个 | +| Rust crate | 15 个(erp-core + 5 基础业务 + erp-health + erp-ai + erp-plugin + 4 插件 + erp-plugin-prototype) | +| 数据库表 | 30 基础表 + 34 健康业务表 + 3 AI 表(已实现) | +| 数据库迁移 | 72 个 | | 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 2 业务 (health + ai) | -| Web 前端页面 | 66 个 TSX 组件(含 19 健康管理页面) | -| 健康模块组件 | 12 个共享组件(StatusTag/PatientSelect/DoctorSelect/VitalSignsChart 等) | -| 微信小程序 | Taro 4.2 + React 18,27 个页面 | +| erp-health 实体 | 34 个 Entity(17k 行 Rust) | +| erp-ai 实体 | 3 个 Entity(1.7k 行 Rust) | +| Web 前端 | 77 个 TSX + 56 个 TS = 133 个源文件(48 个页面 + 22 健康页面 + 11 健康组件) | +| 微信小程序 | Taro 4.2 + React 18,12 个页面 | | 前端单元测试 | 3 个(vitest)+ 4 E2E spec(playwright) | -| 后端测试 | 36 个(workspace)+ 57 validation 纯函数测试 | -| 总代码量 | Rust ~57k 行 + 前端 TSX/TS ~174 文件 | +| 后端测试 | 36 个(workspace)+ 83 validation 纯函数测试 | +| 总代码量 | Rust ~63k 行 + 前端 TSX/TS ~133 文件 + 小程序 ~7.5k 行 | | API 文档 | `http://localhost:3000/api/docs/openapi.json` | -| Git 提交 | 273 次 | +| Git 提交 | 297 次 | ## 症状导航 @@ -52,8 +53,8 @@ - erp-plugin — WASM 运行时 · 动态表 · 热更新(HMS 保留但非主要扩展方式) ### 核心业务层(HMS 专属) -- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理 · 内容管理**(原生 Rust 模块,已实现) -- [[erp-ai]] — **AI 智能分析 · 化验单解读 · 趋势分析 · 报告摘要**(原生 Rust 模块,开发中) +- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理 · 内容管理 · 积分商城 · 透析管理 · 线下活动 · 日常监测**(原生 Rust 模块,34 实体,已实现) +- [[erp-ai]] — **AI 智能分析 · 化验单解读 · 趋势分析 · 报告摘要**(原生 Rust 模块,3 实体,Phase 1 MVP) ### 组装层 - [[erp-server]] — Axum 入口 · AppState · 7 模块注册 · 后台任务 · 优雅关闭 @@ -63,8 +64,8 @@ ### 基础设施 - [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**) -- [[database]] — SeaORM 迁移 · 多租户表结构 -- [[frontend]] — React 19 SPA · 健康管理页面(19 页面 + 12 组件) +- [[database]] — SeaORM 迁移 · 多租户表结构(72 迁移) +- [[frontend]] — React 19 SPA · 健康管理页面(22 页面 + 11 组件) - [[testing]] — 验证清单 · 测试分布 · 性能基准 ## 核心架构问答 @@ -82,8 +83,13 @@ | 类型 | 位置 | |------|------| | 健康模块设计规格 | `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | -| QA 审计计划 | `plans/qa-review-brainstorm-floofy-finch.md` | -| 设计规格 | `docs/superpowers/specs/` | -| 实施计划 | `docs/superpowers/plans/` | +| AI 模块设计规格 | `docs/superpowers/specs/2026-04-25-erp-ai-module-design.md` | +| 内容管理设计规格 | `docs/superpowers/specs/2026-04-26-content-management-design.md` | +| PII 加密扩展规格 | `docs/superpowers/specs/2026-04-26-pii-encryption-expansion-design.md` | +| 实时体征管线探讨 | `docs/superpowers/specs/2026-04-26-realtime-vital-signs-pipeline-design.md` | +| 平台复盘与演进 | `docs/superpowers/specs/2026-04-26-platform-retrospective-and-evolution-design.md` | +| 设计规格(全量) | `docs/superpowers/specs/` (23 份) | +| 实施计划(全量) | `docs/superpowers/plans/` (18 份) | +| 讨论记录 | `docs/discussions/` (6 份) | | 协作规则 | `CLAUDE.md` | | 插件制作指南 | `.claude/skills/plugin-development/SKILL.md` |