fix(health): 精准审计修复 6 个真实问题 — 安全/一致性/性能
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

P0: consultation handler sender_role 从请求体移除,改为服务端推导(防伪造)
P1: 所有软删除操作统一使用 check_version 乐观锁(6个函数)
P1: 修复 health_trend 索引缺少 tenant_id 前导列 + follow_up_record 补 (tenant_id, executed_date) 索引
P2: Decimal->f64 使用 ToPrimitive::to_f64 替代脆弱的 to_string().parse()
P2: 预约取消释放槽位+状态更新包裹进同一事务
This commit is contained in:
iven
2026-04-24 08:36:22 +08:00
parent 6391a13467
commit a0ca156e2c
14 changed files with 136 additions and 26 deletions

1
Cargo.lock generated
View File

@@ -1281,6 +1281,7 @@ dependencies = [
"axum",
"chrono",
"erp-core",
"num-traits",
"sea-orm",
"serde",
"serde_json",

View File

@@ -17,3 +17,4 @@ thiserror.workspace = true
validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true
num-traits = "0.2.19"

View File

@@ -35,7 +35,6 @@ pub struct CloseSessionReq {
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateConsultationMessageReq {
pub session_id: Uuid,
pub sender_role: String,
pub content_type: Option<String>,
pub content: String,
}
@@ -135,7 +134,7 @@ where
let msg_req = CreateMessageReq {
session_id: req.session_id,
sender_id: ctx.user_id,
sender_role: req.sender_role,
sender_role: "doctor".to_string(),
content_type: req.content_type,
content: req.content,
};

View File

@@ -28,6 +28,11 @@ pub struct UpdateDoctorWithVersion {
pub version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {
pub version: i32,
}
pub async fn list_doctors<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -100,12 +105,13 @@ pub async fn delete_doctor<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.manage")?;
doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -36,6 +36,11 @@ pub struct UpdateFollowUpTaskWithVersion {
pub version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {
pub version: i32,
}
pub async fn list_tasks<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -95,13 +100,14 @@ pub async fn delete_task<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -34,6 +34,11 @@ pub struct GenerateTrendReq {
pub period_end: chrono::NaiveDate,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {
pub version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateWithVersion<T> {
pub data: T,
@@ -104,13 +109,14 @@ pub async fn delete_vital_signs<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path((_patient_id, vid)): Path<(Uuid, Uuid)>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id)).await?;
health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -178,13 +184,14 @@ pub async fn delete_lab_report<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path((_patient_id, rid)): Path<(Uuid, Uuid)>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?;
health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -252,13 +259,14 @@ pub async fn delete_health_record<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path((_patient_id, rid)): Path<(Uuid, Uuid)>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?;
health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -30,6 +30,11 @@ pub struct AssignDoctorReq {
pub relationship_type: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {
pub version: i32,
}
pub async fn list_patients<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -118,13 +123,14 @@ pub async fn delete_patient<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -219,6 +225,7 @@ pub async fn delete_family_member<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path((patient_id, member_id)): Path<(Uuid, Uuid)>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
@@ -226,7 +233,7 @@ where
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_family_member(
&state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id),
&state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id), req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))

View File

@@ -184,6 +184,8 @@ pub async fn update_appointment_status(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let txn = state.db.begin().await?;
// 取消时释放排班名额(带下限保护)
if req.status == "cancelled" {
if let Some(did) = model.doctor_id {
@@ -198,7 +200,7 @@ pub async fn update_appointment_status(
.filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date))
.filter(doctor_schedule::Column::DeletedAt.is_null())
.filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0))
.exec(&state.db)
.exec(&txn)
.await;
if let Err(e) = release_result {
tracing::error!(error = %e, "取消预约时释放排班名额失败");
@@ -213,7 +215,9 @@ pub async fn update_appointment_status(
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
let m = active.update(&txn).await?;
txn.commit().await?;
let event_type = format!("appointment.{}", m.status);
let event = DomainEvent::new(

View File

@@ -152,14 +152,17 @@ pub async fn delete_doctor(
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = find_doctor(&state.db, tenant_id, id).await?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: doctor_profile::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())

View File

@@ -164,6 +164,7 @@ pub async fn delete_task(
tenant_id: Uuid,
task_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = follow_up_task::Entity::find()
.filter(follow_up_task::Column::Id.eq(task_id))
@@ -173,11 +174,14 @@ pub async fn delete_task(
.await?
.ok_or(HealthError::FollowUpTaskNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: follow_up_task::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())
}

View File

@@ -2,6 +2,7 @@
use chrono::Utc;
use erp_core::events::DomainEvent;
use num_traits::cast::ToPrimitive;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -52,8 +53,8 @@ pub async fn list_vital_signs(
systolic_bp_evening: m.systolic_bp_evening,
diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)),
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml,
urine_output_ml: m.urine_output_ml,
notes: m.notes,
@@ -110,8 +111,8 @@ pub async fn create_vital_signs(
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)),
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
@@ -159,8 +160,8 @@ pub async fn update_vital_signs(
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)),
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
@@ -171,6 +172,7 @@ pub async fn delete_vital_signs(
tenant_id: Uuid,
vital_signs_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = vital_signs::Entity::find()
.filter(vital_signs::Column::Id.eq(vital_signs_id))
@@ -180,11 +182,14 @@ pub async fn delete_vital_signs(
.await?
.ok_or(HealthError::VitalSignsNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: vital_signs::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())
}
@@ -321,6 +326,7 @@ pub async fn delete_lab_report(
tenant_id: Uuid,
report_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = lab_report::Entity::find()
.filter(lab_report::Column::Id.eq(report_id))
@@ -330,11 +336,14 @@ pub async fn delete_lab_report(
.await?
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: lab_report::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())
}
@@ -468,6 +477,7 @@ pub async fn delete_health_record(
tenant_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = health_record::Entity::find()
.filter(health_record::Column::Id.eq(record_id))
@@ -477,11 +487,14 @@ pub async fn delete_health_record(
.await?
.ok_or(HealthError::HealthRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: health_record::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())
}
@@ -605,8 +618,8 @@ pub async fn get_indicator_timeseries(
let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| {
let val = match indicator.as_str() {
"heart_rate" => v.heart_rate.map(|x| x as f64),
"weight" => v.weight.map(|d| d.to_string().parse().unwrap_or(0.0)),
"blood_sugar" => v.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)),
"weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)),
"blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
"systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64),
"diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64),
"systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64),

View File

@@ -229,14 +229,17 @@ pub async fn delete_patient(
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = find_patient(&state.db, tenant_id, id).await?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: patient::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())
@@ -498,6 +501,7 @@ pub async fn delete_family_member(
patient_id: Uuid,
family_member_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = patient_family_member::Entity::find()
.filter(patient_family_member::Column::Id.eq(family_member_id))
@@ -508,11 +512,14 @@ pub async fn delete_family_member(
.await?
.ok_or(HealthError::FamilyMemberNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: patient_family_member::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.version = Set(next_ver);
active.update(&state.db).await?;
Ok(())

View File

@@ -46,6 +46,7 @@ mod m20260423_000043_create_wechat_users;
mod m20260423_000044_create_articles;
mod m20260424_000045_health_indexes;
mod m20260424_000046_health_constraints_fix;
mod m20260424_000047_health_index_fix;
pub struct Migrator;
@@ -99,6 +100,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260423_000044_create_articles::Migration),
Box::new(m20260424_000045_health_indexes::Migration),
Box::new(m20260424_000046_health_constraints_fix::Migration),
Box::new(m20260424_000047_health_index_fix::Migration),
]
}
}

View File

@@ -0,0 +1,49 @@
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> {
let db = manager.get_connection();
// 删除旧索引(缺少 tenant_id 前导列)
db.execute_unprepared(
"DROP INDEX IF EXISTS idx_health_trend_patient_period"
).await?;
// 重建为包含 tenant_id 的正确索引
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_health_trend_tenant_patient_period \
ON health_trend (tenant_id, patient_id, period_start DESC)"
).await?;
// 添加 follow_up_record 缺失的 (tenant_id, executed_date) 索引
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_follow_up_record_tenant_executed_date \
ON follow_up_record (tenant_id, executed_date DESC)"
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
"DROP INDEX IF EXISTS idx_health_trend_tenant_patient_period"
).await?;
db.execute_unprepared(
"DROP INDEX IF EXISTS idx_follow_up_record_tenant_executed_date"
).await?;
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_health_trend_patient_period \
ON health_trend (patient_id, period_start)"
).await?;
Ok(())
}
}