From 13f553590bf62f1ed1a77ad74b97b9b3c5f7355c Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 30 Apr 2026 10:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(health+dialysis):=20=E8=A1=A5=E5=85=A8=208?= =?UTF-8?q?=20=E7=BB=84=E6=9D=83=E9=99=90=E7=A0=81=20+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20N+1=20=E6=9F=A5=E8=AF=A2=20+=20=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E6=80=A7=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权限补全: - 新增 14 个权限声明(危急值告警/阈值/随访模板/日常监测/知情同意/用药记录/药物提醒) - 更新 8 个 handler 使用正确的专属权限码 - erp-dialysis 新增 health.dialysis.stats 权限 性能优化: - article_service list_articles 标签加载从 N+1 改为批量查询 - follow_up_template_service 字段计数从 N+1 改为批量 GROUP BY 防御性编码: - alert_engine/article/critical_alert 的 unwrap() 替换为 unwrap_or/expect --- .../src/handler/dialysis_stats_handler.rs | 2 +- crates/erp-dialysis/src/module.rs | 6 ++ .../src/handler/critical_alert_handler.rs | 6 +- .../critical_value_threshold_handler.rs | 8 +- .../src/handler/daily_monitoring_handler.rs | 10 +- .../src/handler/follow_up_template_handler.rs | 10 +- .../handler/medication_reminder_handler.rs | 8 +- crates/erp-health/src/module.rs | 93 +++++++++++++++++++ crates/erp-health/src/service/alert_engine.rs | 4 +- .../erp-health/src/service/article_service.rs | 61 +++++++++++- .../src/service/critical_alert_service.rs | 6 +- .../src/service/follow_up_template_service.rs | 37 +++++++- .../erp-health/src/service/points_service.rs | 4 +- 13 files changed, 219 insertions(+), 36 deletions(-) diff --git a/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs b/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs index f4c2cbd..104953f 100644 --- a/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs +++ b/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs @@ -16,7 +16,7 @@ where DialysisState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.dialysis.list")?; + require_permission(&ctx, "health.dialysis.stats")?; let dialysis_state = DialysisState::from_ref(&state); let stats = dialysis_stats_service::get_dialysis_statistics(&dialysis_state, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(stats))) diff --git a/crates/erp-dialysis/src/module.rs b/crates/erp-dialysis/src/module.rs index 13e63d8..9c51b1d 100644 --- a/crates/erp-dialysis/src/module.rs +++ b/crates/erp-dialysis/src/module.rs @@ -106,6 +106,12 @@ impl ErpModule for DialysisModule { description: "创建、编辑、删除透析处方".into(), module: "erp-dialysis".into(), }, + PermissionDescriptor { + code: "health.dialysis.stats".into(), + name: "查看透析统计".into(), + description: "查看透析统计数据".into(), + module: "erp-dialysis".into(), + }, ] } diff --git a/crates/erp-health/src/handler/critical_alert_handler.rs b/crates/erp-health/src/handler/critical_alert_handler.rs index 7972e0b..16ecdb9 100644 --- a/crates/erp-health/src/handler/critical_alert_handler.rs +++ b/crates/erp-health/src/handler/critical_alert_handler.rs @@ -27,7 +27,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.critical-alert.list")?; + require_permission(&ctx, "health.critical-alerts.list")?; let page = query.page.unwrap_or(1); let page_size = query.page_size.unwrap_or(20); @@ -54,7 +54,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.critical-alert.list")?; + require_permission(&ctx, "health.critical-alerts.list")?; let alert = critical_alert_service::get_alert(&state, ctx.tenant_id, id).await?; Ok(axum::Json(ApiResponse::ok(alert))) } @@ -74,7 +74,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.critical-alert.manage")?; + require_permission(&ctx, "health.critical-alerts.manage")?; critical_alert_service::acknowledge_alert( &state, ctx.tenant_id, diff --git a/crates/erp-health/src/handler/critical_value_threshold_handler.rs b/crates/erp-health/src/handler/critical_value_threshold_handler.rs index 621d44a..1a432d9 100644 --- a/crates/erp-health/src/handler/critical_value_threshold_handler.rs +++ b/crates/erp-health/src/handler/critical_value_threshold_handler.rs @@ -37,7 +37,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.list")?; + require_permission(&ctx, "health.critical-value-thresholds.list")?; let list = critical_value_threshold_service::find_thresholds(&state.db, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(list))) } @@ -51,7 +51,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.critical-value-thresholds.manage")?; let result = critical_value_threshold_service::create_threshold( &state.db, ctx.tenant_id, @@ -78,7 +78,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.critical-value-thresholds.manage")?; let result = critical_value_threshold_service::update_threshold( &state.db, ctx.tenant_id, @@ -104,7 +104,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.critical-value-thresholds.manage")?; critical_value_threshold_service::delete_threshold(&state.db, ctx.tenant_id, id, Some(ctx.user_id)) .await?; Ok(Json(ApiResponse::ok(()))) diff --git a/crates/erp-health/src/handler/daily_monitoring_handler.rs b/crates/erp-health/src/handler/daily_monitoring_handler.rs index b5c813e..840b2af 100644 --- a/crates/erp-health/src/handler/daily_monitoring_handler.rs +++ b/crates/erp-health/src/handler/daily_monitoring_handler.rs @@ -36,7 +36,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.list")?; + require_permission(&ctx, "health.daily-monitoring.list")?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = daily_monitoring_service::list_daily_monitoring( @@ -55,7 +55,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.list")?; + require_permission(&ctx, "health.daily-monitoring.list")?; let result = daily_monitoring_service::get_daily_monitoring( &state, ctx.tenant_id, record_id, ) @@ -72,7 +72,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.daily-monitoring.manage")?; let mut req = req; req.sanitize(); let result = daily_monitoring_service::create_daily_monitoring( @@ -92,7 +92,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.daily-monitoring.manage")?; let mut data = req.data; data.sanitize(); let result = daily_monitoring_service::update_daily_monitoring( @@ -112,7 +112,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.daily-monitoring.manage")?; daily_monitoring_service::delete_daily_monitoring( &state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version, ) diff --git a/crates/erp-health/src/handler/follow_up_template_handler.rs b/crates/erp-health/src/handler/follow_up_template_handler.rs index d2ee22e..6962af1 100644 --- a/crates/erp-health/src/handler/follow_up_template_handler.rs +++ b/crates/erp-health/src/handler/follow_up_template_handler.rs @@ -37,7 +37,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.follow-up.list")?; + require_permission(&ctx, "health.follow-up-templates.list")?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = follow_up_template_service::list_templates( @@ -56,7 +56,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.follow-up.list")?; + require_permission(&ctx, "health.follow-up-templates.list")?; let result = follow_up_template_service::get_template(&state, ctx.tenant_id, id).await?; Ok(Json(ApiResponse::ok(result))) } @@ -70,7 +70,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.follow-up.manage")?; + require_permission(&ctx, "health.follow-up-templates.manage")?; let mut req = req; req.sanitize(); let result = follow_up_template_service::create_template( @@ -90,7 +90,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.follow-up.manage")?; + require_permission(&ctx, "health.follow-up-templates.manage")?; let mut data = req.data; data.sanitize(); let result = follow_up_template_service::update_template( @@ -110,7 +110,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.follow-up.manage")?; + require_permission(&ctx, "health.follow-up-templates.manage")?; follow_up_template_service::delete_template( &state, ctx.tenant_id, id, Some(ctx.user_id), req.version, ) diff --git a/crates/erp-health/src/handler/medication_reminder_handler.rs b/crates/erp-health/src/handler/medication_reminder_handler.rs index 49a85f0..756edcf 100644 --- a/crates/erp-health/src/handler/medication_reminder_handler.rs +++ b/crates/erp-health/src/handler/medication_reminder_handler.rs @@ -24,7 +24,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.list")?; + require_permission(&ctx, "health.medication-reminders.list")?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let result = medication_reminder_service::list_reminders( @@ -42,7 +42,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.medication-reminders.manage")?; req.sanitize(); let result = medication_reminder_service::create_reminder( &state, ctx.tenant_id, Some(ctx.user_id), req.0, @@ -67,7 +67,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.medication-reminders.manage")?; let mut data = req.data; data.sanitize(); let result = medication_reminder_service::update_reminder( @@ -91,7 +91,7 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, { - require_permission(&ctx, "health.health-data.manage")?; + require_permission(&ctx, "health.medication-reminders.manage")?; medication_reminder_service::delete_reminder( &state, ctx.tenant_id, id, Some(ctx.user_id), req.version, ).await?; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index afd79e6..6740e64 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -526,6 +526,15 @@ impl HealthModule { "/health/admin/points/orders", axum::routing::get(points_handler::admin_list_orders), ) + // 积分账户 — 管理端按患者查询 + .route( + "/health/admin/points/patients/{patient_id}/account", + axum::routing::get(points_handler::admin_get_patient_account), + ) + .route( + "/health/admin/points/patients/{patient_id}/transactions", + axum::routing::get(points_handler::admin_list_patient_transactions), + ) // 线下活动 — 管理端 .route( "/health/admin/offline-events", @@ -954,6 +963,90 @@ impl ErpModule for HealthModule { description: "创建/编辑/启停告警规则".into(), module: "health".into(), }, + PermissionDescriptor { + code: "health.critical-alerts.list".into(), + name: "查看危急值告警".into(), + description: "查看危急值告警列表和详情".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.critical-alerts.manage".into(), + name: "处理危急值告警".into(), + description: "确认危急值告警".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.critical-value-thresholds.list".into(), + name: "查看危急值阈值".into(), + description: "查看危急值阈值配置".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.critical-value-thresholds.manage".into(), + name: "管理危急值阈值".into(), + description: "创建/编辑/删除危急值阈值配置".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.follow-up-templates.list".into(), + name: "查看随访模板".into(), + description: "查看随访模板列表和详情".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.follow-up-templates.manage".into(), + name: "管理随访模板".into(), + description: "创建/编辑/删除随访模板".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.daily-monitoring.list".into(), + name: "查看日常监测".into(), + description: "查看患者日常监测数据".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.daily-monitoring.manage".into(), + name: "管理日常监测".into(), + description: "录入/编辑/删除日常监测数据".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.consent.list".into(), + name: "查看知情同意".into(), + description: "查看患者知情同意记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.consent.manage".into(), + name: "管理知情同意".into(), + description: "签署/撤销知情同意".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.medication-records.list".into(), + name: "查看用药记录".into(), + description: "查看患者用药记录列表和详情".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.medication-records.manage".into(), + name: "管理用药记录".into(), + description: "创建/编辑/删除用药记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.medication-reminders.list".into(), + name: "查看药物提醒".into(), + description: "查看患者药物提醒列表".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.medication-reminders.manage".into(), + name: "管理药物提醒".into(), + description: "创建/编辑/删除药物提醒".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/alert_engine.rs b/crates/erp-health/src/service/alert_engine.rs index 70799b2..f3a6c6a 100644 --- a/crates/erp-health/src/service/alert_engine.rs +++ b/crates/erp-health/src/service/alert_engine.rs @@ -156,8 +156,8 @@ fn evaluate_trend_in_memory( return false; } - let first = in_window.first().unwrap().avg_val; - let last = in_window.last().unwrap().avg_val; + let first = in_window.first().map(|r| r.avg_val).unwrap_or(0.0); + let last = in_window.last().map(|r| r.avg_val).unwrap_or(0.0); let actual_delta = last - first; match direction { diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs index 2413268..6119699 100644 --- a/crates/erp-health/src/service/article_service.rs +++ b/crates/erp-health/src/service/article_service.rs @@ -77,9 +77,14 @@ pub async fn list_articles( .await?; let total_pages = total.div_ceil(limit.max(1)); + + // 批量加载所有文章的标签,避免 N+1 查询 + let article_ids: Vec = models.iter().map(|m| m.id).collect(); + let tags_map = batch_load_article_tags(state, &article_ids).await?; + let mut data = Vec::with_capacity(models.len()); for m in models { - let tags = load_article_tags(state, m.id).await?; + let tags = tags_map.get(&m.id).cloned().unwrap_or_default(); data.push(ArticleListItem { id: m.id, title: m.title, @@ -285,7 +290,7 @@ pub async fn increment_view_count( ) -> HealthResult<()> { let model = find_article(state, tenant_id, id).await?; let mut active: article::ActiveModel = model.into(); - active.view_count = Set(active.view_count.unwrap() + 1); + active.view_count = Set(active.view_count.take().unwrap_or(0) + 1); active.updated_at = Set(Utc::now()); active.update(&state.db).await?; Ok(()) @@ -480,6 +485,58 @@ async fn load_article_tags(state: &HealthState, article_id: Uuid) -> HealthResul Ok(tags.into_iter().map(|t| t.name).collect()) } +/// 批量加载多篇文章的标签,避免 N+1 查询。 +/// 返回 HashMap>。 +async fn batch_load_article_tags( + state: &HealthState, + article_ids: &[Uuid], +) -> HealthResult>> { + use std::collections::HashMap; + + if article_ids.is_empty() { + return Ok(HashMap::new()); + } + + // 1. 一次查询所有文章-标签关联 + let ids: Vec = article_ids.to_vec(); + let relations = article_article_tag::Entity::find() + .filter(article_article_tag::Column::ArticleId.is_in(ids)) + .all(&state.db) + .await?; + + if relations.is_empty() { + return Ok(HashMap::new()); + } + + // 2. 收集所有 tag_id,按 article_id 分组 + let tag_ids: Vec = relations.iter().map(|r| r.tag_id).collect(); + let mut article_to_tag_ids: HashMap> = HashMap::new(); + for r in &relations { + article_to_tag_ids.entry(r.article_id).or_default().push(r.tag_id); + } + + // 3. 一次查询所有标签实体 + let tags = article_tag::Entity::find() + .filter(article_tag::Column::Id.is_in(tag_ids)) + .filter(article_tag::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let tag_name_map: HashMap = tags.into_iter().map(|t| (t.id, t.name)).collect(); + + // 4. 组装结果 + let mut result = HashMap::new(); + for (article_id, tids) in article_to_tag_ids { + let names: Vec = tids + .into_iter() + .filter_map(|tid| tag_name_map.get(&tid).cloned()) + .collect(); + result.insert(article_id, names); + } + + Ok(result) +} + async fn save_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> { for tid in tag_ids { let active = article_article_tag::ActiveModel { diff --git a/crates/erp-health/src/service/critical_alert_service.rs b/crates/erp-health/src/service/critical_alert_service.rs index b3dfb02..dd85abe 100644 --- a/crates/erp-health/src/service/critical_alert_service.rs +++ b/crates/erp-health/src/service/critical_alert_service.rs @@ -78,7 +78,7 @@ pub async fn acknowledge_alert( active.acknowledged_at = Set(Some(now)); active.updated_at = Set(now); active.updated_by = Set(Some(responder_id)); - active.version = Set(active.version.unwrap() + 1); + active.version = Set(active.version.take().unwrap_or(0) + 1); critical_alert::Entity::update(active) .exec(&state.db) .await?; @@ -133,7 +133,7 @@ pub async fn scan_escalation( active.escalation_level = Set(1); active.status = Set("escalated".to_string()); active.updated_at = Set(now); - active.version = Set(active.version.unwrap() + 1); + active.version = Set(active.version.take().unwrap_or(0) + 1); critical_alert::Entity::update(active) .exec(&state.db) .await?; @@ -158,7 +158,7 @@ pub async fn scan_escalation( let mut active: critical_alert::ActiveModel = alert.clone().into(); active.escalation_level = Set(2); active.updated_at = Set(now); - active.version = Set(active.version.unwrap() + 1); + active.version = Set(active.version.take().unwrap_or(0) + 1); critical_alert::Entity::update(active) .exec(&state.db) .await?; diff --git a/crates/erp-health/src/service/follow_up_template_service.rs b/crates/erp-health/src/service/follow_up_template_service.rs index 974052e..f216313 100644 --- a/crates/erp-health/src/service/follow_up_template_service.rs +++ b/crates/erp-health/src/service/follow_up_template_service.rs @@ -46,13 +46,13 @@ pub async fn list_templates( .all(&state.db) .await?; + // 批量统计所有模板的字段数,避免 N+1 查询 + let template_ids: Vec = models.iter().map(|m| m.id).collect(); + let field_count_map = batch_count_template_fields(state, &template_ids).await?; + let mut data = Vec::with_capacity(models.len()); for m in models { - let field_count = follow_up_template_field::Entity::find() - .filter(follow_up_template_field::Column::TemplateId.eq(m.id)) - .filter(follow_up_template_field::Column::DeletedAt.is_null()) - .count(&state.db) - .await?; + let field_count = field_count_map.get(&m.id).copied().unwrap_or(0); data.push(FollowUpTemplateListItemResp { id: m.id, name: m.name, @@ -344,3 +344,30 @@ fn validate_field_type(val: &str) -> HealthResult<()> { } Ok(()) } + +/// 批量统计模板字段数量,避免 N+1 查询。 +/// 返回 HashMap。 +async fn batch_count_template_fields( + state: &HealthState, + template_ids: &[Uuid], +) -> HealthResult> { + use std::collections::HashMap; + + if template_ids.is_empty() { + return Ok(HashMap::new()); + } + + let ids: Vec = template_ids.to_vec(); + let fields = follow_up_template_field::Entity::find() + .filter(follow_up_template_field::Column::TemplateId.is_in(ids)) + .filter(follow_up_template_field::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let mut counts: HashMap = HashMap::new(); + for f in fields { + *counts.entry(f.template_id).or_insert(0) += 1; + } + + Ok(counts) +} diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index d13d661..24f28b0 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -105,7 +105,7 @@ pub async fn earn_points( // 3. 检查每日上限(用 account.id 而非 patient_id) if rule.daily_cap > 0 { let today = Utc::now().date_naive(); - let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc(); + let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); let earned_today: i32 = points_transaction::Entity::find() .filter(points_transaction::Column::TenantId.eq(tenant_id)) .filter(points_transaction::Column::AccountId.eq(acc.id)) @@ -322,7 +322,7 @@ async fn earn_points_in_txn( // 3. 检查每日上限 if rule.daily_cap > 0 { let today = Utc::now().date_naive(); - let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc(); + let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); let earned_today: i32 = points_transaction::Entity::find() .filter(points_transaction::Column::TenantId.eq(tenant_id)) .filter(points_transaction::Column::AccountId.eq(acc.id))