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

@@ -177,6 +177,7 @@ mod m20260526_000167_create_ai_knowledge_documents;
mod m20260527_000168_ai_knowledge_v2_menu;
mod m20260529_000169_supplement_rls_for_new_tables;
mod m20260626_000170_extend_device_readings_partitions;
mod m20260626_000171_seed_patient_export_permission;
pub struct Migrator;
@@ -361,6 +362,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
Box::new(m20260626_000170_extend_device_readings_partitions::Migration),
Box::new(m20260626_000171_seed_patient_export_permission::Migration),
]
}
}

View File

@@ -0,0 +1,95 @@
use sea_orm_migration::prelude::*;
/// 个保法 §45 数据可携权:注册 health.patient.export 权限并分配角色
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-0000-0000-0000-000000000000";
// 1) 注册 health.patient.export 权限(跨租户幂等)
db.execute_unprepared(&format!(
"INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT gen_random_uuid(), t.id, '患者数据导出(数据可携权)', 'health.patient.export', 'health', 'export', '个保法 §45 数据可携权:导出患者全量健康数据', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \
FROM tenant t \
WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'health.patient.export' AND p.deleted_at IS NULL)"
)).await?;
// 2) 医护和管理角色data_scope=all可导出任意患者数据
let staff_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"];
for role in staff_roles {
assign_single_perm(db, role, "health.patient.export").await?;
}
// 3) patient 角色data_scope=self仅导出自己的数据
// handler 层 enforce self-scopepatient.user_id == ctx.user_id
assign_perms_by_codes(db, "patient", &["health.patient.export"]).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 移除所有角色的 health.patient.export 关联
db.execute_unprepared(
"DELETE FROM role_permissions \
WHERE permission_id IN (SELECT id FROM permissions WHERE code = 'health.patient.export')",
)
.await?;
// 软删除权限
db.execute_unprepared(
"UPDATE permissions SET deleted_at = NOW() \
WHERE code = 'health.patient.export' AND deleted_at IS NULL",
)
.await?;
Ok(())
}
}
async fn assign_perms_by_codes(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_codes: &[&str],
) -> Result<(), DbErr> {
let codes_csv: String = perm_codes
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(",");
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}
async fn assign_single_perm(
db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>,
role_code: &str,
perm_code: &str,
) -> Result<(), DbErr> {
db.execute_unprepared(&format!(
"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \
SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \
FROM roles r \
JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \
WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \
ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \
DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()"
)).await?;
Ok(())
}

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}"
);
}