fix(ai): 修复 AI 分析读取化验报告 items 为空的问题
- parse_lab_items 兼容两种存储格式(item_name/name, string/f64 value, reference_range/reference_low+high) - get_lab_report 添加 PII 解密步骤:数据库中 items 是加密存储的, AI 分析前需要先解密再解析 - HealthDataProviderImpl 添加 PiiCrypto 字段用于解密 - pii_crypto 创建提前到 AI state 构建之前 - default.toml rate_limit.fail_close 改为 false(开发环境)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
|
use erp_core::crypto::{self as pii, PiiCrypto};
|
||||||
use erp_core::error::{AppError, AppResult};
|
use erp_core::error::{AppError, AppResult};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use erp_core::health_provider::{
|
use erp_core::health_provider::{
|
||||||
@@ -14,6 +15,7 @@ use crate::entity::{diagnosis, health_record, lab_report, medication_record, pat
|
|||||||
|
|
||||||
pub struct HealthDataProviderImpl {
|
pub struct HealthDataProviderImpl {
|
||||||
pub db: sea_orm::DatabaseConnection,
|
pub db: sea_orm::DatabaseConnection,
|
||||||
|
pub crypto: PiiCrypto,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_age_group(birth_date: Option<chrono::NaiveDate>) -> String {
|
fn compute_age_group(birth_date: Option<chrono::NaiveDate>) -> String {
|
||||||
@@ -62,31 +64,53 @@ fn parse_lab_items(items_json: &Option<serde_json::Value>) -> Vec<LabItemDto> {
|
|||||||
};
|
};
|
||||||
arr.iter()
|
arr.iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
let name = item.get("name")?.as_str()?.to_string();
|
// 兼容两种存储格式:item_name/name
|
||||||
let value = item.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
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::<f64>().ok())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
let unit = item
|
let unit = item
|
||||||
.get("unit")
|
.get("unit")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let low = item
|
|
||||||
.get("reference_low")
|
// 兼容两种参考范围格式:reference_range(string) 或 reference_low/reference_high(number)
|
||||||
.and_then(|v| v.as_f64())
|
let reference_range = item
|
||||||
.map(|l| l.to_string());
|
.get("reference_range")
|
||||||
let high = item
|
.and_then(|v| v.as_str())
|
||||||
.get("reference_high")
|
.map(|s| s.to_string())
|
||||||
.and_then(|v| v.as_f64())
|
.unwrap_or_else(|| {
|
||||||
.map(|h| h.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());
|
||||||
|
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
|
let is_abnormal = item
|
||||||
.get("is_abnormal")
|
.get("is_abnormal")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.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 {
|
Some(LabItemDto {
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
@@ -118,12 +142,20 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
|||||||
let report = find_lab_report(&self.db, tenant_id, report_id).await?;
|
let report = find_lab_report(&self.db, tenant_id, report_id).await?;
|
||||||
let patient = find_patient(&self.db, tenant_id, report.patient_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 {
|
Ok(LabReportDto {
|
||||||
age_group: compute_age_group(patient.birth_date),
|
age_group: compute_age_group(patient.birth_date),
|
||||||
sex: patient.gender.unwrap_or_else(|| "未知".to_string()),
|
sex: patient.gender.unwrap_or_else(|| "未知".to_string()),
|
||||||
department: report_type_to_department(&report.report_type).to_string(),
|
department: report_type_to_department(&report.report_type).to_string(),
|
||||||
report_date: report.report_date.to_string(),
|
report_date: report.report_date.to_string(),
|
||||||
items: parse_lab_items(&report.items),
|
items: parse_lab_items(&decrypted_items),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ max_file_size = "10MB"
|
|||||||
[rate_limit]
|
[rate_limit]
|
||||||
# Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。
|
# Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。
|
||||||
# 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false
|
# 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false
|
||||||
fail_close = true
|
fail_close = false
|
||||||
|
|||||||
@@ -457,6 +457,22 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Extract JWT secret for middleware construction
|
// Extract JWT secret for middleware construction
|
||||||
let jwt_secret = config.jwt.secret.clone();
|
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)
|
// Pre-build AI state (avoids per-request reconstruction)
|
||||||
let ai_state = {
|
let ai_state = {
|
||||||
// 构建多 Provider 注册表
|
// 构建多 Provider 注册表
|
||||||
@@ -558,6 +574,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let suggestion = std::sync::Arc::new(erp_ai::service::suggestion::SuggestionService);
|
let suggestion = std::sync::Arc::new(erp_ai::service::suggestion::SuggestionService);
|
||||||
let health_provider = std::sync::Arc::new(erp_health::HealthDataProviderImpl {
|
let health_provider = std::sync::Arc::new(erp_health::HealthDataProviderImpl {
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
|
crypto: pii_crypto.clone(),
|
||||||
});
|
});
|
||||||
let quota = std::sync::Arc::new(erp_ai::service::quota::QuotaService::new(
|
let quota = std::sync::Arc::new(erp_ai::service::quota::QuotaService::new(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
@@ -584,26 +601,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build shared state
|
||||||
|
|
||||||
// Start auto trend analysis (every 24h, scans high-risk patients)
|
// Start auto trend analysis (every 24h, scans high-risk patients)
|
||||||
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
|
erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone());
|
||||||
tracing::info!("Auto trend analysis scheduler started");
|
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 {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
|
|||||||
Reference in New Issue
Block a user