feat(health): family_member + doctor_profile PII 加密
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

- 迁移 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
This commit is contained in:
iven
2026-04-26 12:23:10 +08:00
parent 2474905727
commit cb3653c92e
7 changed files with 213 additions and 22 deletions

View File

@@ -19,6 +19,8 @@ pub struct Model {
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub license_number: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub license_number_hash: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
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<DateTimeUtc>,
pub version: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub key_version: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -13,6 +13,8 @@ pub struct Model {
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub phone_hash: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub birth_date: Option<chrono::NaiveDate>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
@@ -25,6 +27,8 @@ pub struct Model {
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub key_version: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -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<DoctorResp> {
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,

View File

@@ -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,

View File

@@ -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),
]
}
}

View File

@@ -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,
}

View File

@@ -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,
}