From cb3653c92eaaceba0180301d16442a57ba7e826c Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 12:23:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20family=5Fmember=20+=20doctor=5F?= =?UTF-8?q?profile=20PII=20=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 m000067: patient_family_member 添加 phone_hash + key_version - 迁移 m000068: doctor_profile 添加 license_number_hash + key_version - family_member: phone 加密 + HMAC 索引 + 列表脱敏 - doctor_profile: license_number 加密 + HMAC 搜索重写 + 详情解密 - 列表中 Tier 1 字段返回 None --- .../erp-health/src/entity/doctor_profile.rs | 4 ++ .../src/entity/patient_family_member.rs | 4 ++ .../erp-health/src/service/doctor_service.rs | 51 +++++++++++++--- .../erp-health/src/service/patient_service.rs | 60 ++++++++++++++----- crates/erp-server/migration/src/lib.rs | 4 ++ ...427_000067_add_family_member_pii_fields.rs | 56 +++++++++++++++++ ...27_000068_add_doctor_profile_pii_fields.rs | 56 +++++++++++++++++ 7 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260427_000067_add_family_member_pii_fields.rs create mode 100644 crates/erp-server/migration/src/m20260427_000068_add_doctor_profile_pii_fields.rs diff --git a/crates/erp-health/src/entity/doctor_profile.rs b/crates/erp-health/src/entity/doctor_profile.rs index 191c146..06edb4c 100644 --- a/crates/erp-health/src/entity/doctor_profile.rs +++ b/crates/erp-health/src/entity/doctor_profile.rs @@ -19,6 +19,8 @@ pub struct Model { #[sea_orm(skip_serializing_if = "Option::is_none")] pub license_number: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] + pub license_number_hash: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] pub bio: Option, pub online_status: String, pub created_at: DateTimeUtc, @@ -30,6 +32,8 @@ pub struct Model { #[sea_orm(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, pub version: i32, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub key_version: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/erp-health/src/entity/patient_family_member.rs b/crates/erp-health/src/entity/patient_family_member.rs index d988b27..b17e2a2 100644 --- a/crates/erp-health/src/entity/patient_family_member.rs +++ b/crates/erp-health/src/entity/patient_family_member.rs @@ -13,6 +13,8 @@ pub struct Model { #[sea_orm(skip_serializing_if = "Option::is_none")] pub phone: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] + pub phone_hash: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] pub birth_date: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub notes: Option, @@ -25,6 +27,8 @@ pub struct Model { #[sea_orm(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, pub version: i32, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub key_version: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/erp-health/src/service/doctor_service.rs b/crates/erp-health/src/service/doctor_service.rs index cf3e202..80fdca5 100644 --- a/crates/erp-health/src/service/doctor_service.rs +++ b/crates/erp-health/src/service/doctor_service.rs @@ -15,6 +15,7 @@ use crate::entity::doctor_profile; use crate::error::{HealthError, HealthResult}; use crate::service::validation::validate_online_status; use crate::state::HealthState; +use erp_core::crypto as pii; pub async fn list_doctors( state: &HealthState, @@ -33,10 +34,11 @@ pub async fn list_doctors( .filter(doctor_profile::Column::DeletedAt.is_null()); if let Some(ref s) = search { + let license_hash = pii::hmac_hash(state.crypto.kek(), s); query = query.filter( Condition::any() .add(doctor_profile::Column::Name.contains(s)) - .add(doctor_profile::Column::LicenseNumber.contains(s)) + .add(doctor_profile::Column::LicenseNumberHash.eq(license_hash)) .add(doctor_profile::Column::Department.contains(s)) .add(doctor_profile::Column::Specialty.contains(s)), ); @@ -77,6 +79,15 @@ pub async fn create_doctor( let now = Utc::now(); let id = Uuid::now_v7(); + let (encrypted_license, license_hash) = match req.license_number { + Some(ref l) if !l.is_empty() => { + let encrypted = pii::encrypt(state.crypto.kek(), l)?; + let hash = pii::hmac_hash(state.crypto.kek(), l); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + let active = doctor_profile::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), @@ -85,7 +96,8 @@ pub async fn create_doctor( department: Set(req.department), title: Set(req.title), specialty: Set(req.specialty), - license_number: Set(req.license_number), + license_number: Set(encrypted_license), + license_number_hash: Set(license_hash), bio: Set(req.bio), online_status: Set("offline".to_string()), created_at: Set(now), @@ -94,6 +106,7 @@ pub async fn create_doctor( updated_by: Set(operator_id), deleted_at: Set(None), version: Set(1), + key_version: Set(Some(1)), }; let model = active.insert(&state.db).await?; @@ -104,7 +117,7 @@ pub async fn create_doctor( &state.db, ).await; - Ok(model_to_resp(model)) + Ok(model_to_resp_decrypted(&state.crypto, model)) } pub async fn get_doctor( @@ -113,7 +126,7 @@ pub async fn get_doctor( id: Uuid, ) -> HealthResult { let model = find_doctor(&state.db, tenant_id, id).await?; - Ok(model_to_resp(model)) + Ok(model_to_resp_decrypted(&state.crypto, model)) } pub async fn update_doctor( @@ -134,7 +147,12 @@ pub async fn update_doctor( if let Some(v) = req.department { active.department = Set(Some(v)); } if let Some(v) = req.title { active.title = Set(Some(v)); } if let Some(v) = req.specialty { active.specialty = Set(Some(v)); } - if let Some(v) = req.license_number { active.license_number = Set(Some(v)); } + if let Some(v) = req.license_number { + let encrypted = pii::encrypt(state.crypto.kek(), &v)?; + let hash = pii::hmac_hash(state.crypto.kek(), &v); + active.license_number = Set(Some(encrypted)); + active.license_number_hash = Set(Some(hash)); + } if let Some(v) = req.bio { active.bio = Set(Some(v)); } if let Some(ref v) = req.online_status { validate_online_status(v)?; @@ -160,7 +178,7 @@ pub async fn update_doctor( &state.db, ).await; - Ok(model_to_resp(updated)) + Ok(model_to_resp_decrypted(&state.crypto, updated)) } pub async fn delete_doctor( @@ -212,7 +230,26 @@ fn model_to_resp(m: doctor_profile::Model) -> DoctorResp { department: m.department, title: m.title, specialty: m.specialty, - license_number: m.license_number, + license_number: None, + bio: m.bio, + online_status: m.online_status, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn model_to_resp_decrypted(crypto: &erp_core::crypto::PiiCrypto, m: doctor_profile::Model) -> DoctorResp { + let license = m.license_number.as_ref() + .map(|l| pii::decrypt(crypto.kek(), l).unwrap_or_else(|_| l.clone())); + DoctorResp { + id: m.id, + user_id: m.user_id, + name: m.name, + department: m.department, + title: m.title, + specialty: m.specialty, + license_number: license, bio: m.bio, online_status: m.online_status, created_at: m.created_at, diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index c630d18..79ceb55 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -493,17 +493,23 @@ pub async fn list_family_members( .all(&state.db) .await?; - Ok(models.into_iter().map(|m| FamilyMemberResp { - id: m.id, - patient_id: m.patient_id, - name: m.name, - relationship: m.relationship, - phone: m.phone, - birth_date: m.birth_date, - notes: m.notes, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, + let kek = state.crypto.kek(); + Ok(models.into_iter().map(|m| { + let phone = m.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); + FamilyMemberResp { + id: m.id, + patient_id: m.patient_id, + name: m.name, + relationship: m.relationship, + phone, + birth_date: m.birth_date, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } }).collect()) } @@ -520,13 +526,24 @@ pub async fn create_family_member( let now = Utc::now(); let id = Uuid::now_v7(); + let kek = state.crypto.kek(); + let (encrypted_phone, phone_hash) = match req.phone { + Some(ref p) if !p.is_empty() => { + let encrypted = pii::encrypt(kek, p)?; + let hash = pii::hmac_hash(kek, p); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + let active = patient_family_member::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), patient_id: Set(patient_id), name: Set(req.name), relationship: Set(req.relationship), - phone: Set(req.phone), + phone: Set(encrypted_phone), + phone_hash: Set(phone_hash), birth_date: Set(req.birth_date), notes: Set(req.notes), created_at: Set(now), @@ -535,6 +552,7 @@ pub async fn create_family_member( updated_by: Set(operator_id), deleted_at: Set(None), version: Set(1), + key_version: Set(Some(1)), }; let model = active.insert(&state.db).await?; @@ -545,12 +563,15 @@ pub async fn create_family_member( &state.db, ).await; + let decrypted_phone = model.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); Ok(FamilyMemberResp { id: model.id, patient_id: model.patient_id, name: model.name, relationship: model.relationship, - phone: model.phone, + phone: decrypted_phone, birth_date: model.birth_date, notes: model.notes, created_at: model.created_at, @@ -581,10 +602,16 @@ pub async fn update_family_member( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + let kek = state.crypto.kek(); let mut active: patient_family_member::ActiveModel = model.into(); active.name = Set(req.name); active.relationship = Set(req.relationship); - active.phone = Set(req.phone); + if let Some(ref p) = req.phone { + let encrypted = pii::encrypt(kek, p)?; + let hash = pii::hmac_hash(kek, p); + active.phone = Set(Some(encrypted)); + active.phone_hash = Set(Some(hash)); + } active.birth_date = Set(req.birth_date); active.notes = Set(req.notes); active.updated_at = Set(Utc::now()); @@ -599,12 +626,15 @@ pub async fn update_family_member( &state.db, ).await; + let decrypted_phone = updated.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); Ok(FamilyMemberResp { id: updated.id, patient_id: updated.patient_id, name: updated.name, relationship: updated.relationship, - phone: updated.phone, + phone: decrypted_phone, birth_date: updated.birth_date, notes: updated.notes, created_at: updated.created_at, diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 81d1e20..68d68a4 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -66,6 +66,8 @@ mod m20260427_000063_content_management; mod m20260427_000064_add_patient_pii_fields; mod m20260427_000065_add_consultation_message_key_version; mod m20260427_000066_add_follow_up_record_key_version; +mod m20260427_000067_add_family_member_pii_fields; +mod m20260427_000068_add_doctor_profile_pii_fields; pub struct Migrator; @@ -139,6 +141,8 @@ impl MigratorTrait for Migrator { Box::new(m20260427_000064_add_patient_pii_fields::Migration), Box::new(m20260427_000065_add_consultation_message_key_version::Migration), Box::new(m20260427_000066_add_follow_up_record_key_version::Migration), + Box::new(m20260427_000067_add_family_member_pii_fields::Migration), + Box::new(m20260427_000068_add_doctor_profile_pii_fields::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260427_000067_add_family_member_pii_fields.rs b/crates/erp-server/migration/src/m20260427_000067_add_family_member_pii_fields.rs new file mode 100644 index 0000000..3a61ca3 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000067_add_family_member_pii_fields.rs @@ -0,0 +1,56 @@ +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> { + manager + .alter_table( + Table::alter() + .table(PatientFamilyMember::Table) + .add_column(ColumnDef::new(PatientFamilyMember::PhoneHash).string_len(64).null()) + .add_column(ColumnDef::new(PatientFamilyMember::KeyVersion).integer().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_family_member_phone_hash") + .table(PatientFamilyMember::Table) + .col(PatientFamilyMember::PhoneHash) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index(Index::drop().name("idx_family_member_phone_hash").to_owned()) + .await?; + + manager + .alter_table( + Table::alter() + .table(PatientFamilyMember::Table) + .drop_column(PatientFamilyMember::PhoneHash) + .drop_column(PatientFamilyMember::KeyVersion) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum PatientFamilyMember { + Table, + PhoneHash, + KeyVersion, +} diff --git a/crates/erp-server/migration/src/m20260427_000068_add_doctor_profile_pii_fields.rs b/crates/erp-server/migration/src/m20260427_000068_add_doctor_profile_pii_fields.rs new file mode 100644 index 0000000..cf00102 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000068_add_doctor_profile_pii_fields.rs @@ -0,0 +1,56 @@ +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> { + manager + .alter_table( + Table::alter() + .table(DoctorProfile::Table) + .add_column(ColumnDef::new(DoctorProfile::LicenseNumberHash).string_len(64).null()) + .add_column(ColumnDef::new(DoctorProfile::KeyVersion).integer().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_doctor_profile_license_hash") + .table(DoctorProfile::Table) + .col(DoctorProfile::LicenseNumberHash) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index(Index::drop().name("idx_doctor_profile_license_hash").to_owned()) + .await?; + + manager + .alter_table( + Table::alter() + .table(DoctorProfile::Table) + .drop_column(DoctorProfile::LicenseNumberHash) + .drop_column(DoctorProfile::KeyVersion) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum DoctorProfile { + Table, + LicenseNumberHash, + KeyVersion, +}