fix(health): 四次审计修复 — 6 CRITICAL + 8 HIGH + 4 MEDIUM
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

CRITICAL:
- C-1: consultation sender_id 改为从 JWT ctx.user_id 注入,防伪造
- C-2: consultation session 更新改为 CAS 原子操作,防并发丢失
- C-3: 随访记录创建包裹在事务中,保证记录/任务/后续任务一致性
- C-4/C-5/C-6: 唯一索引改为 partial index WHERE deleted_at IS NULL

HIGH:
- H-1: manage_patient_tags 添加 tag_ids 租户归属校验
- H-2: assign_doctor 添加重复关联检查
- H-3: calendar_view 限制日期范围最多 90 天
- H-4: export_sessions 添加 10000 条上限
- H-5: patient_tag_relation/patient_doctor_relation 添加 version 字段
- H-6: create_schedule 添加医生存在性检查
- H-7: 预约取消排班释放错误改为日志记录
- H-8: follow_up_task.related_appointment_id 添加 FK 约束

MEDIUM:
- M-2: 修复 search LIKE 双重 % 包裹问题
- M-3: article_service 错误类型改为 ArticleNotFound
- M-4: patient.created 事件移除 PII(姓名)
- M-6: lab_report 添加 (tenant_id, report_type) 索引
This commit is contained in:
iven
2026-04-24 07:50:14 +08:00
parent 7b7677dfec
commit 4867202437
11 changed files with 191 additions and 29 deletions

View File

@@ -18,6 +18,7 @@ pub struct Model {
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)]

View File

@@ -17,6 +17,7 @@ pub struct Model {
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)]

View File

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

View File

@@ -187,7 +187,7 @@ pub async fn update_appointment_status(
// 取消时释放排班名额(带下限保护)
if req.status == "cancelled" {
if let Some(did) = model.doctor_id {
let _ = doctor_schedule::Entity::update_many()
let release_result = doctor_schedule::Entity::update_many()
.col_expr(
doctor_schedule::Column::CurrentAppointments,
Expr::col(doctor_schedule::Column::CurrentAppointments).sub(1),
@@ -200,6 +200,9 @@ pub async fn update_appointment_status(
.filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0))
.exec(&state.db)
.await;
if let Err(e) = release_result {
tracing::error!(error = %e, "取消预约时释放排班名额失败");
}
}
}
@@ -279,6 +282,16 @@ pub async fn create_schedule(
let now = Utc::now();
let period_type = req.period_type.unwrap_or_else(|| "am".to_string());
validate_period_type(&period_type)?;
// H-6: 校验医生存在
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.eq(req.doctor_id))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.filter(doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DoctorNotFound)?;
let active = doctor_schedule::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
@@ -356,6 +369,12 @@ pub async fn calendar_view(
end_date: chrono::NaiveDate,
doctor_id: Option<Uuid>,
) -> HealthResult<Vec<CalendarDayResp>> {
// H-3: 限制日期范围跨度最多 90 天
let max_span = chrono::Duration::days(90);
if end_date - start_date > max_span {
return Err(HealthError::Validation("日历查询范围不能超过 90 天".to_string()));
}
let mut query = doctor_schedule::Entity::find()
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
.filter(doctor_schedule::Column::DeletedAt.is_null())

View File

@@ -65,7 +65,7 @@ pub async fn get_article(
.filter(article::Column::PublishedAt.is_not_null())
.one(&state.db)
.await?
.ok_or(HealthError::HealthRecordNotFound)?;
.ok_or(HealthError::ArticleNotFound)?;
Ok(model_to_resp(model))
}

View File

@@ -181,6 +181,7 @@ pub async fn export_sessions(
let models = query
.order_by_desc(consultation_session::Column::CreatedAt)
.limit(10000)
.all(&state.db)
.await?;
@@ -278,20 +279,34 @@ pub async fn create_message(
let m = active.insert(&state.db).await?;
// 更新会话的 last_message_at 和未读计数waiting→active 自动触发
let mut session_active: consultation_session::ActiveModel = session.into();
session_active.last_message_at = Set(Some(now));
// 使用 CAS 防止并发发消息时丢失 unread_count 更新
let expected_version = session.version;
let mut cas = consultation_session::Entity::update_many()
.col_expr(consultation_session::Column::LastMessageAt, Expr::value(Some(now)))
.col_expr(consultation_session::Column::UpdatedAt, Expr::value(now))
.col_expr(consultation_session::Column::Version, Expr::col(consultation_session::Column::Version).add(1))
.filter(consultation_session::Column::Id.eq(req.session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::Version.eq(expected_version));
if should_activate {
session_active.status = Set("active".to_string());
cas = cas.col_expr(consultation_session::Column::Status, Expr::value("active".to_string()));
}
// 根据发送者角色更新对方的 unread_count
if is_patient {
session_active.unread_count_doctor = Set(session_active.unread_count_doctor.unwrap() + 1);
cas = cas.col_expr(
consultation_session::Column::UnreadCountDoctor,
Expr::col(consultation_session::Column::UnreadCountDoctor).add(1),
);
} else {
session_active.unread_count_patient = Set(session_active.unread_count_patient.unwrap() + 1);
cas = cas.col_expr(
consultation_session::Column::UnreadCountPatient,
Expr::col(consultation_session::Column::UnreadCountPatient).add(1),
);
}
let cas_result = cas.exec(&state.db).await?;
if cas_result.rows_affected == 0 {
return Err(HealthError::VersionMismatch);
}
session_active.updated_at = Set(now);
session_active.version = Set(session_active.version.unwrap() + 1);
session_active.update(&state.db).await?;
Ok(MessageResp {
id: m.id, session_id: m.session_id, sender_id: m.sender_id,

View File

@@ -31,13 +31,12 @@ pub async fn list_doctors(
.filter(doctor_profile::Column::DeletedAt.is_null());
if let Some(ref s) = search {
let pattern = format!("%{}%", s);
query = query.filter(
Condition::any()
.add(doctor_profile::Column::Name.contains(&pattern))
.add(doctor_profile::Column::LicenseNumber.contains(&pattern))
.add(doctor_profile::Column::Department.contains(&pattern))
.add(doctor_profile::Column::Specialty.contains(&pattern)),
.add(doctor_profile::Column::Name.contains(s))
.add(doctor_profile::Column::LicenseNumber.contains(s))
.add(doctor_profile::Column::Department.contains(s))
.add(doctor_profile::Column::Specialty.contains(s)),
);
}
if let Some(ref d) = department {

View File

@@ -3,7 +3,7 @@
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait};
use uuid::Uuid;
use erp_core::error::check_version;
@@ -206,6 +206,9 @@ pub async fn create_record(
let now = Utc::now();
// 事务包裹:插入记录 + 更新任务状态 + 创建后续任务
let txn = state.db.begin().await?;
let record_active = follow_up_record::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
@@ -223,17 +226,17 @@ pub async fn create_record(
deleted_at: Set(None),
version: Set(1),
};
let record = record_active.insert(&state.db).await?;
let record = record_active.insert(&txn).await?;
let task_patient_id = task.patient_id;
let task_assigned_to = task.assigned_to;
let task_follow_up_type = task.follow_up_type.clone();
let mut task_active: follow_up_task::ActiveModel = task.into();
let task_patient_id = task_active.patient_id.clone().unwrap();
let task_assigned_to = task_active.assigned_to.clone().unwrap();
let task_follow_up_type = task_active.follow_up_type.clone().unwrap();
task_active.status = Set("completed".to_string());
task_active.updated_at = Set(now);
task_active.updated_by = Set(operator_id);
task_active.version = Set(task_active.version.unwrap() + 1);
task_active.update(&state.db).await?;
task_active.update(&txn).await?;
// 当 next_follow_up_date 不为空时,自动创建后续随访任务
if let Some(next_date) = req.next_follow_up_date {
@@ -254,9 +257,11 @@ pub async fn create_record(
deleted_at: Set(None),
version: Set(1),
};
new_task.insert(&state.db).await?;
new_task.insert(&txn).await?;
}
txn.commit().await?;
let event = DomainEvent::new(
"follow_up.completed",
tenant_id,

View File

@@ -12,6 +12,7 @@ use erp_core::types::PaginatedResponse;
use crate::dto::patient_dto::*;
use crate::entity::patient;
use crate::entity::patient_family_member;
use crate::entity::patient_tag;
use crate::entity::patient_tag_relation;
use crate::entity::patient_doctor_relation;
use crate::error::{HealthError, HealthResult};
@@ -52,11 +53,10 @@ pub async fn list_patients(
.filter(patient::Column::DeletedAt.is_null());
if let Some(ref search) = search {
let pattern = format!("%{}%", search);
query = query.filter(
Condition::any()
.add(patient::Column::Name.contains(&pattern))
.add(patient::Column::IdNumber.contains(&pattern)),
.add(patient::Column::Name.contains(search))
.add(patient::Column::IdNumber.contains(search)),
);
}
@@ -131,7 +131,7 @@ pub async fn create_patient(
let event = DomainEvent::new(
"patient.created",
tenant_id,
serde_json::json!({ "patient_id": model.id, "name": model.name }),
serde_json::json!({ "patient_id": model.id }),
);
state.event_bus.publish(event, &state.db).await;
@@ -257,6 +257,19 @@ pub async fn manage_patient_tags(
// 确认患者存在
find_patient(&state.db, tenant_id, patient_id).await?;
// H-1: 校验所有 tag_ids 属于当前租户
if !req.tag_ids.is_empty() {
let valid_count = patient_tag::Entity::find()
.filter(patient_tag::Column::TenantId.eq(tenant_id))
.filter(patient_tag::Column::Id.is_in(req.tag_ids.iter().copied()))
.filter(patient_tag::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
if valid_count != req.tag_ids.len() as u64 {
return Err(HealthError::Validation("部分标签不存在或不属于当前租户".to_string()));
}
}
let now = Utc::now();
// 软删除旧的关联
@@ -287,6 +300,7 @@ pub async fn manage_patient_tags(
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
rel.insert(&state.db).await?;
}
@@ -519,6 +533,18 @@ pub async fn assign_doctor(
) -> HealthResult<()> {
find_patient(&state.db, tenant_id, patient_id).await?;
// H-2: 检查是否已存在相同的未删除关联
let existing = patient_doctor_relation::Entity::find()
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
.filter(patient_doctor_relation::Column::PatientId.eq(patient_id))
.filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id))
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
if existing.is_some() {
return Err(HealthError::Validation("该医生已关联此患者".to_string()));
}
let now = Utc::now();
let active = patient_doctor_relation::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -531,6 +557,7 @@ pub async fn assign_doctor(
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
active.insert(&state.db).await?;
Ok(())

View File

@@ -45,6 +45,7 @@ mod m20260423_000042_create_health_tables;
mod m20260423_000043_create_wechat_users;
mod m20260423_000044_create_articles;
mod m20260424_000045_health_indexes;
mod m20260424_000046_health_constraints_fix;
pub struct Migrator;
@@ -97,6 +98,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260423_000043_create_wechat_users::Migration),
Box::new(m20260423_000044_create_articles::Migration),
Box::new(m20260424_000045_health_indexes::Migration),
Box::new(m20260424_000046_health_constraints_fix::Migration),
]
}
}

View File

@@ -0,0 +1,94 @@
use sea_orm_migration::prelude::*;
/// 迁移 000046: 修复唯一索引软删除兼容 + 关联表添加 version + 补充索引/FK
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// C-4: patient.id_number 唯一索引 — 重建为 partial index WHERE deleted_at IS NULL
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number) WHERE deleted_at IS NULL AND id_number IS NOT NULL"
).await?;
// C-5: patient_tag.name 唯一索引 — 重建为 partial index WHERE deleted_at IS NULL
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tag_tenant_name_unique").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_patient_tag_tenant_name_unique ON patient_tag (tenant_id, name) WHERE deleted_at IS NULL"
).await?;
// C-6: doctor_schedule 唯一索引 — 重建为 partial index修正列选择为 (tenant_id, doctor_id, schedule_date, period_type)
conn.execute_unprepared("DROP INDEX IF EXISTS idx_doctor_schedule_unique_slot").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_doctor_schedule_unique_slot ON doctor_schedule (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL"
).await?;
// H-5: patient_tag_relation 添加 version 列
conn.execute_unprepared(
"ALTER TABLE patient_tag_relation ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1"
).await?;
// H-5: patient_doctor_relation 添加 version 列
conn.execute_unprepared(
"ALTER TABLE patient_doctor_relation ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1"
).await?;
// H-8: follow_up_task.related_appointment_id 添加 FK 约束
conn.execute_unprepared(
"ALTER TABLE follow_up_task DROP CONSTRAINT IF EXISTS fk_follow_up_task_appointment"
).await?;
conn.execute_unprepared(
"ALTER TABLE follow_up_task ADD CONSTRAINT fk_follow_up_task_appointment \
FOREIGN KEY (related_appointment_id) REFERENCES appointment(id) ON DELETE SET NULL"
).await?;
// M-6: lab_report 添加 (tenant_id, report_type) 索引
conn.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_lab_report_tenant_type ON lab_report (tenant_id, report_type)"
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 恢复原始索引(非 partial
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number)"
).await?;
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tag_tenant_name_unique").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_patient_tag_tenant_name_unique ON patient_tag (tenant_id, name)"
).await?;
conn.execute_unprepared("DROP INDEX IF EXISTS idx_doctor_schedule_unique_slot").await?;
conn.execute_unprepared(
"CREATE UNIQUE INDEX idx_doctor_schedule_unique_slot ON doctor_schedule (tenant_id, doctor_id, schedule_date, start_time)"
).await?;
conn.execute_unprepared(
"ALTER TABLE patient_tag_relation DROP COLUMN IF EXISTS version"
).await?;
conn.execute_unprepared(
"ALTER TABLE patient_doctor_relation DROP COLUMN IF EXISTS version"
).await?;
conn.execute_unprepared(
"ALTER TABLE follow_up_task DROP CONSTRAINT IF EXISTS fk_follow_up_task_appointment"
).await?;
conn.execute_unprepared(
"DROP INDEX IF EXISTS idx_lab_report_tenant_type"
).await?;
Ok(())
}
}