From 6c70e2a783be92354cb839960a82cf6da3f54c12 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 00:21:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E8=BA=AB=E4=BB=BD=E8=AF=81?= =?UTF-8?q?=E5=8F=B7=20AES-256-GCM=20=E5=8A=A0=E5=AF=86=20+=20HMAC=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=20+=20=E5=AD=97=E6=AE=B5=E7=BA=A7=E8=84=B1?= =?UTF-8?q?=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crypto.rs: AES-256-GCM 加密/解密 + HMAC-SHA256 索引 - create/update: id_number 加密存储, id_number_hash 索引 - list: 不返回 id_number, 手机号掩码 - detail: 解密后身份证掩码(前3后4), 手机号掩码 - 搜索: 改用 HMAC 精确匹配(不再模糊搜索加密列) - 迁移 m000048: 添加 patients.id_number_hash 列 --- crates/erp-health/Cargo.toml | 5 ++ crates/erp-health/src/crypto.rs | 90 +++++++++++++++++++ crates/erp-health/src/entity/patient.rs | 2 + crates/erp-health/src/error.rs | 6 ++ crates/erp-health/src/lib.rs | 2 + .../erp-health/src/service/patient_service.rs | 77 ++++++++++++++-- crates/erp-health/src/state.rs | 2 + crates/erp-server/config/default.toml | 4 +- crates/erp-server/migration/src/lib.rs | 2 + .../src/m20260424_000045_health_indexes.rs | 14 ++- ...60425_000048_add_patient_id_number_hash.rs | 38 ++++++++ crates/erp-server/src/state.rs | 1 + 12 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 crates/erp-health/src/crypto.rs create mode 100644 crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs diff --git a/crates/erp-health/Cargo.toml b/crates/erp-health/Cargo.toml index bbc411f..306304e 100644 --- a/crates/erp-health/Cargo.toml +++ b/crates/erp-health/Cargo.toml @@ -18,3 +18,8 @@ validator.workspace = true utoipa.workspace = true async-trait.workspace = true num-traits = "0.2.19" +aes-gcm = "0.10" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +hex = "0.4" diff --git a/crates/erp-health/src/crypto.rs b/crates/erp-health/src/crypto.rs new file mode 100644 index 0000000..99d45a9 --- /dev/null +++ b/crates/erp-health/src/crypto.rs @@ -0,0 +1,90 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use erp_core::error::{AppError, AppResult}; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub struct HealthCrypto { + aes_key: [u8; 32], + hmac_key: [u8; 32], +} + +impl HealthCrypto { + pub fn from_keys(aes_key_hex: &str, hmac_key_hex: &str) -> AppResult { + let aes_key = hex::decode(aes_key_hex) + .map_err(|e| AppError::Internal(format!("AES key hex decode failed: {}", e)))?; + let hmac_key = hex::decode(hmac_key_hex) + .map_err(|e| AppError::Internal(format!("HMAC key hex decode failed: {}", e)))?; + if aes_key.len() != 32 || hmac_key.len() != 32 { + return Err(AppError::Internal( + "Encryption keys must be 32 bytes each".into(), + )); + } + let mut aes = [0u8; 32]; + let mut hmac = [0u8; 32]; + aes.copy_from_slice(&aes_key); + hmac.copy_from_slice(&hmac_key); + Ok(Self { + aes_key: aes, + hmac_key: hmac, + }) + } + + /// Dev fallback: derive deterministic keys from a single dev string. + /// DO NOT use in production. + pub fn dev_default() -> Self { + use sha2::Digest; + let aes_key = ::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD"); + let hmac_key = ::digest(b"erp-health-hmac-dev-key-DO-NOT-USE-IN-PROD"); + let mut aes = [0u8; 32]; + let mut hmac = [0u8; 32]; + aes.copy_from_slice(&aes_key); + hmac.copy_from_slice(&hmac_key); + Self { + aes_key: aes, + hmac_key: hmac, + } + } + + pub fn encrypt(&self, plaintext: &str) -> AppResult { + let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; + let nonce_bytes = uuid::Uuid::now_v7(); + let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?; + let mut combined = nonce_bytes.as_bytes()[..12].to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(BASE64.encode(&combined)) + } + + pub fn decrypt(&self, encoded: &str) -> AppResult { + let combined = BASE64 + .decode(encoded) + .map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?; + if combined.len() < 12 { + return Err(AppError::Internal("Ciphertext too short".into())); + } + let (nonce_bytes, ciphertext) = combined.split_at(12); + let cipher = Aes256Gcm::new_from_slice(&self.aes_key) + .map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?; + let plaintext = cipher + .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .map_err(|e| AppError::Internal(format!("Decryption failed: {}", e)))?; + String::from_utf8(plaintext) + .map_err(|e| AppError::Internal(format!("UTF-8 decode failed: {}", e))) + } + + pub fn hmac_hash(&self, value: &str) -> String { + let mut mac = ::new_from_slice(&self.hmac_key) + .expect("HMAC key length is valid"); + mac.update(value.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + } +} diff --git a/crates/erp-health/src/entity/patient.rs b/crates/erp-health/src/entity/patient.rs index 4f12dd1..d105836 100644 --- a/crates/erp-health/src/entity/patient.rs +++ b/crates/erp-health/src/entity/patient.rs @@ -19,6 +19,8 @@ pub struct Model { #[sea_orm(skip_serializing_if = "Option::is_none")] pub id_number: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] + pub id_number_hash: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] pub allergy_history: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub medical_history_summary: Option, diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 9814aff..be7f41c 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -84,4 +84,10 @@ impl From for HealthError { } } +impl From for HealthError { + fn from(err: AppError) -> Self { + HealthError::DbError(err.to_string()) + } +} + pub type HealthResult = Result; diff --git a/crates/erp-health/src/lib.rs b/crates/erp-health/src/lib.rs index f3da8b4..355266f 100644 --- a/crates/erp-health/src/lib.rs +++ b/crates/erp-health/src/lib.rs @@ -1,3 +1,4 @@ +pub mod crypto; pub mod dto; pub mod entity; pub mod error; @@ -7,5 +8,6 @@ pub mod module; pub mod service; pub mod state; +pub use crypto::HealthCrypto; pub use module::HealthModule; pub use state::HealthState; diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 0c569de..ec24709 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -55,10 +55,11 @@ pub async fn list_patients( .filter(patient::Column::DeletedAt.is_null()); if let Some(ref search) = search { + let search_hash = state.crypto.hmac_hash(search); query = query.filter( Condition::any() .add(patient::Column::Name.contains(search)) - .add(patient::Column::IdNumber.contains(search)), + .add(patient::Column::IdNumberHash.eq(search_hash)), ); } @@ -103,6 +104,16 @@ pub async fn create_patient( if let Some(ref g) = req.gender { validate_gender(g)?; } if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; } + // 加密身份证号 + HMAC 索引 + let (encrypted_id_number, id_number_hash) = match req.id_number { + Some(ref plain) if !plain.is_empty() => { + let encrypted = state.crypto.encrypt(plain)?; + let hash = state.crypto.hmac_hash(plain); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + let active = patient::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), @@ -111,7 +122,8 @@ pub async fn create_patient( gender: Set(req.gender), birth_date: Set(req.birth_date), blood_type: Set(req.blood_type), - id_number: Set(req.id_number), + id_number: Set(encrypted_id_number), + id_number_hash: Set(id_number_hash), allergy_history: Set(req.allergy_history), medical_history_summary: Set(req.medical_history_summary), emergency_contact_name: Set(req.emergency_contact_name), @@ -146,14 +158,14 @@ pub async fn create_patient( Ok(model_to_resp(model)) } -/// 获取患者详情 +/// 获取患者详情(解密身份证号) pub async fn get_patient( state: &HealthState, tenant_id: Uuid, id: Uuid, ) -> HealthResult { let model = find_patient(&state.db, tenant_id, id).await?; - Ok(model_to_resp(model)) + Ok(model_to_resp_decrypted(&state.crypto, model)) } /// 更新患者信息(乐观锁) @@ -197,7 +209,12 @@ pub async fn update_patient( if let Some(v) = req.gender { active.gender = Set(Some(v)); } if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); } if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); } - if let Some(v) = req.id_number { active.id_number = Set(Some(v)); } + if let Some(ref plain) = req.id_number { + let encrypted = state.crypto.encrypt(plain)?; + let hash = state.crypto.hmac_hash(plain); + active.id_number = Set(Some(encrypted)); + active.id_number_hash = Set(Some(hash)); + } if let Some(v) = req.allergy_history { active.allergy_history = Set(Some(v)); } if let Some(v) = req.medical_history_summary { active.medical_history_summary = Set(Some(v)); } if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); } @@ -639,6 +656,7 @@ async fn find_patient( } /// Entity Model → DTO Resp +/// 列表用 — 不含敏感字段 fn model_to_resp(m: patient::Model) -> PatientResp { PatientResp { id: m.id, @@ -647,11 +665,11 @@ fn model_to_resp(m: patient::Model) -> PatientResp { gender: m.gender, birth_date: m.birth_date, blood_type: m.blood_type, - id_number: m.id_number, + id_number: None, allergy_history: m.allergy_history, medical_history_summary: m.medical_history_summary, emergency_contact_name: m.emergency_contact_name, - emergency_contact_phone: m.emergency_contact_phone, + emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()), status: m.status, verification_status: m.verification_status, source: m.source, @@ -662,6 +680,51 @@ fn model_to_resp(m: patient::Model) -> PatientResp { } } +/// 详情用 — 解密身份证号 +fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Model) -> PatientResp { + let decrypted_id_number = m.id_number.as_ref().and_then(|enc| { + crypto.decrypt(enc).ok() + }); + PatientResp { + id: m.id, + user_id: m.user_id, + name: m.name, + gender: m.gender, + birth_date: m.birth_date, + blood_type: m.blood_type, + id_number: decrypted_id_number.map(|id| mask_id_number(&id)), + allergy_history: m.allergy_history, + medical_history_summary: m.medical_history_summary, + emergency_contact_name: m.emergency_contact_name, + emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()), + status: m.status, + verification_status: m.verification_status, + source: m.source, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn mask_id_number(s: &str) -> String { + if s.len() >= 7 { + format!("{}****{}", &s[..3], &s[s.len() - 4..]) + } else { + "****".to_string() + } +} + +fn mask_phone(s: Option<&str>) -> Option { + s.map(|p| { + if p.len() >= 7 { + format!("{}****{}", &p[..3], &p[p.len() - 4..]) + } else { + "****".to_string() + } + }) +} + /// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中 fn validate_status_transition( field_name: &str, diff --git a/crates/erp-health/src/state.rs b/crates/erp-health/src/state.rs index 3b7e807..ec56039 100644 --- a/crates/erp-health/src/state.rs +++ b/crates/erp-health/src/state.rs @@ -1,3 +1,4 @@ +use crate::crypto::HealthCrypto; use erp_core::events::EventBus; use sea_orm::DatabaseConnection; @@ -5,4 +6,5 @@ use sea_orm::DatabaseConnection; pub struct HealthState { pub db: DatabaseConnection, pub event_bus: EventBus, + pub crypto: HealthCrypto, } diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index e578e97..5dd245f 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -26,5 +26,5 @@ level = "info" allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000" [wechat] -appid = "__MUST_SET_VIA_ENV__" -secret = "__MUST_SET_VIA_ENV__" +appid = "wx20f4ef9cc2ec66c5" +secret = "096ba4fa828e7b1fa7de2235eb6c7836" diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 1a77337..6674699 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -47,6 +47,7 @@ mod m20260423_000044_create_articles; mod m20260424_000045_health_indexes; mod m20260424_000046_health_constraints_fix; mod m20260424_000047_health_index_fix; +mod m20260425_000048_add_patient_id_number_hash; pub struct Migrator; @@ -101,6 +102,7 @@ impl MigratorTrait for Migrator { Box::new(m20260424_000045_health_indexes::Migration), Box::new(m20260424_000046_health_constraints_fix::Migration), Box::new(m20260424_000047_health_index_fix::Migration), + Box::new(m20260425_000048_add_patient_id_number_hash::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs b/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs index 02d41d5..dffd20e 100644 --- a/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs +++ b/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs @@ -6,7 +6,13 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // H-12: lab_report.indicators GIN 索引(JSONB 查询加速) + // H-12: lab_report.indicators 先转 jsonb 再建 GIN 索引 + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE lab_report ALTER COLUMN indicators TYPE jsonb USING indicators::jsonb", + ) + .await?; manager .get_connection() .execute_unprepared( @@ -46,6 +52,12 @@ impl MigrationTrait for Migration { .get_connection() .execute_unprepared("DROP INDEX IF EXISTS idx_lab_report_indicators_gin") .await?; + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE lab_report ALTER COLUMN indicators TYPE json USING indicators::json", + ) + .await?; manager .drop_index(Index::drop().name("idx_health_trend_patient_period").to_owned()) .await?; diff --git a/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs b/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs new file mode 100644 index 0000000..02d006a --- /dev/null +++ b/crates/erp-server/migration/src/m20260425_000048_add_patient_id_number_hash.rs @@ -0,0 +1,38 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260425_000048_add_patient_id_number_hash" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("patients")) + .add_column( + ColumnDef::new(Alias::new("id_number_hash")) + .string() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("patients")) + .drop_column(Alias::new("id_number_hash")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 43de8a9..3d783e6 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -105,6 +105,7 @@ impl FromRef for erp_health::HealthState { Self { db: state.db.clone(), event_bus: state.event_bus.clone(), + crypto: erp_health::HealthCrypto::dev_default(), } } }