Files
hms/crates/erp-ai/src/sanitization/mod.rs
iven 2660f1afff feat(ai): Phase 2A-3 随访页 AI 辅助生成小结 — SSE 端点 + 前端集成
- AnalysisType 新增 FollowUpSummary 变体(as_str/prompt_name)
- HealthDataProvider 新增 get_follow_up_summary_data() + FollowUpSummaryDataDto
- erp-health 实现随访数据查询(task + records + PII 解密)
- 新增 /ai/analyze/follow-up-summary SSE 端点
- SanitizationService 新增 sanitize_follow_up_data()
- 前端 analysisSse.ts/AiAnalysisCard 支持 follow-up-summary 类型
- FollowUpTaskList 操作列新增「AI 小结」按钮
2026-05-19 00:54:15 +08:00

242 lines
7.7 KiB
Rust

use erp_core::health_provider::{
FollowUpSummaryDataDto, HealthReportDto, LabReportDto, PatientSummaryDto, TrendAnalysisDto,
VitalSignDto,
};
use serde_json::Value;
use crate::error::{AiError, AiResult};
/// 数据脱敏服务 — 确保发送给 AI 的数据不含 PII
/// HealthDataProvider 返回的 DTO 已经是脱敏的(只有年龄/性别/医疗数据)
/// 此服务做二次检查和安全约束注入
pub struct SanitizationService;
impl Default for SanitizationService {
fn default() -> Self {
Self::new()
}
}
impl SanitizationService {
pub fn new() -> Self {
Self
}
pub fn sanitize_lab_report(&self, report: &LabReportDto) -> AiResult<Value> {
let sanitized = serde_json::to_value(report)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
pub fn sanitize_vital_signs(&self, signs: &[VitalSignDto]) -> AiResult<Value> {
let sanitized = serde_json::to_value(signs)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
pub fn sanitize_patient_summary(&self, summary: &PatientSummaryDto) -> AiResult<Value> {
let sanitized = serde_json::to_value(summary)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
pub fn sanitize_health_report(&self, report: &HealthReportDto) -> AiResult<Value> {
let sanitized = serde_json::to_value(report)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
pub fn sanitize_trend_analysis(&self, data: &TrendAnalysisDto) -> AiResult<Value> {
let sanitized = serde_json::to_value(data)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
pub fn sanitize_follow_up_data(&self, data: &FollowUpSummaryDataDto) -> AiResult<Value> {
let sanitized = serde_json::to_value(data)
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
self.verify_no_pii(&sanitized)?;
Ok(sanitized)
}
/// 二次验证: 确保没有意外泄漏的 PII
fn verify_no_pii(&self, value: &Value) -> AiResult<()> {
let pii_keys = [
"name",
"phone",
"id_number",
"address",
"birth_date",
"email",
];
if let Value::Object(map) = value {
for key in pii_keys {
if map.contains_key(key) {
return Err(AiError::SanitizationError(format!(
"检测到疑似 PII 字段: {key}"
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use erp_core::health_provider::{
HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto, ReportSectionDto,
VitalSignDto,
};
fn sanitizer() -> SanitizationService {
SanitizationService::new()
}
fn clean_lab_report() -> LabReportDto {
LabReportDto {
age_group: "中年".to_string(),
sex: "male".to_string(),
department: "内科".to_string(),
report_date: "2026-05-01".to_string(),
items: vec![LabItemDto {
name: "WBC".to_string(),
value: 6.5,
unit: "10^9/L".to_string(),
reference_range: "3.5-9.5".to_string(),
is_abnormal: false,
}],
}
}
// ---- clean data passes ----
#[test]
fn sanitize_lab_report_clean_passes() {
let report = clean_lab_report();
let result = sanitizer().sanitize_lab_report(&report);
assert!(result.is_ok());
let val = result.unwrap();
assert_eq!(val["age_group"], "中年");
assert_eq!(val["items"][0]["name"], "WBC");
}
#[test]
fn sanitize_vital_signs_clean_passes() {
let signs = vec![VitalSignDto {
metric: "血压".to_string(),
values: vec![("2026-05-01".to_string(), 125.0)],
unit: "mmHg".to_string(),
}];
let result = sanitizer().sanitize_vital_signs(&signs);
assert!(result.is_ok());
}
#[test]
fn sanitize_patient_summary_clean_passes() {
let summary = PatientSummaryDto {
age_group: "青年".to_string(),
sex: "female".to_string(),
chronic_conditions: vec!["高血压".to_string()],
medications: vec!["降压药".to_string()],
family_history: vec![],
last_checkup_date: "2026-04-01".to_string(),
};
let result = sanitizer().sanitize_patient_summary(&summary);
assert!(result.is_ok());
}
#[test]
fn sanitize_health_report_clean_passes() {
let report = HealthReportDto {
age_group: "老年".to_string(),
sex: "male".to_string(),
department: "体检中心".to_string(),
report_date: "2026-05-01".to_string(),
sections: vec![ReportSectionDto {
title: "血常规".to_string(),
findings: vec!["WBC 正常".to_string()],
abnormal_items: vec![],
}],
};
let result = sanitizer().sanitize_health_report(&report);
assert!(result.is_ok());
}
// ---- PII detection ----
#[test]
fn sanitize_rejects_data_with_name_field() {
// LabReportDto 没有 name 字段,反序列化会丢弃 PII 字段
// 验证 DTO 结构本身安全
let svc = sanitizer();
let mut polluted = serde_json::to_value(&clean_lab_report()).unwrap();
polluted["name"] = serde_json::json!("张三");
let report: LabReportDto = serde_json::from_value(polluted).unwrap();
let check = svc.sanitize_lab_report(&report);
assert!(check.is_ok());
}
#[test]
fn verify_no_pii_detects_all_pii_keys() {
let svc = sanitizer();
let pii_keys = [
"name",
"phone",
"id_number",
"address",
"birth_date",
"email",
];
for key in pii_keys {
let mut report_json = serde_json::to_value(&clean_lab_report()).unwrap();
report_json[key] = serde_json::json!("test");
let report: LabReportDto = serde_json::from_value(report_json).unwrap();
let result = svc.sanitize_lab_report(&report);
assert!(
result.is_ok(),
"LabReportDto 不包含 {} 字段,反序列化时被丢弃",
key
);
}
}
// ---- verify_no_pii 对原始 JSON 的验证 ----
#[test]
fn dto_serialization_contains_no_pii() {
let report = clean_lab_report();
let val = serde_json::to_value(&report).unwrap();
for key in &[
"name",
"phone",
"id_number",
"address",
"birth_date",
"email",
] {
assert!(
!val.as_object().unwrap().contains_key(*key),
"LabReportDto 不应包含 PII 字段: {}",
key
);
}
}
// ---- 空数据边界 ----
#[test]
fn sanitize_empty_vital_signs() {
let signs: Vec<VitalSignDto> = vec![];
let result = sanitizer().sanitize_vital_signs(&signs);
assert!(result.is_ok());
assert!(result.unwrap().is_array());
}
}