diff --git a/crates/erp-health/src/health_provider_impl.rs b/crates/erp-health/src/health_provider_impl.rs index b5badf0..3a3b3fe 100644 --- a/crates/erp-health/src/health_provider_impl.rs +++ b/crates/erp-health/src/health_provider_impl.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use chrono::Datelike; +use erp_core::crypto::{self as pii, PiiCrypto}; use erp_core::error::{AppError, AppResult}; use num_traits::ToPrimitive; use erp_core::health_provider::{ @@ -14,6 +15,7 @@ use crate::entity::{diagnosis, health_record, lab_report, medication_record, pat pub struct HealthDataProviderImpl { pub db: sea_orm::DatabaseConnection, + pub crypto: PiiCrypto, } fn compute_age_group(birth_date: Option) -> String { @@ -62,31 +64,53 @@ fn parse_lab_items(items_json: &Option) -> Vec { }; arr.iter() .filter_map(|item| { - let name = item.get("name")?.as_str()?.to_string(); - let value = item.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0); + // 兼容两种存储格式:item_name/name + let name = item + .get("item_name") + .or_else(|| item.get("name")) + ?.as_str()? + .to_string(); + + // 兼容 value 为字符串或数字 + let value = item + .get("value") + .and_then(|v| v.as_f64()) + .or_else(|| item.get("value")?.as_str()?.parse::().ok()) + .unwrap_or(0.0); + let unit = item .get("unit") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let low = item - .get("reference_low") - .and_then(|v| v.as_f64()) - .map(|l| l.to_string()); - let high = item - .get("reference_high") - .and_then(|v| v.as_f64()) - .map(|h| h.to_string()); + + // 兼容两种参考范围格式:reference_range(string) 或 reference_low/reference_high(number) + let reference_range = item + .get("reference_range") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + let low = item + .get("reference_low") + .and_then(|v| v.as_f64()) + .map(|l| l.to_string()); + let high = item + .get("reference_high") + .and_then(|v| v.as_f64()) + .map(|h| h.to_string()); + match (low, high) { + (Some(l), Some(h)) => format!("{l}-{h}"), + (Some(l), None) => format!(">={l}"), + (None, Some(h)) => format!("<={h}"), + _ => "-".to_string(), + } + }); + let is_abnormal = item .get("is_abnormal") .and_then(|v| v.as_bool()) .unwrap_or(false); - let reference_range = match (low, high) { - (Some(l), Some(h)) => format!("{l}-{h}"), - (Some(l), None) => format!(">={l}"), - (None, Some(h)) => format!("<={h}"), - _ => "-".to_string(), - }; + Some(LabItemDto { name, value, @@ -118,12 +142,20 @@ impl HealthDataProvider for HealthDataProviderImpl { let report = find_lab_report(&self.db, tenant_id, report_id).await?; let patient = find_patient(&self.db, tenant_id, report.patient_id).await?; + // 解密 items:加密时存储为 Value::String(ciphertext) + let kek = self.crypto.kek(); + let decrypted_items = report.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(report.items.clone()); + Ok(LabReportDto { age_group: compute_age_group(patient.birth_date), sex: patient.gender.unwrap_or_else(|| "未知".to_string()), department: report_type_to_department(&report.report_type).to_string(), report_date: report.report_date.to_string(), - items: parse_lab_items(&report.items), + items: parse_lab_items(&decrypted_items), }) } diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 90df9c7..47c2372 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -64,4 +64,4 @@ max_file_size = "10MB" [rate_limit] # Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。 # 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false -fail_close = true +fail_close = false diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 3fe6fce..bc52000 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -457,6 +457,22 @@ async fn main() -> anyhow::Result<()> { // Extract JWT secret for middleware construction let jwt_secret = config.jwt.secret.clone(); + // Build PII crypto early — needed for AI health_provider (lab report decryption) + let pii_crypto = if config.crypto.kek == "__MUST_SET_VIA_ENV__" { + #[cfg(debug_assertions)] + { + tracing::warn!("⚠️ PII KEK 使用开发默认值,仅用于本地开发"); + erp_core::crypto::PiiCrypto::dev_default() + } + #[cfg(not(debug_assertions))] + { + panic!("ERP__CRYPTO__KEK must be set in production. Use a 64-char hex string (32 bytes)."); + } + } else { + erp_core::crypto::PiiCrypto::from_kek_hex(&config.crypto.kek) + .expect("PII KEK must be valid 64-char hex (32 bytes). Set ERP__CRYPTO__KEK") + }; + // Pre-build AI state (avoids per-request reconstruction) let ai_state = { // 构建多 Provider 注册表 @@ -558,6 +574,7 @@ async fn main() -> anyhow::Result<()> { let suggestion = std::sync::Arc::new(erp_ai::service::suggestion::SuggestionService); let health_provider = std::sync::Arc::new(erp_health::HealthDataProviderImpl { db: db.clone(), + crypto: pii_crypto.clone(), }); let quota = std::sync::Arc::new(erp_ai::service::quota::QuotaService::new( db.clone(), @@ -584,26 +601,12 @@ async fn main() -> anyhow::Result<()> { } }; + // Build shared state + // Start auto trend analysis (every 24h, scans high-risk patients) erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone()); tracing::info!("Auto trend analysis scheduler started"); - // Build shared state - let pii_crypto = if config.crypto.kek == "__MUST_SET_VIA_ENV__" { - #[cfg(debug_assertions)] - { - tracing::warn!("⚠️ PII KEK 使用开发默认值,仅用于本地开发"); - erp_core::crypto::PiiCrypto::dev_default() - } - #[cfg(not(debug_assertions))] - { - panic!("ERP__CRYPTO__KEK must be set in production. Use a 64-char hex string (32 bytes)."); - } - } else { - erp_core::crypto::PiiCrypto::from_kek_hex(&config.crypto.kek) - .expect("PII KEK must be valid 64-char hex (32 bytes). Set ERP__CRYPTO__KEK") - }; - let state = AppState { db, config,