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:
66
crates/erp-health/src/dto/export_dto.rs
Normal file
66
crates/erp-health/src/dto/export_dto.rs
Normal file
@@ -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<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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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)))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Option<Uuid>> {
|
||||
let model = find_patient(&state.db, tenant_id, id).await?;
|
||||
Ok(model.user_id)
|
||||
}
|
||||
|
||||
/// 更新患者信息(乐观锁)
|
||||
pub async fn update_patient(
|
||||
state: &HealthState,
|
||||
|
||||
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
278
crates/erp-health/src/service/patient_service/export.rs
Normal file
@@ -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<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(复用 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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
fn decrypt_field(
|
||||
pub(crate) fn decrypt_field(
|
||||
kek: &[u8; 32],
|
||||
field: &Option<String>,
|
||||
name: &str,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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(())
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user