- 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 小结」按钮
242 lines
7.7 KiB
Rust
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());
|
|
}
|
|
}
|