feat(health): B5 个保法 §45 患者数据可携权导出

GET /health/patients/{id}/export?format=json|fhir 双格式同步导出:
- json: 明文 PII(解密不脱敏,可携权本意),聚合 7 段数据
- fhir: FHIR R4 Bundle(复用现有 converter,PII 天然脱敏)
- 安全边界:consent 门控 + patient 角色 self-scope + 审计 patient.exported(不含明文 PII)+ 日志不记 payload
- 权限 health.patient.export(医护=all, patient=self),迁移 m20260626_000171
- 事件 patient.exported;6 集成测试全绿

含顺手修复 auth_tests UserService::list 签名 drift(exclude_only_roles),解锁 integration crate 编译。
§47 删除权留后续。
This commit is contained in:
iven
2026-06-26 17:58:20 +08:00
parent 5d256fbf52
commit 15b6bec215
14 changed files with 792 additions and 4 deletions

View File

@@ -32,6 +32,8 @@ mod health_follow_up_template_tests;
mod health_follow_up_tests;
#[path = "integration/health_medication_tests.rs"]
mod health_medication_tests;
#[path = "integration/health_patient_export_tests.rs"]
mod health_patient_export_tests;
#[path = "integration/health_patient_tests.rs"]
mod health_patient_tests;
#[path = "integration/health_pii_encryption_tests.rs"]

View File

@@ -48,6 +48,7 @@ async fn test_user_crud() {
page_size: Some(10),
},
None,
None,
db,
)
.await
@@ -90,6 +91,7 @@ async fn test_tenant_isolation() {
page_size: Some(10),
},
None,
None,
db,
)
.await

View File

@@ -0,0 +1,238 @@
//! 个保法 §45 患者数据导出集成测试
//!
//! 验证双格式导出、明文 PII、跨租户隔离、审计不含明文 PII、FHIR 脱敏。
//! 直接调用 service 层(不走 HTTP/认证/consent与 health_patient_tests.rs 一致。
use erp_core::crypto::PiiCrypto;
use erp_core::entity::audit_log;
use erp_core::events::EventBus;
use erp_health::dto::export_dto::ExportFormat;
use erp_health::dto::patient_dto::CreatePatientReq;
use erp_health::service::patient_service;
use erp_health::state::HealthState;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use super::test_db::TestDb;
/// 构建测试用 HealthState
fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState {
HealthState {
db: db.clone(),
event_bus: EventBus::new(100),
crypto: PiiCrypto::dev_default(),
jwt_secret: "test-jwt-secret".to_string(),
external_health_checks: vec![],
cron_heartbeat: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
/// 构造带完整 PII 的患者请求
fn make_patient_req() -> CreatePatientReq {
CreatePatientReq {
name: "张三".to_string(),
gender: Some("male".to_string()),
birth_date: Some(chrono::NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()),
blood_type: Some("A".to_string()),
id_number: Some("110101199001151234".to_string()),
allergy_history: Some("青霉素过敏".to_string()),
medical_history_summary: Some("高血压病史3年".to_string()),
emergency_contact_name: Some("李四".to_string()),
emergency_contact_phone: Some("13800138000".to_string()),
source: Some("offline".to_string()),
notes: None,
}
}
#[tokio::test]
async fn test_export_json_contains_plaintext_pii() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let patient =
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
.await
.expect("创建患者应成功");
let resp = patient_service::export_patient(
&state,
tenant_id,
Some(operator_id),
patient.id,
ExportFormat::Json,
)
.await
.expect("json 导出应成功");
assert_eq!(resp.format, ExportFormat::Json);
// §45 可携权本意json 导出含明文 PII非脱敏
assert_eq!(
resp.payload["patient"]["id_number"].as_str().unwrap(),
"110101199001151234",
"json 导出应含明文身份证号"
);
assert_eq!(
resp.payload["patient"]["allergy_history"].as_str().unwrap(),
"青霉素过敏"
);
assert_eq!(
resp.payload["patient"]["medical_history_summary"]
.as_str()
.unwrap(),
"高血压病史3年"
);
assert_eq!(
resp.payload["patient"]["emergency_contact_phone"]
.as_str()
.unwrap(),
"13800138000"
);
}
#[tokio::test]
async fn test_export_fhir_returns_bundle() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
.await
.expect("fhir 导出应成功");
assert_eq!(resp.format, ExportFormat::Fhir);
assert_eq!(resp.payload["resourceType"], "Bundle");
assert_eq!(resp.payload["type"], "collection");
assert_eq!(
resp.payload["entry"][0]["resource"]["resourceType"],
"Patient"
);
assert!(
resp.payload["total"].as_u64().unwrap() >= 1,
"Bundle 至少含 Patient 资源"
);
}
#[tokio::test]
async fn test_export_empty_patient_no_error() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let req = CreatePatientReq {
name: "空患者".to_string(),
gender: None,
birth_date: None,
blood_type: None,
id_number: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
source: None,
notes: None,
};
let patient = patient_service::create_patient(&state, tenant_id, None, req)
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Json)
.await
.expect("空患者导出不应报错");
assert_eq!(resp.payload["device_readings"].as_array().unwrap().len(), 0);
assert_eq!(resp.resource_counts["observations"].as_u64().unwrap(), 0);
assert_eq!(resp.resource_counts["appointments"].as_u64().unwrap(), 0);
}
#[tokio::test]
async fn test_export_cross_tenant_isolation() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_a = uuid::Uuid::new_v4();
let tenant_b = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_a, None, make_patient_req())
.await
.expect("创建患者应成功");
// tenant_b 尝试导出 tenant_a 的患者 → find_packet 按 tenant 过滤 → NotFound
let result =
patient_service::export_patient(&state, tenant_b, None, patient.id, ExportFormat::Json)
.await;
assert!(result.is_err(), "跨租户导出应失败");
}
#[tokio::test]
async fn test_export_writes_audit_without_plaintext() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let operator_id = uuid::Uuid::new_v4();
let patient =
patient_service::create_patient(&state, tenant_id, Some(operator_id), make_patient_req())
.await
.expect("创建患者应成功");
patient_service::export_patient(
&state,
tenant_id,
Some(operator_id),
patient.id,
ExportFormat::Json,
)
.await
.expect("导出应成功");
let logs = audit_log::Entity::find()
.filter(audit_log::Column::Action.eq("patient.exported"))
.filter(audit_log::Column::ResourceId.eq(patient.id))
.all(test_db.db())
.await
.expect("查 audit 应成功");
assert_eq!(logs.len(), 1, "应写一条 patient.exported 审计");
let new_value = logs[0].new_value.as_ref().expect("new_value 应存在");
let new_value_str = new_value.to_string();
assert!(new_value_str.contains("json"), "审计应记录 format=json");
assert!(
!new_value_str.contains("110101199001151234"),
"审计 new_value 绝不能含明文身份证号"
);
}
#[tokio::test]
async fn test_export_fhir_redacts_id_number() {
let test_db = TestDb::new().await;
let state = make_state(test_db.db());
let tenant_id = uuid::Uuid::new_v4();
let patient = patient_service::create_patient(&state, tenant_id, None, make_patient_req())
.await
.expect("创建患者应成功");
let resp =
patient_service::export_patient(&state, tenant_id, None, patient.id, ExportFormat::Fhir)
.await
.expect("fhir 导出应成功");
// FHIR Bundle 内 Patient.identifier.value 应脱敏或 REDACTED非明文
let id_val = resp.payload["entry"][0]["resource"]["identifier"][0]["value"]
.as_str()
.unwrap_or("");
assert_ne!(
id_val, "110101199001151234",
"FHIR 路径不应输出明文身份证号"
);
assert!(
id_val.contains('*') || id_val == "[REDACTED]",
"应为脱敏(含*)或 [REDACTED],实际: {id_val}"
);
}