feat(health): dialysis/lab_report/diagnosis 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

- 迁移 m000069-m000071: 三个表添加 key_version
- dialysis_record: symptoms(JSON) + complication_notes 加密
- lab_report: items(JSON) + doctor_notes 加密
- diagnosis: notes 加密
- JSON 字段: serialize → encrypt → Value::String(ciphertext)
- 解密失败时回退原始值(兼容未迁移明文数据)
This commit is contained in:
iven
2026-04-26 12:35:27 +08:00
parent cb3653c92e
commit 731e080125
10 changed files with 316 additions and 34 deletions

View File

@@ -28,6 +28,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

@@ -58,6 +58,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

@@ -36,6 +36,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

@@ -4,6 +4,7 @@ use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::crypto as pii;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
@@ -37,7 +38,8 @@ pub async fn list_diagnoses(
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(to_resp).collect();
let crypto = &state.crypto;
let data = models.into_iter().map(|m| to_resp(crypto, m)).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
@@ -64,6 +66,12 @@ pub async fn create_diagnosis(
return Err(HealthError::Validation("诊断状态无效,可选: active/resolved/chronic".into()));
}
// PII 加密
let kek = state.crypto.kek();
let encrypted_notes = req.notes.as_ref()
.map(|n| pii::encrypt(kek, n))
.transpose()?;
let now = Utc::now();
let active = diagnosis::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -76,13 +84,14 @@ pub async fn create_diagnosis(
diagnosed_date: Set(req.diagnosed_date),
status: Set(req.status),
diagnosed_by: Set(req.diagnosed_by.or(operator_id)),
notes: Set(req.notes),
notes: Set(encrypted_notes),
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),
key_version: Set(Some(1)),
};
let m = active.insert(&state.db).await?;
@@ -92,7 +101,7 @@ pub async fn create_diagnosis(
&state.db,
).await;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn update_diagnosis(
@@ -132,10 +141,15 @@ pub async fn update_diagnosis(
}
if let Some(v) = req.health_record_id { active.health_record_id = Set(Some(v)); }
if let Some(v) = req.diagnosed_by { active.diagnosed_by = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
if let Some(v) = req.notes {
let kek = state.crypto.kek();
let encrypted = pii::encrypt(kek, &v)?;
active.notes = Set(Some(encrypted));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.key_version = Set(Some(1));
let m = active.update(&state.db).await?;
@@ -145,7 +159,7 @@ pub async fn update_diagnosis(
&state.db,
).await;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn delete_diagnosis(
@@ -182,7 +196,14 @@ pub async fn delete_diagnosis(
Ok(())
}
fn to_resp(m: diagnosis::Model) -> DiagnosisResp {
fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: diagnosis::Model) -> DiagnosisResp {
let kek = crypto.kek();
// 解密备注
let notes = m.notes.as_ref()
.map(|n| pii::decrypt(kek, n).unwrap_or_else(|_| n.clone()))
.or(m.notes);
DiagnosisResp {
id: m.id,
patient_id: m.patient_id,
@@ -193,7 +214,7 @@ fn to_resp(m: diagnosis::Model) -> DiagnosisResp {
diagnosed_date: m.diagnosed_date,
status: m.status,
diagnosed_by: m.diagnosed_by,
notes: m.notes,
notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,

View File

@@ -3,6 +3,7 @@
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::crypto as pii;
use num_traits::ToPrimitive;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
@@ -40,7 +41,8 @@ pub async fn list_dialysis_records(
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data: Vec<DialysisRecordResp> = models.into_iter().map(to_resp).collect();
let crypto = &state.crypto;
let data: Vec<DialysisRecordResp> = models.into_iter().map(|m| to_resp(crypto, m)).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
@@ -58,7 +60,7 @@ pub async fn get_dialysis_record(
.await?
.ok_or(HealthError::DialysisRecordNotFound)?;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn create_dialysis_record(
@@ -77,6 +79,21 @@ pub async fn create_dialysis_record(
validate_dialysis_type(&req.dialysis_type)?;
let kek = state.crypto.kek();
// PII 加密
let encrypted_symptoms = req.symptoms.as_ref()
.map(|v| -> HealthResult<serde_json::Value> {
let json_str = serde_json::to_string(v)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
})
.transpose()?;
let encrypted_complication = req.complication_notes.as_ref()
.map(|c| pii::encrypt(kek, c))
.transpose()?;
let now = Utc::now();
let active = dialysis_record::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -98,8 +115,8 @@ pub async fn create_dialysis_record(
dialysis_duration: Set(req.dialysis_duration),
blood_flow_rate: Set(req.blood_flow_rate),
dialysis_type: Set(req.dialysis_type),
symptoms: Set(req.symptoms),
complication_notes: Set(req.complication_notes),
symptoms: Set(encrypted_symptoms),
complication_notes: Set(encrypted_complication),
status: Set("draft".to_string()),
reviewed_by: Set(None),
reviewed_at: Set(None),
@@ -109,6 +126,7 @@ pub async fn create_dialysis_record(
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
key_version: Set(Some(1)),
};
let m = active.insert(&state.db).await?;
@@ -118,7 +136,7 @@ pub async fn create_dialysis_record(
&state.db,
).await;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn update_dialysis_record(
@@ -157,11 +175,22 @@ pub async fn update_dialysis_record(
if let Some(v) = req.dialysis_duration { active.dialysis_duration = Set(Some(v)); }
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
if let Some(ref v) = req.dialysis_type { validate_dialysis_type(v)?; active.dialysis_type = Set(v.clone()); }
if let Some(v) = req.symptoms { active.symptoms = Set(Some(v)); }
if let Some(v) = req.complication_notes { active.complication_notes = Set(Some(v)); }
if let Some(v) = req.symptoms {
let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
));
active.symptoms = Set(encrypted);
}
if let Some(v) = req.complication_notes {
let kek = state.crypto.kek();
let encrypted = pii::encrypt(kek, &v)?;
active.complication_notes = Set(Some(encrypted));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.key_version = Set(Some(1));
let m = active.update(&state.db).await?;
@@ -171,7 +200,7 @@ pub async fn update_dialysis_record(
&state.db,
).await;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn review_dialysis_record(
@@ -208,7 +237,7 @@ pub async fn review_dialysis_record(
&state.db,
).await;
Ok(to_resp(m))
Ok(to_resp(&state.crypto, m))
}
pub async fn delete_dialysis_record(
@@ -254,7 +283,21 @@ fn validate_dialysis_type(dialysis_type: &str) -> HealthResult<()> {
}
}
fn to_resp(m: dialysis_record::Model) -> DialysisRecordResp {
fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: dialysis_record::Model) -> DialysisRecordResp {
let kek = crypto.kek();
// 解密症状 JSON加密时存储为 Value::String(ciphertext)
let symptoms = m.symptoms.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.symptoms);
// 解密并发症备注
let complication_notes = m.complication_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.complication_notes);
DialysisRecordResp {
id: m.id,
patient_id: m.patient_id,
@@ -274,8 +317,8 @@ fn to_resp(m: dialysis_record::Model) -> DialysisRecordResp {
dialysis_duration: m.dialysis_duration,
blood_flow_rate: m.blood_flow_rate,
dialysis_type: m.dialysis_type,
symptoms: m.symptoms,
complication_notes: m.complication_notes,
symptoms,
complication_notes,
status: m.status,
reviewed_by: m.reviewed_by,
reviewed_at: m.reviewed_at,

View File

@@ -3,6 +3,7 @@
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::crypto as pii;
use erp_core::events::DomainEvent;
use num_traits::ToPrimitive;
use sea_orm::entity::prelude::*;
@@ -271,12 +272,27 @@ pub async fn list_lab_reports(
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
let kek = state.crypto.kek();
let data = models.into_iter().map(|m| {
// 解密 items JSON加密时存储为 Value::String(ciphertext)
let items = m.items.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.items.clone());
// 解密医生备注
let doctor_notes = m.doctor_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.doctor_notes.clone());
LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items, image_urls: m.image_urls, doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
@@ -298,6 +314,21 @@ pub async fn create_lab_report(
.await?
.ok_or(HealthError::PatientNotFound)?;
let kek = state.crypto.kek();
// PII 加密
let encrypted_items = req.items.as_ref()
.map(|v| -> HealthResult<serde_json::Value> {
let json_str = serde_json::to_string(v)
.map_err(|e| HealthError::Validation(e.to_string()))?;
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
})
.transpose()?;
let encrypted_doctor_notes = req.doctor_notes.as_ref()
.map(|c| pii::encrypt(kek, c))
.transpose()?;
let now = Utc::now();
let active = lab_report::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -306,9 +337,9 @@ pub async fn create_lab_report(
report_date: Set(req.report_date),
report_type: Set(req.report_type),
source: Set(req.source),
items: Set(req.items),
items: Set(encrypted_items),
image_urls: Set(req.image_urls),
doctor_notes: Set(req.doctor_notes),
doctor_notes: Set(encrypted_doctor_notes),
status: Set("pending".to_string()),
reviewed_by: Set(None),
reviewed_at: Set(None),
@@ -318,6 +349,7 @@ pub async fn create_lab_report(
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
key_version: Set(Some(1)),
};
let m = active.insert(&state.db).await?;
@@ -334,10 +366,21 @@ pub async fn create_lab_report(
&state.db,
).await;
// 解密返回
let decrypted_items = m.items.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.items);
let decrypted_doctor_notes = m.doctor_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.doctor_notes);
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
@@ -367,12 +410,23 @@ pub async fn update_lab_report(
if let Some(v) = req.report_date { active.report_date = Set(v); }
if let Some(v) = req.report_type { active.report_type = Set(v); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.items { active.items = Set(Some(v)); }
if let Some(v) = req.items {
let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
));
active.items = Set(encrypted);
}
if let Some(v) = req.image_urls { active.image_urls = Set(Some(v)); }
if let Some(v) = req.doctor_notes { active.doctor_notes = Set(Some(v)); }
if let Some(v) = req.doctor_notes {
let kek = state.crypto.kek();
let encrypted = pii::encrypt(kek, &v)?;
active.doctor_notes = Set(Some(encrypted));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.key_version = Set(Some(1));
let m = active.update(&state.db).await?;
@@ -382,10 +436,22 @@ pub async fn update_lab_report(
&state.db,
).await;
// 解密返回
let kek = state.crypto.kek();
let decrypted_items = m.items.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.items);
let decrypted_doctor_notes = m.doctor_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.doctor_notes);
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
@@ -450,11 +516,22 @@ pub async fn review_lab_report(
active.status = Set("reviewed".to_string());
active.reviewed_by = Set(Some(reviewer_id));
active.reviewed_at = Set(Some(Utc::now()));
if let Some(v) = req.doctor_notes { active.doctor_notes = Set(Some(v)); }
if let Some(v) = req.items { active.items = Set(Some(v)); }
if let Some(v) = req.doctor_notes {
let kek = state.crypto.kek();
let encrypted = pii::encrypt(kek, &v)?;
active.doctor_notes = Set(Some(encrypted));
}
if let Some(v) = req.items {
let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
));
active.items = Set(encrypted);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(reviewer_id));
active.version = Set(next_ver);
active.key_version = Set(Some(1));
let m = active.update(&state.db).await?;
@@ -464,10 +541,22 @@ pub async fn review_lab_report(
&state.db,
).await;
// 解密返回
let kek = state.crypto.kek();
let decrypted_items = m.items.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.items);
let decrypted_doctor_notes = m.doctor_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.doctor_notes);
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})

View File

@@ -68,6 +68,9 @@ 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;
mod m20260427_000069_add_dialysis_record_key_version;
mod m20260427_000070_add_lab_report_key_version;
mod m20260427_000071_add_diagnosis_key_version;
pub struct Migrator;
@@ -143,6 +146,9 @@ impl MigratorTrait for Migrator {
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),
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),
]
}
}

View File

@@ -0,0 +1,39 @@
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(DialysisRecord::Table)
.add_column(ColumnDef::new(DialysisRecord::KeyVersion).integer().null())
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(DialysisRecord::Table)
.drop_column(DialysisRecord::KeyVersion)
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum DialysisRecord {
Table,
KeyVersion,
}

View File

@@ -0,0 +1,39 @@
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(LabReport::Table)
.add_column(ColumnDef::new(LabReport::KeyVersion).integer().null())
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(LabReport::Table)
.drop_column(LabReport::KeyVersion)
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum LabReport {
Table,
KeyVersion,
}

View File

@@ -0,0 +1,39 @@
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(Diagnosis::Table)
.add_column(ColumnDef::new(Diagnosis::KeyVersion).integer().null())
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Diagnosis::Table)
.drop_column(Diagnosis::KeyVersion)
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Diagnosis {
Table,
KeyVersion,
}