feat(health): consultation_message + follow_up_record 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

- 迁移 m000065/m000066: 添加 key_version 列
- consultation_message: content 加密写入 + 解密读取
- follow_up_record: result/patient_condition/medical_advice 加密
- Entity: 添加 key_version 字段
This commit is contained in:
iven
2026-04-26 12:17:49 +08:00
parent e6f036eaf4
commit 1b3caf0e69
7 changed files with 121 additions and 15 deletions

View File

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

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

@@ -16,6 +16,7 @@ use crate::entity::{consultation_message, consultation_session, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_sender_role, validate_content_type, validate_consultation_type};
use crate::state::HealthState;
use erp_core::crypto as pii;
// ---------------------------------------------------------------------------
// 咨询会话
@@ -249,10 +250,14 @@ pub async fn list_messages(
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| MessageResp {
id: m.id, session_id: m.session_id, sender_id: m.sender_id,
sender_role: m.sender_role, content_type: m.content_type,
content: m.content, is_read: m.is_read, created_at: m.created_at,
let kek = state.crypto.kek();
let data = models.into_iter().map(|m| {
let content = pii::decrypt(kek, &m.content).unwrap_or(m.content);
MessageResp {
id: m.id, session_id: m.session_id, sender_id: m.sender_id,
sender_role: m.sender_role, content_type: m.content_type,
content, is_read: m.is_read, created_at: m.created_at,
}
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
@@ -301,7 +306,7 @@ pub async fn create_message(
sender_id: Set(sender_id),
sender_role: Set(req.sender_role),
content_type: Set(content_type),
content: Set(req.content),
content: Set(pii::encrypt(state.crypto.kek(), &req.content)?),
is_read: Set(false),
created_at: Set(now),
updated_at: Set(now),
@@ -309,6 +314,7 @@ pub async fn create_message(
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
key_version: Set(Some(1)),
};
let m = active.insert(&txn).await?;
@@ -351,9 +357,10 @@ pub async fn create_message(
&state.db,
).await;
let decrypted_content = pii::decrypt(state.crypto.kek(), &m.content).unwrap_or(m.content);
Ok(MessageResp {
id: m.id, session_id: m.session_id, sender_id: m.sender_id,
sender_role: m.sender_role, content_type: m.content_type,
content: m.content, is_read: m.is_read, created_at: m.created_at,
content: decrypted_content, is_read: m.is_read, created_at: m.created_at,
})
}

View File

@@ -16,6 +16,7 @@ use crate::entity::{follow_up_record, follow_up_task, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_follow_up_type;
use crate::state::HealthState;
use erp_core::crypto as pii;
// ---------------------------------------------------------------------------
// 随访任务
@@ -257,15 +258,18 @@ pub async fn create_record(
// 事务包裹:插入记录 + 更新任务状态 + 创建后续任务
let txn = state.db.begin().await?;
let kek = state.crypto.kek();
let record_active = follow_up_record::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
task_id: Set(req.task_id),
executed_by: Set(req.executed_by.or(operator_id)),
executed_date: Set(req.executed_date),
result: Set(req.result),
patient_condition: Set(req.patient_condition),
medical_advice: Set(req.medical_advice),
result: Set(pii::encrypt(kek, &req.result)?),
patient_condition: Set(req.patient_condition.as_ref()
.map(|p| pii::encrypt(kek, p)).transpose()?),
medical_advice: Set(req.medical_advice.as_ref()
.map(|m| pii::encrypt(kek, m)).transpose()?),
next_follow_up_date: Set(req.next_follow_up_date),
created_at: Set(now),
updated_at: Set(now),
@@ -273,6 +277,7 @@ pub async fn create_record(
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
key_version: Set(Some(1)),
};
let record = record_active.insert(&txn).await?;
@@ -371,12 +376,20 @@ pub async fn list_records(
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| FollowUpRecordResp {
id: m.id, task_id: m.task_id, executed_by: m.executed_by,
executed_date: m.executed_date, result: m.result,
patient_condition: m.patient_condition, medical_advice: m.medical_advice,
next_follow_up_date: m.next_follow_up_date,
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| {
let result = pii::decrypt(kek, &m.result).unwrap_or(m.result);
let patient_condition = m.patient_condition.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()));
let medical_advice = m.medical_advice.as_ref()
.map(|a| pii::decrypt(kek, a).unwrap_or_else(|_| a.clone()));
FollowUpRecordResp {
id: m.id, task_id: m.task_id, executed_by: m.executed_by,
executed_date: m.executed_date, result,
patient_condition, medical_advice,
next_follow_up_date: m.next_follow_up_date,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })

View File

@@ -64,6 +64,8 @@ mod m20260426_000061_create_consent;
mod m20260427_000062_create_tenant_crypto_keys;
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;
pub struct Migrator;
@@ -135,6 +137,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260427_000062_create_tenant_crypto_keys::Migration),
Box::new(m20260427_000063_content_management::Migration),
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),
]
}
}

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