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

@@ -0,0 +1,66 @@
//! 患者数据导出 DTO个保法 §45 数据可携权)
//!
//! 双格式分工:
//! - `json` — 自定义 JSONPII 明文(可携权本意,患者拿到完整数据)
//! - `fhir` — FHIR R4 Bundle复用现有 converterPII 天然脱敏(标准化互操作)
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<String>,
}
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<Utc>,
/// 各资源类型数量统计(如 `{"observations":12,"appointments":3}`
pub resource_counts: serde_json::Value,
/// 是否因 limit 截断MVP 同步导出,大数据量时为 true
pub truncated: bool,
/// 导出载荷JSON 明文结构 或 FHIR Bundle
pub payload: serde_json::Value,
}

View File

@@ -9,6 +9,7 @@ pub mod consultation_dto;
pub mod daily_monitoring_dto; pub mod daily_monitoring_dto;
pub mod diagnosis_dto; pub mod diagnosis_dto;
pub mod doctor_dto; pub mod doctor_dto;
pub mod export_dto;
pub mod follow_up_dto; pub mod follow_up_dto;
pub mod follow_up_template_dto; pub mod follow_up_template_dto;
pub mod health_data_dto; pub mod health_data_dto;

View File

@@ -64,6 +64,8 @@ pub const PATIENT_UPDATED: &str = "patient.updated";
// TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代 // TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代
pub const PATIENT_VERIFIED: &str = "patient.verified"; pub const PATIENT_VERIFIED: &str = "patient.verified";
pub const PATIENT_DECEASED: &str = "patient.deceased"; pub const PATIENT_DECEASED: &str = "patient.deceased";
/// 患者数据导出(个保法 §45 数据可携权)— 审计 + 后续可触发导出完成通知
pub const PATIENT_EXPORTED: &str = "patient.exported";
// 积分 // 积分
pub const POINTS_EXPIRED: &str = "points.expired"; pub const POINTS_EXPIRED: &str = "points.expired";

View File

@@ -10,11 +10,13 @@ use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::DeleteWithVersion; use crate::dto::DeleteWithVersion;
use crate::dto::export_dto::{ExportQuery, ExportResp};
use crate::dto::patient_dto::{ use crate::dto::patient_dto::{
BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq, BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq,
FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, PatientSummary, ReferPatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, PatientSummary, ReferPatientReq,
ReferResultResp, UpdatePatientReq, ReferResultResp, UpdatePatientReq,
}; };
use crate::handler::consent_check::check_consent_active;
use crate::service::patient_service; use crate::service::patient_service;
use crate::state::HealthState; use crate::state::HealthState;
@@ -582,3 +584,50 @@ where
patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?; patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(result))) 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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Query(query): Query<ExportQuery>,
) -> Result<Json<ApiResponse<ExportResp>>, AppError>
where
HealthState: FromRef<S>,
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)))
}

View File

@@ -72,6 +72,11 @@ where
"/health/patients/{id}/refer", "/health/patients/{id}/refer",
axum::routing::post(patient_handler::refer_patient), axum::routing::post(patient_handler::refer_patient),
) )
// 患者数据导出(个保法 §45 数据可携权)
.route(
"/health/patients/{id}/export",
axum::routing::get(patient_handler::export_patient),
)
// 家庭成员健康代理 — 管理端 // 家庭成员健康代理 — 管理端
.route( .route(
"/health/patients/{patient_id}/family-members/{family_member_id}/grant-access", "/health/patients/{patient_id}/family-members/{family_member_id}/grant-access",

View File

@@ -254,6 +254,19 @@ pub async fn get_patient(
Ok(model_to_resp_decrypted(&state.crypto, model)) 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<Option<Uuid>> {
let model = find_patient(&state.db, tenant_id, id).await?;
Ok(model.user_id)
}
/// 更新患者信息(乐观锁) /// 更新患者信息(乐观锁)
pub async fn update_patient( pub async fn update_patient(
state: &HealthState, state: &HealthState,

View File

@@ -0,0 +1,278 @@
//! 患者数据导出 Service个保法 §45 数据可携权)
//!
//! 双格式:
//! - `json` — 自定义 JSONPII 明文(可携权本意,患者拿到完整数据)
//! - `fhir` — FHIR R4 Bundle复用现有 converterPII 天然脱敏
//!
//! 数据装配逻辑复刻 `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<device_readings::Model>,
devices: Vec<patient_devices::Model>,
consultations: Vec<consultation_session::Model>,
appointments: Vec<appointment::Model>,
tasks: Vec<follow_up_task::Model>,
reports: Vec<lab_report::Model>,
}
/// 导出患者数据(个保法 §45 数据可携权)
///
/// 强制审计 `patient.exported`(不含明文 PII并发事件 `patient.exported`。
/// 日志只记元数据patient_id/format/counts绝不记录 payload。
pub async fn export_patient(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
patient_id: Uuid,
format: ExportFormat,
) -> HealthResult<ExportResp> {
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<AssembledData> {
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<String>/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复用 converterPII 天然脱敏)
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(),
})
}

View File

@@ -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<String>,
pub allergy_history: Option<String>,
pub medical_history_summary: Option<String>,
pub emergency_contact_phone: Option<String>,
}
/// 解密患者全部 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 /// 解密单个 PII 字段,失败时输出 warn 日志并返回 None
fn decrypt_field( pub(crate) fn decrypt_field(
kek: &[u8; 32], kek: &[u8; 32],
field: &Option<String>, field: &Option<String>,
name: &str, name: &str,

View File

@@ -1,12 +1,14 @@
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要 //! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要、数据导出
//! //!
//! 按 4 个功能域组织: //! 按 5 个功能域组织:
//! - `crud` — 患者基础 CRUD 操作 //! - `crud` — 患者基础 CRUD 操作
//! - `export` — 患者数据导出(个保法 §45 数据可携权)
//! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要 //! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要
//! - `tag` — 患者标签 CRUD //! - `tag` — 患者标签 CRUD
//! - `helper` — 共享辅助函数 //! - `helper` — 共享辅助函数
mod crud; mod crud;
mod export;
mod helper; mod helper;
mod relation; mod relation;
mod tag; mod tag;
@@ -14,8 +16,9 @@ mod tag;
// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变 // 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变
pub use crud::{ pub use crud::{
batch_import_patients, bind_by_phone, create_patient, delete_patient, get_patient, 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::{ pub use relation::{
assign_doctor, create_family_member, delete_family_member, get_health_summary, assign_doctor, create_family_member, delete_family_member, get_health_summary,
list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member, list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member,

View File

@@ -177,6 +177,7 @@ mod m20260526_000167_create_ai_knowledge_documents;
mod m20260527_000168_ai_knowledge_v2_menu; mod m20260527_000168_ai_knowledge_v2_menu;
mod m20260529_000169_supplement_rls_for_new_tables; mod m20260529_000169_supplement_rls_for_new_tables;
mod m20260626_000170_extend_device_readings_partitions; mod m20260626_000170_extend_device_readings_partitions;
mod m20260626_000171_seed_patient_export_permission;
pub struct Migrator; pub struct Migrator;
@@ -361,6 +362,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration), Box::new(m20260527_000168_ai_knowledge_v2_menu::Migration),
Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration), Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration),
Box::new(m20260626_000170_extend_device_readings_partitions::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; mod health_follow_up_tests;
#[path = "integration/health_medication_tests.rs"] #[path = "integration/health_medication_tests.rs"]
mod health_medication_tests; mod health_medication_tests;
#[path = "integration/health_patient_export_tests.rs"]
mod health_patient_export_tests;
#[path = "integration/health_patient_tests.rs"] #[path = "integration/health_patient_tests.rs"]
mod health_patient_tests; mod health_patient_tests;
#[path = "integration/health_pii_encryption_tests.rs"] #[path = "integration/health_pii_encryption_tests.rs"]

View File

@@ -48,6 +48,7 @@ async fn test_user_crud() {
page_size: Some(10), page_size: Some(10),
}, },
None, None,
None,
db, db,
) )
.await .await
@@ -90,6 +91,7 @@ async fn test_tenant_isolation() {
page_size: Some(10), page_size: Some(10),
}, },
None, None,
None,
db, db,
) )
.await .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}"
);
}