docs: 修正测试策略 spec 的事实性错误
修正 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%"目标的准确含义
This commit is contained in:
72
crates/erp-health/src/dto/alert_dto.rs
Normal file
72
crates/erp-health/src/dto/alert_dto.rs
Normal file
@@ -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<String>,
|
||||
pub device_type: String,
|
||||
pub condition_type: String,
|
||||
pub condition_params: serde_json::Value,
|
||||
pub severity: Option<String>,
|
||||
pub apply_tags: Option<serde_json::Value>,
|
||||
pub notify_roles: Option<serde_json::Value>,
|
||||
pub cooldown_minutes: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateAlertRuleRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub condition_params: Option<serde_json::Value>,
|
||||
pub severity: Option<String>,
|
||||
pub apply_tags: Option<serde_json::Value>,
|
||||
pub notify_roles: Option<serde_json::Value>,
|
||||
pub cooldown_minutes: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AlertRuleResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub device_type: String,
|
||||
pub condition_type: String,
|
||||
pub condition_params: serde_json::Value,
|
||||
pub severity: String,
|
||||
pub is_active: bool,
|
||||
pub apply_tags: Option<serde_json::Value>,
|
||||
pub notify_roles: serde_json::Value,
|
||||
pub cooldown_minutes: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ── 告警记录 DTO ──
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AcknowledgeAlertRequest {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AlertResponse {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub rule_id: Uuid,
|
||||
pub severity: String,
|
||||
pub title: String,
|
||||
pub detail: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
pub acknowledged_by: Option<Uuid>,
|
||||
pub acknowledged_at: Option<DateTime<Utc>>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod appointment_dto;
|
||||
pub mod alert_dto;
|
||||
pub mod article_dto;
|
||||
pub mod consent_dto;
|
||||
pub mod consultation_dto;
|
||||
|
||||
36
crates/erp-health/src/entity/alert_rules.rs
Normal file
36
crates/erp-health/src/entity/alert_rules.rs
Normal file
@@ -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<String>,
|
||||
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<serde_json::Value>,
|
||||
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<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
58
crates/erp-health/src/entity/alerts.rs
Normal file
58
crates/erp-health/src/entity/alerts.rs
Normal file
@@ -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<serde_json::Value>,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub acknowledged_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub acknowledged_at: Option<DateTimeUtc>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
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<super::alert_rules::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AlertRule.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
39
crates/erp-health/src/entity/device_readings.rs
Normal file
39
crates/erp-health/src/entity/device_readings.rs
Normal file
@@ -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<String>,
|
||||
pub device_type: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub device_model: Option<String>,
|
||||
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<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[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<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -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;
|
||||
|
||||
47
crates/erp-health/src/entity/patient_devices.rs
Normal file
47
crates/erp-health/src/entity/patient_devices.rs
Normal file
@@ -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<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub device_type: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub bound_at: Option<DateTimeUtc>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub last_sync_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<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<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
40
crates/erp-health/src/entity/vital_signs_hourly.rs
Normal file
40
crates/erp-health/src/entity/vital_signs_hourly.rs
Normal file
@@ -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<f64>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub max_val: Option<f64>,
|
||||
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<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -68,6 +68,12 @@ pub enum HealthError {
|
||||
#[error("知情同意记录不存在")]
|
||||
ConsentNotFound,
|
||||
|
||||
#[error("告警规则不存在")]
|
||||
AlertRuleNotFound,
|
||||
|
||||
#[error("告警记录不存在")]
|
||||
AlertNotFound,
|
||||
|
||||
#[error("状态转换无效: {0}")]
|
||||
InvalidStatusTransition(String),
|
||||
|
||||
@@ -101,7 +107,9 @@ impl From<HealthError> 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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
100
crates/erp-health/src/handler/alert_handler.rs
Normal file
100
crates/erp-health/src/handler/alert_handler.rs
Normal file
@@ -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<Uuid>,
|
||||
pub status: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_alerts<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<AlertListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<AcknowledgeAlertRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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)))
|
||||
}
|
||||
102
crates/erp-health/src/handler/alert_rule_handler.rs
Normal file
102
crates/erp-health/src/handler/alert_rule_handler.rs
Normal file
@@ -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<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeactivateRequest {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn list_rules<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<RuleListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
axum::Json(body): axum::Json<CreateAlertRuleRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<UpdateAlertRuleRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
axum::Json(body): axum::Json<DeactivateRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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)))
|
||||
}
|
||||
92
crates/erp-health/src/handler/device_reading_handler.rs
Normal file
92
crates/erp-health/src/handler/device_reading_handler.rs
Normal file
@@ -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<String>,
|
||||
pub hours: Option<i64>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct HourlyQuery {
|
||||
pub device_type: String,
|
||||
pub days: Option<i64>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn batch_create<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(path): Path<PatientPath>,
|
||||
axum::Json(body): axum::Json<device_reading_service::BatchReadingRequest>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(path): Path<PatientPath>,
|
||||
Query(query): Query<ReadingListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(path): Path<PatientPath>,
|
||||
Query(query): Query<HourlyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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)))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
240
crates/erp-health/src/service/alert_engine.rs
Normal file
240
crates/erp-health/src/service/alert_engine.rs
Normal file
@@ -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<Vec<alerts::Model>> {
|
||||
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<bool> {
|
||||
let cooldown_start = Utc::now() - chrono::Duration::minutes(cooldown_minutes as i64);
|
||||
let recent = alerts::Entity::find()
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::PatientId.eq(patient_id))
|
||||
.filter(alerts::Column::RuleId.eq(rule_id))
|
||||
.filter(alerts::Column::CreatedAt.gt(cooldown_start))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(recent.is_some())
|
||||
}
|
||||
|
||||
async fn evaluate_single_threshold(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
device_type: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> HealthResult<bool> {
|
||||
let direction = params["direction"].as_str().unwrap_or("above");
|
||||
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
|
||||
|
||||
let latest = vital_signs_hourly::Entity::find()
|
||||
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
|
||||
.order_by_desc(vital_signs_hourly::Column::HourStart)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match latest {
|
||||
Some(record) => {
|
||||
let val = record.avg_val;
|
||||
Ok(match direction {
|
||||
"above" => val > threshold,
|
||||
"below" => val < threshold,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn evaluate_consecutive(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
device_type: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> HealthResult<bool> {
|
||||
let count = params["count"].as_u64().unwrap_or(3) as u64;
|
||||
let direction = params["direction"].as_str().unwrap_or("above");
|
||||
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
|
||||
let window_hours = params["window_hours"].as_i64();
|
||||
|
||||
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<bool> {
|
||||
let window_hours = params["window_hours"].as_i64().unwrap_or(168);
|
||||
let delta_threshold = params["delta"].as_f64().unwrap_or(20.0);
|
||||
let direction = params["direction"].as_str().unwrap_or("up");
|
||||
|
||||
let since = Utc::now() - chrono::Duration::hours(window_hours);
|
||||
|
||||
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<alerts::Model> {
|
||||
let alert_id = Uuid::now_v7();
|
||||
let alert = alerts::ActiveModel {
|
||||
id: Set(alert_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
rule_id: Set(rule.id),
|
||||
severity: Set(rule.severity.clone()),
|
||||
title: Set(format!("{}触发", rule.name)),
|
||||
detail: Set(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)
|
||||
}
|
||||
135
crates/erp-health/src/service/alert_rule_service.rs
Normal file
135
crates/erp-health/src/service/alert_rule_service.rs
Normal file
@@ -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<alert_rules::Model>, 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<alert_rules::Model> {
|
||||
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<alert_rules::Model> {
|
||||
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<alert_rules::Model> {
|
||||
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?)
|
||||
}
|
||||
124
crates/erp-health/src/service/alert_service.rs
Normal file
124
crates/erp-health/src/service/alert_service.rs
Normal file
@@ -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<Uuid>,
|
||||
status: Option<&str>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<(Vec<alerts::Model>, u64)> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let mut query = alerts::Entity::find()
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(alerts::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(s) = status {
|
||||
validation::validate_alert_status(s)?;
|
||||
query = query.filter(alerts::Column::Status.eq(s));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(alerts::Column::CreatedAt)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn acknowledge_alert(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
alert_id: Uuid,
|
||||
user_id: Uuid,
|
||||
version: i32,
|
||||
) -> HealthResult<alerts::Model> {
|
||||
let alert = alerts::Entity::find_by_id(alert_id)
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::AlertNotFound)?;
|
||||
|
||||
validation::validate_alert_status_transition(&alert.status, "acknowledged")?;
|
||||
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<alerts::Model> {
|
||||
let alert = alerts::Entity::find_by_id(alert_id)
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::AlertNotFound)?;
|
||||
|
||||
validation::validate_alert_status_transition(&alert.status, "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<alerts::Model> {
|
||||
let alert = alerts::Entity::find_by_id(alert_id)
|
||||
.filter(alerts::Column::TenantId.eq(tenant_id))
|
||||
.filter(alerts::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::AlertNotFound)?;
|
||||
|
||||
validation::validate_alert_status_transition(&alert.status, "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?)
|
||||
}
|
||||
429
crates/erp-health/src/service/device_reading_service.rs
Normal file
429
crates/erp-health/src/service/device_reading_service.rs
Normal file
@@ -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<String>,
|
||||
pub readings: Vec<ReadingInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ReadingInput {
|
||||
pub device_type: String,
|
||||
pub values: serde_json::Value,
|
||||
pub measured_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct BatchResult {
|
||||
pub accepted: u64,
|
||||
pub duplicates: u64,
|
||||
pub earliest: Option<String>,
|
||||
pub latest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct DeviceReadingDto {
|
||||
pub id: Uuid,
|
||||
pub device_id: Option<String>,
|
||||
pub device_type: String,
|
||||
pub device_model: Option<String>,
|
||||
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<f64>,
|
||||
pub max_val: Option<f64>,
|
||||
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<BatchResult> {
|
||||
// 1. 校验患者存在
|
||||
let _patient = patient::Entity::find_by_id(patient_id)
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::PatientNotFound)?;
|
||||
|
||||
// 2. 校验/创建设备绑定
|
||||
ensure_device_binding(
|
||||
&state.db, tenant_id, patient_id,
|
||||
&req.device_id, req.device_model.as_deref(),
|
||||
).await?;
|
||||
|
||||
// 3. 解析 + 校验 readings
|
||||
let mut parsed_readings = Vec::with_capacity(req.readings.len().min(500));
|
||||
let mut earliest: Option<DateTime<Utc>> = None;
|
||||
let mut latest: Option<DateTime<Utc>> = None;
|
||||
|
||||
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<Utc> = 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<Utc>)],
|
||||
) -> HealthResult<u64> {
|
||||
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<Utc>)],
|
||||
) -> HealthResult<()> {
|
||||
// 按 (device_type, hour_start) 分组
|
||||
let mut groups: HashMap<(String, DateTime<Utc>), Vec<f64>> = 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::<f64>() / 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<f64> {
|
||||
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<i64>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<DeviceReadingDto>> {
|
||||
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<DeviceReadingDto> = 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<PaginatedResponse<HourlyReadingDto>> {
|
||||
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<HourlyReadingDto> = 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)),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()); }
|
||||
}
|
||||
|
||||
@@ -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<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, 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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 结束后复盘实际覆盖率,必要时调整计划。
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 页,设计规格确认 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user