feat(health): family_member + doctor_profile PII 加密
- 迁移 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:
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user