diff --git a/crates/erp-core/src/crypto/key_manager.rs b/crates/erp-core/src/crypto/key_manager.rs index 45ecf40..dc8cb18 100644 --- a/crates/erp-core/src/crypto/key_manager.rs +++ b/crates/erp-core/src/crypto/key_manager.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use std::time::Instant; use dashmap::DashMap; @@ -105,7 +104,6 @@ impl DekManager { fn evict_if_full(&self) { if self.cache.len() >= self.max_entries { - // 简单策略:移除最早加载的一半 let to_remove: Vec = self.cache .iter() .filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2) @@ -118,3 +116,90 @@ impl DekManager { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::PiiCrypto; + + fn test_kek() -> [u8; 32] { + *PiiCrypto::dev_default().kek() + } + + fn test_uuid(i: u8) -> Uuid { + let s = format!("00000000-0000-0000-0000-0000000000{:02x}", i); + Uuid::parse_str(&s).unwrap() + } + + #[test] + fn generate_new_dek_returns_32_bytes() { + let (dek, _enc) = DekManager::generate_new_dek(&test_kek()).unwrap(); + assert_eq!(dek.len(), 32); + } + + #[test] + fn generate_new_dek_produces_unique_keys() { + let (dek1, _) = DekManager::generate_new_dek(&test_kek()).unwrap(); + let (dek2, _) = DekManager::generate_new_dek(&test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn encrypt_dek_roundtrip() { + let kek = test_kek(); + let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap(); + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(1); + let (recovered_dek, _ver) = mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek).unwrap(); + assert_eq!(original_dek, recovered_dek); + } + + #[test] + fn get_or_create_generates_when_none() { + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(2); + let (dek1, ver1) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_eq!(ver1, 1); + let (dek2, ver2) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_eq!(dek1, dek2); + assert_eq!(ver2, 1); + } + + #[test] + fn invalidate_removes_cached_dek() { + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(3); + let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + mgr.invalidate(tenant_id); + let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn decrypt_with_wrong_kek_fails() { + let kek1 = test_kek(); + let kek2 = [0xffu8; 32]; + let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap(); + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(4); + assert!(mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2).is_err()); + } + + #[test] + fn expired_entry_not_returned() { + let mgr = DekManager::new(0, 100); + let tenant_id = test_uuid(5); + let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn max_entries_eviction() { + let mgr = DekManager::new(300, 3); + for i in 0..5u8 { + let _ = mgr.get_or_create_dek(test_uuid(i), None, &test_kek()).unwrap(); + } + assert!(mgr.cache.len() <= 6); + } +} diff --git a/crates/erp-core/src/crypto/masking.rs b/crates/erp-core/src/crypto/masking.rs new file mode 100644 index 0000000..360ea8d --- /dev/null +++ b/crates/erp-core/src/crypto/masking.rs @@ -0,0 +1,64 @@ +/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 +pub fn mask_id_number(s: &str) -> String { + if s.len() >= 7 { + format!("{}****{}", &s[..3], &s[s.len() - 4..]) + } else { + "****".to_string() + } +} + +/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 +pub fn mask_phone(s: Option<&str>) -> Option { + s.map(|p| { + if p.len() >= 7 { + format!("{}****{}", &p[..3], &p[p.len() - 4..]) + } else { + "****".to_string() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mask_id_18_digits() { + assert_eq!("110****1234", mask_id_number("110101199001011234")); + } + + #[test] + fn mask_id_short() { + assert_eq!("****", mask_id_number("123456")); + } + + #[test] + fn mask_id_empty() { + assert_eq!("****", mask_id_number("")); + } + + #[test] + fn mask_phone_normal() { + assert_eq!(Some("138****5678".to_string()), mask_phone(Some("13812345678"))); + } + + #[test] + fn mask_phone_none() { + assert_eq!(None, mask_phone(None)); + } + + #[test] + fn mask_phone_short() { + assert_eq!(Some("****".to_string()), mask_phone(Some("123"))); + } + + #[test] + fn mask_phone_exactly_7() { + assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567"))); + } + + #[test] + fn mask_id_exactly_7() { + assert_eq!("123****4567", mask_id_number("1234567")); + } +} diff --git a/crates/erp-core/src/crypto/mod.rs b/crates/erp-core/src/crypto/mod.rs index ce94f1e..b56d0df 100644 --- a/crates/erp-core/src/crypto/mod.rs +++ b/crates/erp-core/src/crypto/mod.rs @@ -140,4 +140,64 @@ mod tests { let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); assert_eq!(plaintext, decrypted); } + + // ── 性能基准 ── + + #[test] + fn bench_encrypt_1000() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let plaintext = "13812345678"; + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = encrypt(kek, plaintext).unwrap(); + } + let elapsed = start.elapsed(); + let avg_us = elapsed.as_micros() / 1000; + assert!( + avg_us < 50, + "encrypt 平均耗时应 < 50μs, 实际: {}μs", + avg_us + ); + eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); + } + + #[test] + fn bench_decrypt_1000() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let ciphertext = encrypt(kek, "13812345678").unwrap(); + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = decrypt(kek, &ciphertext).unwrap(); + } + let elapsed = start.elapsed(); + let avg_us = elapsed.as_micros() / 1000; + assert!( + avg_us < 50, + "decrypt 平均耗时应 < 50μs, 实际: {}μs", + avg_us + ); + eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); + } + + #[test] + fn bench_batch_decrypt_50() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let ciphertexts: Vec = (0..50) + .map(|i| encrypt(kek, &format!("数据{}", i)).unwrap()) + .collect(); + let start = std::time::Instant::now(); + for ct in &ciphertexts { + let _ = decrypt(kek, ct).unwrap(); + } + let elapsed = start.elapsed(); + assert!( + elapsed.as_millis() < 10, + "批量解密 50 条应 < 10ms, 实际: {}ms", + elapsed.as_millis() + ); + eprintln!("batch decrypt 50 条: {:?}", elapsed); + } } diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index f88b981..17d96ed 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -364,3 +364,158 @@ pub async fn create_message( content: decrypted_content, is_read: m.is_read, created_at: m.created_at, }) } + +// --------------------------------------------------------------------------- +// 标记已读 +// --------------------------------------------------------------------------- + +/// 标记会话为已读(将 unread_count_doctor 或 unread_count_patient 置零)。 +pub async fn mark_session_read( + state: &HealthState, + tenant_id: Uuid, + session_id: Uuid, + user_id: Uuid, + role: &str, +) -> HealthResult<()> { + let session = consultation_session::Entity::find() + .filter(consultation_session::Column::Id.eq(session_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::ConsultationNotFound)?; + + use sea_orm::sea_query::Expr; + let column = if role == "doctor" { + consultation_session::Column::UnreadCountDoctor + } else { + consultation_session::Column::UnreadCountPatient + }; + + let result = consultation_session::Entity::update_many() + .col_expr(column, Expr::value(0i32)) + .filter(consultation_session::Column::Id.eq(session.id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::Version.eq(session.version)) + .exec(&state.db) + .await?; + if result.rows_affected == 0 { + return Err(HealthError::VersionMismatch); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 医生仪表盘 +// --------------------------------------------------------------------------- + +/// 医生仪表盘统计数据。 +#[derive(Debug, serde::Serialize)] +pub struct DoctorDashboard { + pub total_patients: i64, + pub active_sessions: i64, + pub unread_messages: i64, + pub pending_follow_ups: i64, + pub today_consultations: i64, +} + +/// 获取指定医生的仪表盘数据。 +pub async fn get_doctor_dashboard( + state: &HealthState, + tenant_id: Uuid, + doctor_user_id: Uuid, +) -> HealthResult { + use crate::entity::{doctor_profile, patient_doctor_relation, follow_up_task}; + use sea_orm::ColumnTrait; + use sea_orm::QueryFilter; + + // 查找医生 profile + let doctor = doctor_profile::Entity::find() + .filter(doctor_profile::Column::UserId.eq(doctor_user_id)) + .filter(doctor_profile::Column::TenantId.eq(tenant_id)) + .filter(doctor_profile::Column::DeletedAt.is_null()) + .one(&state.db) + .await?; + + let doctor_id = match doctor { + Some(d) => d.id, + None => { + // 不是医生,返回空仪表盘 + return Ok(DoctorDashboard { + total_patients: 0, + active_sessions: 0, + unread_messages: 0, + pending_follow_ups: 0, + today_consultations: 0, + }); + } + }; + + // 关联患者数 + let total_patients = patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .count(&state.db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + // 活跃会话数 + let active_sessions = consultation_session::Entity::find() + .filter(consultation_session::Column::DoctorId.eq(doctor_user_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::Status.is_in(["active", "waiting"])) + .count(&state.db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + // 未读消息总数 + use sea_orm::FromQueryResult; + #[derive(FromQueryResult)] + struct UnreadSum { + total: Option, + } + let unread_result = UnreadSum::find_by_statement(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT COALESCE(SUM(unread_count_doctor), 0)::bigint as total + FROM consultation_session + WHERE tenant_id = $1 AND doctor_id = $2 AND deleted_at IS NULL AND status IN ('active', 'waiting')"#, + [tenant_id.into(), doctor_user_id.into()], + )) + .one(&state.db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let unread_messages = unread_result.and_then(|r| r.total).unwrap_or(0); + + // 待处理随访任务 + let pending_follow_ups = follow_up_task::Entity::find() + .filter(follow_up_task::Column::AssignedTo.eq(doctor_user_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::Status.eq("pending")) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .count(&state.db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + // 今日咨询数 + let today = chrono::Utc::now().date_naive(); + let today_start = today.and_hms_opt(0, 0, 0).unwrap_or_default(); + let today_consultations = consultation_session::Entity::find() + .filter(consultation_session::Column::DoctorId.eq(doctor_user_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::CreatedAt.gte(today_start)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .count(&state.db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + Ok(DoctorDashboard { + total_patients: total_patients as i64, + active_sessions: active_sessions as i64, + unread_messages, + pending_follow_ups: pending_follow_ups as i64, + today_consultations: today_consultations as i64, + }) +} diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index 6f36a04..036bd82 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -329,10 +329,15 @@ pub async fn create_record( &state.db, ).await; + let kek = state.crypto.kek(); Ok(FollowUpRecordResp { id: record.id, task_id: record.task_id, executed_by: record.executed_by, - executed_date: record.executed_date, result: record.result, - patient_condition: record.patient_condition, medical_advice: record.medical_advice, + executed_date: record.executed_date, + result: pii::decrypt(kek, &record.result).unwrap_or(record.result.clone()), + patient_condition: record.patient_condition.as_ref() + .map(|v| pii::decrypt(kek, v).unwrap_or(v.clone())), + medical_advice: record.medical_advice.as_ref() + .map(|v| pii::decrypt(kek, v).unwrap_or(v.clone())), next_follow_up_date: record.next_follow_up_date, created_at: record.created_at, updated_at: record.updated_at, version: record.version, }) diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 79ceb55..0bfcd7b 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -879,3 +879,134 @@ pub async fn list_tags( }) .collect()) } + +// --------------------------------------------------------------------------- +// 患者标签 CRUD +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize)] +pub struct CreateTagReq { + pub name: String, + pub color: Option, + pub description: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct TagResp { + pub id: Uuid, + pub name: String, + pub color: Option, + pub description: Option, +} + +pub async fn create_tag( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateTagReq, +) -> HealthResult { + let id = Uuid::now_v7(); + let now = Utc::now(); + let tag = patient_tag::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name), + color: Set(req.color), + description: Set(req.description), + is_system: Set(false), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let tag = tag.insert(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.create", "patient_tag") + .with_resource_id(tag.id), + &state.db, + ).await; + + Ok(TagResp { + id: tag.id, name: tag.name, color: tag.color, description: tag.description, + }) +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateTagReq { + pub name: Option, + pub color: Option, + pub description: Option, + pub version: i32, +} + +pub async fn update_tag( + state: &HealthState, + tenant_id: Uuid, + tag_id: Uuid, + operator_id: Option, + req: UpdateTagReq, +) -> HealthResult { + let tag = patient_tag::Entity::find_by_id(tag_id) + .one(&state.db) + .await? + .ok_or(HealthError::TagNotFound)?; + + if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } + check_version(req.version, tag.version)?; + + let mut active: patient_tag::ActiveModel = tag.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(color) = req.color { active.color = Set(Some(color)); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(req.version + 1); + + let updated = active.update(&state.db).await + .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag") + .with_resource_id(updated.id), + &state.db, + ).await; + + Ok(TagResp { + id: updated.id, name: updated.name, color: updated.color, description: updated.description, + }) +} + +pub async fn delete_tag( + state: &HealthState, + tenant_id: Uuid, + tag_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let tag = patient_tag::Entity::find_by_id(tag_id) + .one(&state.db) + .await? + .ok_or(HealthError::TagNotFound)?; + + if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } + check_version(version, tag.version)?; + + let mut active: patient_tag::ActiveModel = tag.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(version + 1); + active.update(&state.db).await + .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.delete", "patient_tag") + .with_resource_id(tag_id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index ae41ffd..e3555ac 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -71,6 +71,7 @@ mod m20260427_000068_add_doctor_profile_pii_fields; mod m20260427_000069_add_dialysis_record_key_version; mod m20260427_000070_add_lab_report_key_version; mod m20260427_000071_add_diagnosis_key_version; +mod m20260427_000072_widen_encrypted_phone_columns; pub struct Migrator; @@ -149,6 +150,7 @@ impl MigratorTrait for Migrator { Box::new(m20260427_000069_add_dialysis_record_key_version::Migration), Box::new(m20260427_000070_add_lab_report_key_version::Migration), Box::new(m20260427_000071_add_diagnosis_key_version::Migration), + Box::new(m20260427_000072_widen_encrypted_phone_columns::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260427_000072_widen_encrypted_phone_columns.rs b/crates/erp-server/migration/src/m20260427_000072_widen_encrypted_phone_columns.rs new file mode 100644 index 0000000..7387679 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000072_widen_encrypted_phone_columns.rs @@ -0,0 +1,68 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260427_000072_widen_encrypted_phone_columns" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // patient.emergency_contact_phone: varchar(20) → varchar(255),容纳 AES-256-GCM 密文 + conn.execute_unprepared( + "ALTER TABLE patient ALTER COLUMN emergency_contact_phone TYPE varchar(255)", + ) + .await?; + + // patient_family_member.phone: varchar(20) → varchar(255) + conn.execute_unprepared( + "ALTER TABLE patient_family_member ALTER COLUMN phone TYPE varchar(255)", + ) + .await?; + + // doctor_profile.license_number: varchar(50) → varchar(255) + conn.execute_unprepared( + "ALTER TABLE doctor_profile ALTER COLUMN license_number TYPE varchar(255)", + ) + .await?; + + // follow_up_record.result: varchar(20) → varchar(255) + conn.execute_unprepared( + "ALTER TABLE follow_up_record ALTER COLUMN result TYPE varchar(255)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + conn.execute_unprepared( + "ALTER TABLE patient ALTER COLUMN emergency_contact_phone TYPE varchar(20)", + ) + .await?; + + conn.execute_unprepared( + "ALTER TABLE patient_family_member ALTER COLUMN phone TYPE varchar(20)", + ) + .await?; + + conn.execute_unprepared( + "ALTER TABLE doctor_profile ALTER COLUMN license_number TYPE varchar(50)", + ) + .await?; + + conn.execute_unprepared( + "ALTER TABLE follow_up_record ALTER COLUMN result TYPE varchar(20)", + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index d4bfb3f..6889533 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -10,3 +10,5 @@ mod workflow_tests; mod health_patient_tests; #[path = "integration/health_appointment_tests.rs"] mod health_appointment_tests; +#[path = "integration/health_pii_encryption_tests.rs"] +mod health_pii_encryption_tests; diff --git a/crates/erp-server/tests/integration/health_pii_encryption_tests.rs b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs new file mode 100644 index 0000000..6dbc69e --- /dev/null +++ b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs @@ -0,0 +1,635 @@ +//! PII 分级加密集成测试 +//! +//! 验证加密/解密流程:创建时 Tier 1 字段加密存储、详情接口解密返回、 +//! 列表接口隐藏 Tier 1 字段、HMAC 精确搜索、医生执业证号搜索重写。 + +use chrono::NaiveDate; +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; +use erp_health::dto::consultation_dto::{CreateMessageReq, CreateSessionReq}; +use erp_health::dto::doctor_dto::CreateDoctorReq; +use erp_health::dto::follow_up_dto::{CreateFollowUpRecordReq, CreateFollowUpTaskReq}; +use erp_health::dto::patient_dto::CreatePatientReq; +use erp_health::service::{ + consultation_service, doctor_service, follow_up_service, patient_service, +}; +use erp_health::state::HealthState; +use sea_orm::EntityTrait; +use uuid::Uuid; + +use super::test_db::TestDb; + +fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { + HealthState { + db: db.clone(), + event_bus: EventBus::new(100), + crypto: PiiCrypto::dev_default(), + } +} + +fn sample_patient_req() -> CreatePatientReq { + CreatePatientReq { + name: "加密测试患者".to_string(), + gender: Some("male".to_string()), + birth_date: Some(NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()), + blood_type: Some("A".to_string()), + id_number: Some("110101199001151234".to_string()), + allergy_history: Some("青霉素、磺胺类药物过敏".to_string()), + medical_history_summary: Some("高血压 5 年".to_string()), + emergency_contact_name: Some("紧急联系人".to_string()), + emergency_contact_phone: Some("13812345678".to_string()), + source: Some("offline".to_string()), + notes: None, + } +} + +// ── 1. Patient: 创建后 DB 中 Tier 1 字段为密文 ── + +#[tokio::test] +async fn test_patient_tier1_fields_encrypted_in_db() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + let req = sample_patient_req(); + let patient = patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + + // 直接查 DB 验证 id_number 是密文(Base64 格式,非明文) + let row: Option = + erp_health::entity::patient::Entity::find_by_id(patient.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功"); + let row = row.expect("应找到记录"); + + // id_number 存储值不应包含明文 + let stored_id = row.id_number.as_deref().unwrap_or(""); + assert_ne!(stored_id, "110101199001151234", "身份证号不应以明文存储"); + // AES-GCM 输出为 Base64,不应有中文 + assert!( + !stored_id.contains("1101"), + "密文不应包含身份证号片段" + ); + + // allergy_history 同理 + let stored_allergy = row.allergy_history.as_deref().unwrap_or(""); + assert!( + !stored_allergy.contains("青霉素"), + "过敏史不应以明文存储" + ); +} + +// ── 2. Patient: 详情接口返回解密明文 ── + +#[tokio::test] +async fn test_patient_detail_returns_decrypted_fields() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + let req = sample_patient_req(); + let created = patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + + let detail = patient_service::get_patient(&state, tenant_id, created.id) + .await + .expect("查询详情应成功"); + + // Tier 1 字段在详情视图返回解密后脱敏的值 + assert_eq!( + detail.id_number.as_deref(), + Some("110****1234"), + "详情应返回脱敏的身份证号" + ); + assert_eq!( + detail.emergency_contact_phone.as_deref(), + Some("138****5678"), + "详情应返回脱敏的紧急联系电话" + ); + assert_eq!( + detail.allergy_history.as_deref(), + Some("青霉素、磺胺类药物过敏"), + "详情应返回解密的过敏史" + ); + assert_eq!( + detail.medical_history_summary.as_deref(), + Some("高血压 5 年"), + "详情应返回解密的病史摘要" + ); +} + +// ── 3. Patient: 列表接口隐藏 Tier 1 字段 ── + +#[tokio::test] +async fn test_patient_list_hides_tier1_fields() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + let req = sample_patient_req(); + patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + + let list = patient_service::list_patients(&state, tenant_id, 1, 10, None, None) + .await + .expect("列表查询应成功"); + + assert_eq!(list.data.len(), 1); + let item = &list.data[0]; + assert!( + item.id_number.is_none(), + "列表不应返回身份证号" + ); + assert!( + item.allergy_history.is_none(), + "列表不应返回过敏史" + ); + assert!( + item.medical_history_summary.is_none(), + "列表不应返回病史摘要" + ); + assert!( + item.emergency_contact_phone.is_none(), + "列表不应返回紧急联系电话" + ); +} + +// ── 4. Patient: HMAC 精确搜索电话号码 ── + +#[tokio::test] +async fn test_patient_hmac_search_by_phone() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + let req = sample_patient_req(); + patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + + // 通过电话号码精确搜索 + let result = patient_service::list_patients( + &state, + tenant_id, + 1, + 10, + Some("13812345678".to_string()), + None, + ) + .await + .expect("电话搜索应成功"); + + assert_eq!(result.total, 1, "应通过电话号码找到患者"); + assert_eq!(result.data[0].name, "加密测试患者"); + + // 搜索不存在的号码 + let empty = patient_service::list_patients( + &state, + tenant_id, + 1, + 10, + Some("99999999999".to_string()), + None, + ) + .await + .expect("搜索应成功"); + + assert_eq!(empty.total, 0, "不存在的号码不应有结果"); +} + +// ── 5. ConsultationMessage: content 加密存储 + 解密返回 ── + +#[tokio::test] +async fn test_consultation_message_content_encrypted() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + let sender_id = Uuid::now_v7(); + + // 先创建患者(create_session 需要患者存在) + let patient = patient_service::create_patient( + &state, + tenant_id, + None, + CreatePatientReq { + 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, + }, + ) + .await + .expect("创建患者应成功"); + + // 创建医生(consultation_session.doctor_id 外键指向 doctor_profile.id) + let doctor = doctor_service::create_doctor( + &state, + tenant_id, + None, + CreateDoctorReq { + user_id: None, + name: "咨询测试医生".to_string(), + department: None, + title: None, + specialty: None, + license_number: None, + bio: None, + }, + ) + .await + .expect("创建医生应成功"); + + // 创建 session + let session = consultation_service::create_session( + &state, + tenant_id, + None, + CreateSessionReq { + patient_id: patient.id, + doctor_id: Some(doctor.id), + consultation_type: Some("customer_service".to_string()), + }, + ) + .await + .expect("创建会话应成功"); + + let plain_content = "患者过敏史:青霉素、磺胺类药物"; + let msg = consultation_service::create_message( + &state, + tenant_id, + Some(sender_id), + CreateMessageReq { + session_id: session.id, + sender_id, + sender_role: "patient".to_string(), + content_type: Some("text".to_string()), + content: plain_content.to_string(), + }, + ) + .await + .expect("创建消息应成功"); + + // API 返回的 content 应为明文 + assert_eq!(msg.content, plain_content, "API 应返回解密后的明文"); + + // DB 中的 content 应为密文 + let row: Option = + erp_health::entity::consultation_message::Entity::find_by_id(msg.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功"); + let row = row.expect("应找到记录"); + assert_ne!(row.content, plain_content, "DB 中 content 应为密文"); +} + +// ── 6. Doctor: license_number 加密 + HMAC 搜索重写 ── + +#[tokio::test] +async fn test_doctor_license_number_encrypted_and_searchable() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + let doctor = doctor_service::create_doctor( + &state, + tenant_id, + None, + CreateDoctorReq { + user_id: None, + name: "张医生".to_string(), + department: Some("内科".to_string()), + title: Some("主任医师".to_string()), + specialty: None, + license_number: Some("YL-2024-00123".to_string()), + bio: None, + }, + ) + .await + .expect("创建医生应成功"); + + // 详情应返回解密的执业证号 + assert_eq!( + doctor.license_number.as_deref(), + Some("YL-2024-00123"), + "详情应返回解密的执业证号" + ); + + // 列表视图 license_number 为 None + let list = doctor_service::list_doctors( + &state, + tenant_id, + 1, + 10, + Some("YL-2024-00123".to_string()), + None, + None, + ) + .await + .expect("列表查询应成功"); + + assert_eq!(list.total, 1, "应通过执业证号 HMAC 搜索到医生"); + // 列表视图的 license_number 为 None(Tier 1) + assert!(list.data[0].license_number.is_none()); +} + +// ── 7. FollowUpRecord: result/patient_condition/medical_advice 加密 ── + +#[tokio::test] +async fn test_follow_up_record_fields_encrypted() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + // 先创建患者(create_task 需要患者存在) + let patient = patient_service::create_patient( + &state, + tenant_id, + None, + CreatePatientReq { + 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, + }, + ) + .await + .expect("创建患者应成功"); + + // 创建随访任务 + let task = follow_up_service::create_task( + &state, + tenant_id, + None, + CreateFollowUpTaskReq { + patient_id: patient.id, + assigned_to: None, + follow_up_type: "phone".to_string(), + planned_date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), + content_template: None, + related_appointment_id: None, + }, + ) + .await + .expect("创建随访任务应成功"); + + let record = follow_up_service::create_record( + &state, + tenant_id, + None, + CreateFollowUpRecordReq { + task_id: task.id, + executed_by: None, + executed_date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), + result: "随访结果:病情稳定".to_string(), + patient_condition: Some("血压控制良好".to_string()), + medical_advice: Some("继续服药,定期复查".to_string()), + next_follow_up_date: None, + }, + ) + .await + .expect("创建随访记录应成功"); + + // API 应返回解密后的明文 + assert_eq!(record.result, "随访结果:病情稳定"); + assert_eq!( + record.patient_condition.as_deref(), + Some("血压控制良好") + ); + assert_eq!( + record.medical_advice.as_deref(), + Some("继续服药,定期复查") + ); + + // DB 中应为密文 + let row: Option = + erp_health::entity::follow_up_record::Entity::find_by_id(record.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功"); + let row = row.expect("应找到记录"); + assert_ne!(row.result, "随访结果:病情稳定", "DB 中 result 应为密文"); +} + +// ── 8. Patient Family Member: phone 加密 + 脱敏返回 ── + +#[tokio::test] +async fn test_family_member_phone_encrypted_and_masked() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = Uuid::now_v7(); + + // 先创建患者 + let patient = patient_service::create_patient( + &state, + tenant_id, + None, + CreatePatientReq { + name: "家属测试患者".to_string(), + gender: Some("female".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, + }, + ) + .await + .expect("创建患者应成功"); + + // 创建家庭成员 + let family = patient_service::create_family_member( + &state, + tenant_id, + patient.id, + None, + erp_health::dto::patient_dto::FamilyMemberReq { + name: "家属王五".to_string(), + relationship: "spouse".to_string(), + phone: Some("13987654321".to_string()), + birth_date: None, + notes: None, + }, + ) + .await + .expect("创建家庭成员应成功"); + + // 返回的电话应为脱敏后的 + assert_eq!( + family.phone.as_deref(), + Some("139****4321"), + "列表应返回脱敏的电话号码" + ); + + // DB 中的 phone 应为密文 + let row: Option = + erp_health::entity::patient_family_member::Entity::find_by_id(family.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功"); + let row = row.expect("应找到记录"); + assert_ne!(row.phone.as_deref(), Some("13987654321"), "DB 中 phone 应为密文"); +} + +// ═══════════════════════════════════════════════════════════════ +// 多租户数据隔离测试 +// ═══════════════════════════════════════════════════════════════ + +// ── 9. 租户间数据隔离:租户 B 无法读取租户 A 的加密患者 ── + +#[tokio::test] +async fn test_tenant_isolation_encrypted_patient() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + + // 租户 A 创建患者 + let patient_a = patient_service::create_patient( + &state, + tenant_a, + None, + CreatePatientReq { + name: "租户A患者".to_string(), + gender: Some("male".to_string()), + birth_date: None, + blood_type: None, + id_number: Some("110101199001011234".to_string()), + allergy_history: Some("青霉素过敏".to_string()), + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: Some("13811112222".to_string()), + source: None, + notes: None, + }, + ) + .await + .expect("租户 A 创建患者应成功"); + + // 租户 B 列表查询不应看到租户 A 的患者 + let list_b = patient_service::list_patients(&state, tenant_b, 1, 10, None, None) + .await + .expect("租户 B 列表查询应成功"); + assert_eq!(list_b.total, 0, "租户 B 不应看到租户 A 的患者"); + + // 租户 B 直接查 ID 应返回错误 + let lookup = patient_service::get_patient(&state, tenant_b, patient_a.id).await; + assert!(lookup.is_err(), "租户 B 不应能查到租户 A 的患者"); + + // 租户 B 用租户 A 的电话搜索不应有结果 + let search_b = patient_service::list_patients( + &state, + tenant_b, + 1, + 10, + Some("13811112222".to_string()), + None, + ) + .await + .expect("搜索应成功"); + assert_eq!(search_b.total, 0, "租户 B 不应通过电话搜索到租户 A 的患者"); +} + +// ── 10. 同一明文在不同租户下 HMAC hash 不同(因为 KEK 相同,但数据隔离正确) ── + +#[tokio::test] +async fn test_cross_tenant_data_integrity() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_a = Uuid::now_v7(); + let tenant_b = Uuid::now_v7(); + + // 两个租户都创建相同身份证号的患者(应各自成功,因为 tenant_id 不同) + let patient_a = patient_service::create_patient( + &state, + tenant_a, + None, + CreatePatientReq { + name: "租户A患者".to_string(), + gender: Some("male".to_string()), + birth_date: None, + blood_type: None, + id_number: Some("110101199001011234".to_string()), + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }, + ) + .await + .expect("租户 A 创建患者应成功"); + + let patient_b = patient_service::create_patient( + &state, + tenant_b, + None, + CreatePatientReq { + name: "租户B患者".to_string(), + gender: Some("female".to_string()), + birth_date: None, + blood_type: None, + id_number: Some("110101199001011234".to_string()), + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }, + ) + .await + .expect("租户 B 创建患者应成功"); + + // 验证两个患者 ID 不同 + assert_ne!(patient_a.id, patient_b.id); + + // 各自的详情应该正确 + let detail_a = patient_service::get_patient(&state, tenant_a, patient_a.id) + .await + .expect("租户 A 查询应成功"); + assert_eq!(detail_a.name, "租户A患者"); + + let detail_b = patient_service::get_patient(&state, tenant_b, patient_b.id) + .await + .expect("租户 B 查询应成功"); + assert_eq!(detail_b.name, "租户B患者"); + + // DB 中两者的 id_number 密文不同(不同 nonce) + let row_a = erp_health::entity::patient::Entity::find_by_id(patient_a.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功") + .expect("应找到记录"); + let row_b = erp_health::entity::patient::Entity::find_by_id(patient_b.id) + .one(test_db.db()) + .await + .expect("DB 查询应成功") + .expect("应找到记录"); + assert_ne!( + row_a.id_number, row_b.id_number, + "相同明文加密后密文应不同(不同 nonce)" + ); +}