From 15b6bec2156b9a152df6d0f160522a768c46b06a Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 26 Jun 2026 17:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20B5=20=E4=B8=AA=E4=BF=9D?= =?UTF-8?q?=E6=B3=95=20=C2=A745=20=E6=82=A3=E8=80=85=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8F=AF=E6=90=BA=E6=9D=83=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 删除权留后续。 --- crates/erp-health/src/dto/export_dto.rs | 66 +++++ crates/erp-health/src/dto/mod.rs | 1 + crates/erp-health/src/event/mod.rs | 2 + .../erp-health/src/handler/patient_handler.rs | 49 +++ crates/erp-health/src/routes/patient.rs | 5 + .../src/service/patient_service/crud.rs | 13 + .../src/service/patient_service/export.rs | 278 ++++++++++++++++++ .../src/service/patient_service/helper.rs | 34 ++- .../src/service/patient_service/mod.rs | 9 +- crates/erp-server/migration/src/lib.rs | 2 + ...6_000171_seed_patient_export_permission.rs | 95 ++++++ crates/erp-server/tests/integration.rs | 2 + .../tests/integration/auth_tests.rs | 2 + .../health_patient_export_tests.rs | 238 +++++++++++++++ 14 files changed, 792 insertions(+), 4 deletions(-) create mode 100644 crates/erp-health/src/dto/export_dto.rs create mode 100644 crates/erp-health/src/service/patient_service/export.rs create mode 100644 crates/erp-server/migration/src/m20260626_000171_seed_patient_export_permission.rs create mode 100644 crates/erp-server/tests/integration/health_patient_export_tests.rs diff --git a/crates/erp-health/src/dto/export_dto.rs b/crates/erp-health/src/dto/export_dto.rs new file mode 100644 index 0000000..960e11d --- /dev/null +++ b/crates/erp-health/src/dto/export_dto.rs @@ -0,0 +1,66 @@ +//! 患者数据导出 DTO(个保法 §45 数据可携权) +//! +//! 双格式分工: +//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据) +//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏(标准化互操作) + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::IntoParams; + +/// 导出格式 +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, utoipa::ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + /// 自定义 JSON(明文 PII,可携权本意) + #[default] + Json, + /// FHIR R4 Bundle(标准化互操作,PII 脱敏) + Fhir, +} + +impl ExportFormat { + pub fn as_str(&self) -> &'static str { + match self { + Self::Json => "json", + Self::Fhir => "fhir", + } + } +} + +/// 导出查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct ExportQuery { + /// 导出格式:json(默认,明文)/ fhir(标准化 Bundle) + pub format: Option, +} + +impl ExportQuery { + /// 解析 format 参数,未知值/缺省回退到 json + pub fn parse_format(&self) -> ExportFormat { + match self + .format + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("fhir") => ExportFormat::Fhir, + _ => ExportFormat::Json, + } + } +} + +/// 导出响应 +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ExportResp { + /// 导出格式 + pub format: ExportFormat, + /// 导出时间 + pub exported_at: DateTime, + /// 各资源类型数量统计(如 `{"observations":12,"appointments":3}`) + pub resource_counts: serde_json::Value, + /// 是否因 limit 截断(MVP 同步导出,大数据量时为 true) + pub truncated: bool, + /// 导出载荷(JSON 明文结构 或 FHIR Bundle) + pub payload: serde_json::Value, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 070395d..0b1f180 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -9,6 +9,7 @@ pub mod consultation_dto; pub mod daily_monitoring_dto; pub mod diagnosis_dto; pub mod doctor_dto; +pub mod export_dto; pub mod follow_up_dto; pub mod follow_up_template_dto; pub mod health_data_dto; diff --git a/crates/erp-health/src/event/mod.rs b/crates/erp-health/src/event/mod.rs index 9d1909b..b335e6a 100644 --- a/crates/erp-health/src/event/mod.rs +++ b/crates/erp-health/src/event/mod.rs @@ -64,6 +64,8 @@ pub const PATIENT_UPDATED: &str = "patient.updated"; // TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代 pub const PATIENT_VERIFIED: &str = "patient.verified"; pub const PATIENT_DECEASED: &str = "patient.deceased"; +/// 患者数据导出(个保法 §45 数据可携权)— 审计 + 后续可触发导出完成通知 +pub const PATIENT_EXPORTED: &str = "patient.exported"; // 积分 pub const POINTS_EXPIRED: &str = "points.expired"; diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index 0ad8274..d9348e8 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -10,11 +10,13 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::DeleteWithVersion; +use crate::dto::export_dto::{ExportQuery, ExportResp}; use crate::dto::patient_dto::{ BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, PatientSummary, ReferPatientReq, ReferResultResp, UpdatePatientReq, }; +use crate::handler::consent_check::check_consent_active; use crate::service::patient_service; use crate::state::HealthState; @@ -582,3 +584,50 @@ where patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?; Ok(Json(ApiResponse::ok(result))) } + +/// 患者数据导出(个保法 §45 数据可携权) +/// +/// 双格式:`json`(明文 PII,可携本意)/ `fhir`(标准化 Bundle,脱敏)。 +/// 强制 consent 门控 + patient 角色 self-scope(仅导出自己 user_id 关联的档案)。 +#[utoipa::path( + get, + path = "/health/patients/{id}/export", + params( + ("id" = Uuid, Path, description = "患者 ID"), + ExportQuery, + ), + responses( + (status = 200, description = "导出成功", body = ExportResp), + (status = 403, description = "无权限 / 仅能导出自己的数据 / 知情同意未授权"), + (status = 404, description = "患者不存在"), + ), + tag = "患者管理", + security(("bearer_auth" = [])), +)] +pub async fn export_patient( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Query(query): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.export")?; + check_consent_active(&state.db, ctx.tenant_id, id, &ctx).await?; + + // patient 角色 self-scope:只能导出自己 user_id 关联的档案。 + // 用 get_patient_user_id 轻量查询,避免校验阶段解密他人 PII。 + if ctx.roles.iter().any(|r| r == "patient") { + let owner = patient_service::get_patient_user_id(&state, ctx.tenant_id, id).await?; + if owner != Some(ctx.user_id) { + return Err(AppError::Forbidden("只能导出自己的数据".into())); + } + } + + let fmt = query.parse_format(); + let resp = + patient_service::export_patient(&state, ctx.tenant_id, Some(ctx.user_id), id, fmt).await?; + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-health/src/routes/patient.rs b/crates/erp-health/src/routes/patient.rs index 91292c2..6fb1382 100644 --- a/crates/erp-health/src/routes/patient.rs +++ b/crates/erp-health/src/routes/patient.rs @@ -72,6 +72,11 @@ where "/health/patients/{id}/refer", axum::routing::post(patient_handler::refer_patient), ) + // 患者数据导出(个保法 §45 数据可携权) + .route( + "/health/patients/{id}/export", + axum::routing::get(patient_handler::export_patient), + ) // 家庭成员健康代理 — 管理端 .route( "/health/patients/{patient_id}/family-members/{family_member_id}/grant-access", diff --git a/crates/erp-health/src/service/patient_service/crud.rs b/crates/erp-health/src/service/patient_service/crud.rs index fcf75c7..e5b3003 100644 --- a/crates/erp-health/src/service/patient_service/crud.rs +++ b/crates/erp-health/src/service/patient_service/crud.rs @@ -254,6 +254,19 @@ pub async fn get_patient( Ok(model_to_resp_decrypted(&state.crypto, model)) } +/// 查询患者关联的 user_id(仅用于权限 self-scope 校验,不解密 PII) +/// +/// 个保法 §45 导出场景:patient 角色只能导出自己 user_id 关联的档案。 +/// 用此轻量查询避免在校验阶段解密他人 PII(仅读 user_id 非敏感字段)。 +pub async fn get_patient_user_id( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult> { + let model = find_patient(&state.db, tenant_id, id).await?; + Ok(model.user_id) +} + /// 更新患者信息(乐观锁) pub async fn update_patient( state: &HealthState, diff --git a/crates/erp-health/src/service/patient_service/export.rs b/crates/erp-health/src/service/patient_service/export.rs new file mode 100644 index 0000000..082f1f3 --- /dev/null +++ b/crates/erp-health/src/service/patient_service/export.rs @@ -0,0 +1,278 @@ +//! 患者数据导出 Service(个保法 §45 数据可携权) +//! +//! 双格式: +//! - `json` — 自定义 JSON,PII 明文(可携权本意,患者拿到完整数据) +//! - `fhir` — FHIR R4 Bundle,复用现有 converter,PII 天然脱敏 +//! +//! 数据装配逻辑复刻 `fhir::patient_everything`(7 段查询 + 相同 limit), +//! 但走 `/health/` JWT 体系(非 `/fhir` OAuth),并补明文 JSON 格式。 +//! 强制审计 `patient.exported`(new_value 只含 format/counts/标记位,绝不落明文 PII)。 + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::DomainEvent; +use sea_orm::QuerySelect; +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +use crate::dto::export_dto::{ExportFormat, ExportResp}; +use crate::entity::{ + appointment, consultation_session, device_readings, follow_up_task, lab_report, patient, + patient_devices, +}; +use crate::error::HealthResult; +use crate::fhir::converter; +use crate::state::HealthState; + +use super::helper::{find_patient, patient_plaintext_pii}; + +/// 导出限制(与 fhir::patient_everything 对齐,避免大数据量阻塞同步响应) +const OBSERVATIONS_LIMIT: u64 = 200; +const TASKS_LIMIT: u64 = 50; +const REPORTS_LIMIT: u64 = 50; + +/// 装配的患者数据(原始 Model 向量,两格式共享同一批查询) +struct AssembledData { + patient: patient::Model, + readings: Vec, + devices: Vec, + consultations: Vec, + appointments: Vec, + tasks: Vec, + reports: Vec, +} + +/// 导出患者数据(个保法 §45 数据可携权) +/// +/// 强制审计 `patient.exported`(不含明文 PII),并发事件 `patient.exported`。 +/// 日志只记元数据(patient_id/format/counts),绝不记录 payload。 +pub async fn export_patient( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + patient_id: Uuid, + format: ExportFormat, +) -> HealthResult { + tracing::info!( + action = "export_patient", + %patient_id, + %tenant_id, + format = format.as_str(), + "Exporting patient data (PIPL §45)" + ); + + let data = assemble(state, tenant_id, patient_id).await?; + let truncated = data.readings.len() as u64 >= OBSERVATIONS_LIMIT + || data.tasks.len() as u64 >= TASKS_LIMIT + || data.reports.len() as u64 >= REPORTS_LIMIT; + + let (payload, counts) = match format { + ExportFormat::Json => build_json_payload(state, &data), + ExportFormat::Fhir => (build_fhir_bundle(&data), build_counts(&data)), + }; + + let exported_at = Utc::now(); + + // 审计:只记录动作元数据,绝不落明文 PII。 + // json 格式 contains_plaintext_pii=true 标记响应含明文,便于事后追溯。 + let audit_value = serde_json::json!({ + "format": format.as_str(), + "resource_counts": counts.clone(), + "contains_plaintext_pii": format == ExportFormat::Json, + }); + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.exported", "patient") + .with_resource_id(patient_id) + .with_changes(None, Some(audit_value)), + &state.db, + ) + .await; + + // 事件(现有 event/patient.rs 订阅器对 exported 是 no-op,无副作用;留作后续触发通知) + let event = DomainEvent::new( + crate::event::PATIENT_EXPORTED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "patient_id": patient_id, + "format": format.as_str(), + })), + ); + state.event_bus.publish(event, &state.db).await; + + tracing::info!( + action = "export_patient", + %patient_id, + format = format.as_str(), + truncated, + "Patient export completed" + ); + + Ok(ExportResp { + format, + exported_at, + resource_counts: counts, + truncated, + payload, + }) +} + +/// 装配患者全量数据(7 段查询,复刻 fhir::patient_everything 装配逻辑) +async fn assemble( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + let patient = find_patient(&state.db, tenant_id, patient_id).await?; + + let readings = device_readings::Entity::find() + .filter(device_readings::Column::PatientId.eq(patient_id)) + .filter(device_readings::Column::TenantId.eq(tenant_id)) + .filter(device_readings::Column::DeletedAt.is_null()) + .limit(OBSERVATIONS_LIMIT) + .all(&state.db) + .await?; + + let devices = patient_devices::Entity::find() + .filter(patient_devices::Column::PatientId.eq(patient_id)) + .filter(patient_devices::Column::TenantId.eq(tenant_id)) + .filter(patient_devices::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let consultations = consultation_session::Entity::find() + .filter(consultation_session::Column::PatientId.eq(patient_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let appointments = appointment::Entity::find() + .filter(appointment::Column::PatientId.eq(patient_id)) + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let tasks = follow_up_task::Entity::find() + .filter(follow_up_task::Column::PatientId.eq(patient_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .limit(TASKS_LIMIT) + .all(&state.db) + .await?; + + let reports = lab_report::Entity::find() + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .limit(REPORTS_LIMIT) + .all(&state.db) + .await?; + + Ok(AssembledData { + patient, + readings, + devices, + consultations, + appointments, + tasks, + reports, + }) +} + +/// 构建 JSON 明文载荷(解密 PII 不脱敏)+ counts +fn build_json_payload( + state: &HealthState, + data: &AssembledData, +) -> (serde_json::Value, serde_json::Value) { + let pii = patient_plaintext_pii(&state.crypto, &data.patient); + + // data 是引用,非 Copy 字段(String/Option/Vec)须用 & 避免 move out of borrow + let patient_json = serde_json::json!({ + "id": data.patient.id, + "tenant_id": data.patient.tenant_id, + "user_id": data.patient.user_id, + "name": &data.patient.name, + "gender": &data.patient.gender, + "birth_date": data.patient.birth_date, + "blood_type": &data.patient.blood_type, + "id_number": &pii.id_number, + "allergy_history": &pii.allergy_history, + "medical_history_summary": &pii.medical_history_summary, + "emergency_contact_name": &data.patient.emergency_contact_name, + "emergency_contact_phone": &pii.emergency_contact_phone, + "status": &data.patient.status, + "verification_status": &data.patient.verification_status, + "source": &data.patient.source, + "notes": &data.patient.notes, + "created_at": data.patient.created_at, + "updated_at": data.patient.updated_at, + }); + + let payload = serde_json::json!({ + "export": { + "legal_basis": "PIPL §45 数据可携权", + "format": "json", + "note": "包含明文个人身份信息(PII),请妥善保管", + }, + "patient": patient_json, + "device_readings": &data.readings, + "patient_devices": &data.devices, + "consultation_sessions": &data.consultations, + "appointments": &data.appointments, + "follow_up_tasks": &data.tasks, + "lab_reports": &data.reports, + }); + + (payload, build_counts(data)) +} + +/// 构建 FHIR R4 Bundle(复用 converter,PII 天然脱敏) +fn build_fhir_bundle(data: &AssembledData) -> serde_json::Value { + let mut entries = Vec::new(); + + entries.push(serde_json::json!({ + "resource": converter::patient_to_fhir(&data.patient), + "fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", data.patient.id), + })); + for r in &data.readings { + for obs in converter::device_reading_to_fhir_observations(r) { + entries.push(serde_json::json!({ "resource": obs })); + } + } + for d in &data.devices { + entries.push(serde_json::json!({ "resource": converter::patient_device_to_fhir(d) })); + } + for c in &data.consultations { + entries.push(serde_json::json!({ "resource": converter::consultation_to_fhir(c) })); + } + for a in &data.appointments { + entries.push(serde_json::json!({ "resource": converter::appointment_to_fhir(a) })); + } + for t in &data.tasks { + entries.push(serde_json::json!({ "resource": converter::follow_up_to_fhir(t) })); + } + for r in &data.reports { + entries.push(serde_json::json!({ "resource": converter::lab_report_to_fhir(r) })); + } + + serde_json::json!({ + "resourceType": "Bundle", + "type": "collection", + "total": entries.len(), + "entry": entries, + }) +} + +/// 各资源数量统计 +fn build_counts(data: &AssembledData) -> serde_json::Value { + serde_json::json!({ + "observations": data.readings.len(), + "devices": data.devices.len(), + "encounters": data.consultations.len(), + "appointments": data.appointments.len(), + "tasks": data.tasks.len(), + "diagnostic_reports": data.reports.len(), + }) +} diff --git a/crates/erp-health/src/service/patient_service/helper.rs b/crates/erp-health/src/service/patient_service/helper.rs index 4f969ab..028a6c1 100644 --- a/crates/erp-health/src/service/patient_service/helper.rs +++ b/crates/erp-health/src/service/patient_service/helper.rs @@ -92,8 +92,40 @@ pub(crate) fn model_to_resp_decrypted(crypto: &PiiCrypto, m: patient::Model) -> } } +/// 患者明文 PII(仅用于数据导出 §45 可携权,不脱敏) +/// +/// 与 `model_to_resp_decrypted` 区别:后者对 id_number/phone 脱敏用于日常展示; +/// 本结构返回原始明文,专供患者数据可携权导出(个保法 §45)。 +pub(crate) struct PatientPlaintextPii { + pub id_number: Option, + pub allergy_history: Option, + pub medical_history_summary: Option, + pub emergency_contact_phone: Option, +} + +/// 解密患者全部 PII 字段(不脱敏),供数据导出使用 +pub(crate) fn patient_plaintext_pii(crypto: &PiiCrypto, m: &patient::Model) -> PatientPlaintextPii { + let kek = crypto.kek(); + PatientPlaintextPii { + id_number: decrypt_field(kek, &m.id_number, "id_number", m.id), + allergy_history: decrypt_field(kek, &m.allergy_history, "allergy_history", m.id), + medical_history_summary: decrypt_field( + kek, + &m.medical_history_summary, + "medical_history_summary", + m.id, + ), + emergency_contact_phone: decrypt_field( + kek, + &m.emergency_contact_phone, + "emergency_contact_phone", + m.id, + ), + } +} + /// 解密单个 PII 字段,失败时输出 warn 日志并返回 None -fn decrypt_field( +pub(crate) fn decrypt_field( kek: &[u8; 32], field: &Option, name: &str, diff --git a/crates/erp-health/src/service/patient_service/mod.rs b/crates/erp-health/src/service/patient_service/mod.rs index 4fcec37..5fb34f5 100644 --- a/crates/erp-health/src/service/patient_service/mod.rs +++ b/crates/erp-health/src/service/patient_service/mod.rs @@ -1,12 +1,14 @@ -//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要 +//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要、数据导出 //! -//! 按 4 个功能域组织: +//! 按 5 个功能域组织: //! - `crud` — 患者基础 CRUD 操作 +//! - `export` — 患者数据导出(个保法 §45 数据可携权) //! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要 //! - `tag` — 患者标签 CRUD //! - `helper` — 共享辅助函数 mod crud; +mod export; mod helper; mod relation; mod tag; @@ -14,8 +16,9 @@ mod tag; // 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变 pub use crud::{ batch_import_patients, bind_by_phone, create_patient, delete_patient, get_patient, - list_patients, list_summaries, update_patient, + get_patient_user_id, list_patients, list_summaries, update_patient, }; +pub use export::export_patient; pub use relation::{ assign_doctor, create_family_member, delete_family_member, get_health_summary, list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member, diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index d921de7..4dbc842 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260626_000171_seed_patient_export_permission.rs b/crates/erp-server/migration/src/m20260626_000171_seed_patient_export_permission.rs new file mode 100644 index 0000000..c3e4a57 --- /dev/null +++ b/crates/erp-server/migration/src/m20260626_000171_seed_patient_export_permission.rs @@ -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-scope:patient.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::>() + .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(()) +} diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index d43c08f..f271a20 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -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"] diff --git a/crates/erp-server/tests/integration/auth_tests.rs b/crates/erp-server/tests/integration/auth_tests.rs index 1d3fb49..a142b1c 100644 --- a/crates/erp-server/tests/integration/auth_tests.rs +++ b/crates/erp-server/tests/integration/auth_tests.rs @@ -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 diff --git a/crates/erp-server/tests/integration/health_patient_export_tests.rs b/crates/erp-server/tests/integration/health_patient_export_tests.rs new file mode 100644 index 0000000..0cb96a3 --- /dev/null +++ b/crates/erp-server/tests/integration/health_patient_export_tests.rs @@ -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}" + ); +}