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:
iven
2026-04-27 00:21:02 +08:00
parent 8cd65f7be5
commit 5b81a0051f
33 changed files with 2380 additions and 82 deletions

View 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,
}

View File

@@ -1,4 +1,5 @@
pub mod appointment_dto;
pub mod alert_dto;
pub mod article_dto;
pub mod consent_dto;
pub mod consultation_dto;

View 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 {}

View 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 {}

View 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 {}

View File

@@ -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;

View 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 {}

View 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 {}

View File

@@ -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,

View File

@@ -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,
}
}
});
}

View 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)))
}

View 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)))
}

View 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)))
}

View File

@@ -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;

View File

@@ -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(),
},
]
}

View 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)
}

View 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?)
}

View 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?)
}

View 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)),
})
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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()); }
}

View File

@@ -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;

View File

@@ -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),
]
}
}

View File

@@ -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 SQLSeaORM 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(())
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,
}

View File

@@ -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** — 封装完整测试环境:
- TestDbPostgreSQL Testcontainer + 自动迁移)
- Axum Router与生产配置相同的路由
- AppStateDatabaseConnection + EventBus + Crypto
- HTTP 客户端(`reqwest::Client` `tower::ServiceExt`
- TestDb本地 PostgreSQL 隔离数据库 + 自动迁移)
- Axum Router仅注册 erp-health 模块,减少启动开销
- AppStateDatabaseConnection + 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: 高风险 ServiceWeek 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: 中风险 ServiceWeek 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 speclogin/users/plugins/tenant-isolation10 个测试。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 19 周)的目标是后端 80% + 前端 60%,前端剩余部分在后续迭代中持续补齐。每 Phase 结束后复盘实际覆盖率,必要时调整计划。

View File

@@ -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_versiondialysis_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) |

View File

@@ -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 页,设计规格确认 |

View File

@@ -6,18 +6,19 @@
| 指标 | 值 |
|------|-----|
| Rust crate | 16erp-core + 6 基础业务 + erp-health + erp-ai + 6 插件 + erp-plugin-prototype |
| 数据库表 | 30 基础表 + 22 健康业务表 + 3 AI 表(已实现) |
| 数据库迁移 | 55 个 |
| Rust crate | 15erp-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 前端页面 | 66TSX 组件(含 19 健康管理页面 |
| 健康模块组件 | 12 个共享组件StatusTag/PatientSelect/DoctorSelect/VitalSignsChart 等 |
| 微信小程序 | Taro 4.2 + React 1827 个页面 |
| erp-health 实体 | 34Entity17k 行 Rust |
| erp-ai 实体 | 3 个 Entity1.7k 行 Rust |
| Web 前端 | 77 个 TSX + 56 个 TS = 133 个源文件48 个页面 + 22 健康页面 + 11 健康组件) |
| 微信小程序 | Taro 4.2 + React 1812 个页面 |
| 前端单元测试 | 3 个vitest+ 4 E2E specplaywright |
| 后端测试 | 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` |