fix: QA 第二轮修复 — PatientDetail 重构/测试覆盖/id_number 列宽/小程序 URL 规范化
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

- refactor(web): PatientDetail.tsx 拆分为 4 个子组件(737→334行)
- refactor(web): 提取 usePaginatedData hook 消除重复分页状态
- feat(db): patient.id_number varchar(20)→varchar(255) 容纳加密值
- test(health): 添加预约模块集成测试(创建/列表/租户隔离)
- test(plugin): 添加 6 个 SQL 注入 sanitize 测试
- fix(miniprogram): 7 个 service 文件 URL 构建规范化(params 对象)
- fix(miniprogram): 跨平台字段名对齐(birth_date/start_time/end_time)
This commit is contained in:
iven
2026-04-25 10:22:44 +08:00
parent 55a3fd32d0
commit 0bf1822fa9
34 changed files with 1110 additions and 641 deletions

View File

@@ -48,6 +48,7 @@ mod m20260424_000045_health_indexes;
mod m20260424_000046_health_constraints_fix;
mod m20260424_000047_health_index_fix;
mod m20260425_000048_add_patient_id_number_hash;
mod m20260425_000049_widen_patient_id_number;
pub struct Migrator;
@@ -103,6 +104,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260424_000046_health_constraints_fix::Migration),
Box::new(m20260424_000047_health_index_fix::Migration),
Box::new(m20260425_000048_add_patient_id_number_hash::Migration),
Box::new(m20260425_000049_widen_patient_id_number::Migration),
]
}
}

View File

@@ -0,0 +1,51 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260425_000049_widen_patient_id_number"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
// 先删除依赖 id_number 列的唯一索引
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number")
.await?;
// 加宽 id_number 列varchar(20) → varchar(255),容纳 AES-256-GCM 加密值(~88 字符)
conn.execute_unprepared(
"ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(255)",
)
.await?;
// 重建唯一索引partial排除软删除和空值
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?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let conn = manager.get_connection();
conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number")
.await?;
conn.execute_unprepared(
"ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(20)",
)
.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?;
Ok(())
}
}

View File

@@ -8,3 +8,5 @@ mod plugin_tests;
mod workflow_tests;
#[path = "integration/health_patient_tests.rs"]
mod health_patient_tests;
#[path = "integration/health_appointment_tests.rs"]
mod health_appointment_tests;

View File

@@ -0,0 +1,248 @@
//! erp-health 预约排班集成测试
//!
//! 验证预约 CRUD、租户隔离、CAS 排班名额等核心行为。
//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。
//! 预约创建依赖患者 + 医护档案 + 排班三条前置数据。
use erp_core::events::EventBus;
use erp_health::dto::appointment_dto::CreateAppointmentReq;
use erp_health::dto::doctor_dto::CreateDoctorReq;
use erp_health::dto::patient_dto::CreatePatientReq;
use erp_health::service::{
appointment_service, doctor_service, patient_service,
};
use erp_health::state::HealthState;
use erp_health::HealthCrypto;
use super::test_db::TestDb;
/// 构建测试用 HealthState
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
HealthState {
db: db.clone(),
event_bus: EventBus::new(100),
crypto: HealthCrypto::dev_default(),
}
}
/// 创建患者并返回其 ID
async fn seed_patient(
state: &HealthState,
tenant_id: uuid::Uuid,
name: &str,
) -> uuid::Uuid {
let req = CreatePatientReq {
name: name.to_string(),
gender: Some("male".to_string()),
birth_date: None,
blood_type: None,
id_number: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
};
let patient = patient_service::create_patient(state, tenant_id, None, req)
.await
.expect("创建患者应成功");
patient.id
}
/// 创建医护档案并返回其 ID
async fn seed_doctor(
state: &HealthState,
tenant_id: uuid::Uuid,
name: &str,
) -> uuid::Uuid {
let req = CreateDoctorReq {
user_id: None,
name: name.to_string(),
department: Some("内科".to_string()),
title: Some("主治医师".to_string()),
specialty: Some("心血管内科".to_string()),
license_number: None,
bio: None,
};
let doctor = doctor_service::create_doctor(state, tenant_id, None, req)
.await
.expect("创建医护档案应成功");
doctor.id
}
/// 创建排班并返回其 ID
async fn seed_schedule(
state: &HealthState,
tenant_id: uuid::Uuid,
doctor_id: uuid::Uuid,
date: chrono::NaiveDate,
) -> uuid::Uuid {
let req = erp_health::dto::appointment_dto::CreateScheduleReq {
doctor_id,
schedule_date: date,
period_type: Some("am".to_string()),
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
max_appointments: 10,
};
let schedule = appointment_service::create_schedule(state, tenant_id, None, req)
.await
.expect("创建排班应成功");
schedule.id
}
// ---------------------------------------------------------------------------
// 测试 1: 创建预约
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_create_appointment() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
// 前置:患者 + 医护 + 排班
let patient_id = seed_patient(&state, tenant_id, "预约测试患者").await;
let doctor_id = seed_doctor(&state, tenant_id, "预约测试医生").await;
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
seed_schedule(&state, tenant_id, doctor_id, date).await;
let req = CreateAppointmentReq {
patient_id,
doctor_id: Some(doctor_id),
appointment_type: Some("outpatient".to_string()),
appointment_date: date,
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
notes: Some("首次就诊".to_string()),
};
let appointment =
appointment_service::create_appointment(&state, tenant_id, Some(operator_id), req)
.await
.expect("创建预约应成功");
assert_eq!(appointment.patient_id, patient_id);
assert_eq!(appointment.doctor_id, Some(doctor_id));
assert_eq!(appointment.appointment_type, "outpatient");
assert_eq!(appointment.status, "pending");
assert_eq!(appointment.version, 1);
assert_eq!(
appointment.notes,
Some("首次就诊".to_string())
);
// 通过 get_appointment 验证存储正确
let found = appointment_service::get_appointment(&state, tenant_id, appointment.id)
.await
.expect("查询预约应成功");
assert_eq!(found.id, appointment.id);
assert_eq!(found.status, "pending");
assert_eq!(found.version, 1);
}
// ---------------------------------------------------------------------------
// 测试 2: 列表查询 — 创建 2 条预约后验证分页计数
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_list_appointments() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let patient_id = seed_patient(&state, tenant_id, "列表测试患者").await;
let doctor_id = seed_doctor(&state, tenant_id, "列表测试医生").await;
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
seed_schedule(&state, tenant_id, doctor_id, date).await;
// 创建 2 条预约同一个排班时段CAS 按排班 start_time 匹配)
for _i in 0..2 {
let req = CreateAppointmentReq {
patient_id,
doctor_id: Some(doctor_id),
appointment_type: None,
appointment_date: date,
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
notes: None,
};
appointment_service::create_appointment(&state, tenant_id, None, req)
.await
.expect("创建预约应成功");
}
let result = appointment_service::list_appointments(
&state,
tenant_id,
1,
10,
None,
None,
None,
None,
)
.await
.expect("列表查询应成功");
assert_eq!(result.total, 2, "应有 2 条预约记录");
assert_eq!(result.data.len(), 2, "当前页应返回 2 条");
}
// ---------------------------------------------------------------------------
// 测试 3: 租户隔离 — 租户 A 的预约对租户 B 不可见
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_appointment_tenant_isolation() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_a = uuid::Uuid::new_v4();
let tenant_b = uuid::Uuid::new_v4();
// 租户 A 创建完整链路:患者 + 医护 + 排班 + 预约
let patient_a = seed_patient(&state, tenant_a, "租户A患者").await;
let doctor_a = seed_doctor(&state, tenant_a, "租户A医生").await;
let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
seed_schedule(&state, tenant_a, doctor_a, date).await;
let req = CreateAppointmentReq {
patient_id: patient_a,
doctor_id: Some(doctor_a),
appointment_type: None,
appointment_date: date,
start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
notes: None,
};
let appointment_a =
appointment_service::create_appointment(&state, tenant_a, None, req)
.await
.expect("租户 A 创建预约应成功");
// 租户 B 列表查询应看不到租户 A 的预约
let result_b = appointment_service::list_appointments(
&state,
tenant_b,
1,
10,
None,
None,
None,
None,
)
.await
.expect("租户 B 列表查询应成功");
assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的预约");
assert!(result_b.data.is_empty());
// 租户 B 通过 ID 查询租户 A 的预约应返回错误
let lookup_result =
appointment_service::get_appointment(&state, tenant_b, appointment_a.id).await;
assert!(
lookup_result.is_err(),
"跨租户查询预约应返回错误"
);
}