feat(health+dialysis): 补全 8 组权限码 + 修复 N+1 查询 + 防御性编码
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

权限补全:
- 新增 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
This commit is contained in:
iven
2026-04-30 10:22:14 +08:00
parent 931edc3025
commit 13f553590b
13 changed files with 219 additions and 36 deletions

View File

@@ -16,7 +16,7 @@ where
DialysisState: FromRef<S>,
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)))

View File

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

View File

@@ -27,7 +27,7 @@ where
HealthState: FromRef<S>,
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>,
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>,
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,

View File

@@ -37,7 +37,7 @@ where
HealthState: FromRef<S>,
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>,
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>,
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>,
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(())))

View File

@@ -36,7 +36,7 @@ where
HealthState: FromRef<S>,
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>,
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>,
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>,
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>,
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,
)

View File

@@ -37,7 +37,7 @@ where
HealthState: FromRef<S>,
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>,
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>,
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>,
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>,
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,
)

View File

@@ -24,7 +24,7 @@ where
HealthState: FromRef<S>,
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>,
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>,
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>,
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?;

View File

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

View File

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

View File

@@ -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<Uuid> = 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<article_id, Vec<tag_name>>。
async fn batch_load_article_tags(
state: &HealthState,
article_ids: &[Uuid],
) -> HealthResult<std::collections::HashMap<Uuid, Vec<String>>> {
use std::collections::HashMap;
if article_ids.is_empty() {
return Ok(HashMap::new());
}
// 1. 一次查询所有文章-标签关联
let ids: Vec<Uuid> = 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<Uuid> = relations.iter().map(|r| r.tag_id).collect();
let mut article_to_tag_ids: HashMap<Uuid, Vec<Uuid>> = 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<Uuid, String> = 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<String> = 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 {

View File

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

View File

@@ -46,13 +46,13 @@ pub async fn list_templates(
.all(&state.db)
.await?;
// 批量统计所有模板的字段数,避免 N+1 查询
let template_ids: Vec<Uuid> = 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<template_id, count>。
async fn batch_count_template_fields(
state: &HealthState,
template_ids: &[Uuid],
) -> HealthResult<std::collections::HashMap<Uuid, u64>> {
use std::collections::HashMap;
if template_ids.is_empty() {
return Ok(HashMap::new());
}
let ids: Vec<Uuid> = 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<Uuid, u64> = HashMap::new();
for f in fields {
*counts.entry(f.template_id).or_insert(0) += 1;
}
Ok(counts)
}

View File

@@ -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<C: sea_orm::ConnectionTrait>(
// 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))