Files
hms/crates/erp-health/src/fhir/handler.rs
iven 6d5a711d2c
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
2026-05-07 23:43:14 +08:00

825 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::Extension;
use axum::Json;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::IntoResponse;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::TenantContext;
use crate::fhir::converter;
use crate::fhir::types::{category_to_device_types, loinc_to_device_type};
use crate::oauth::middleware::FhirAuthContext;
use crate::state::HealthState;
/// GET /fhir/R4/metadata — FHIR CapabilityStatement
pub async fn capability_statement<S>() -> Result<impl IntoResponse, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let stmt = serde_json::json!({
"resourceType": "CapabilityStatement",
"status": "active",
"date": chrono::Utc::now().format("%Y-%m-%d").to_string(),
"kind": "instance",
"fhirVersion": "4.0.1",
"format": ["application/fhir+json"],
"rest": [{
"mode": "server",
"resource": [
{ "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "everything"}] },
{ "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Practitioner", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Appointment", "interaction": [{"code": "read"}, {"code": "search-type"}] },
{ "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] },
],
"operation": [
{ "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" }
]
}]
});
Ok(Json(stmt))
}
#[derive(Debug, Deserialize)]
pub struct FhirSearchParams {
#[serde(rename = "_id")]
pub id: Option<String>,
#[serde(rename = "_count")]
pub count: Option<u64>,
#[serde(rename = "_offset")]
pub offset: Option<u64>,
pub patient: Option<String>,
pub category: Option<String>,
pub code: Option<String>,
pub date: Option<String>,
pub name: Option<String>,
pub identifier: Option<String>,
pub status: Option<String>,
}
// ── 患者访问范围辅助 ──────────────────────────────────────────────────
/// 检查单个 patient_id 是否在 OAuth 客户端的允许范围内
fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result<(), AppError> {
match &fhir_ctx.allowed_patient_ids {
Some(allowed) if !allowed.is_empty() => {
if !allowed.iter().any(|id| id == &patient_id.to_string()) {
tracing::warn!(
client_id = %fhir_ctx.client_id,
requested_patient = %patient_id,
"FHIR 客户端尝试访问授权范围外的患者"
);
return Err(AppError::Forbidden(
"Access denied: patient not in allowed scope".into(),
));
}
}
_ => {
return Err(AppError::Forbidden(
"OAuth client has no patient access configured".into(),
));
}
}
Ok(())
}
/// 解析 allowed_patient_ids 为 Uuid 列表
fn resolve_allowed_patient_uuids(fhir_ctx: &FhirAuthContext) -> Option<Vec<Uuid>> {
fhir_ctx.allowed_patient_ids.as_ref().map(|ids| {
ids.iter()
.filter_map(|id| Uuid::parse_str(id).ok())
.collect()
})
}
// ── Patient ────────────────────────────────────────────────────────────
pub async fn search_patients(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
let mut query = crate::entity::patient::Entity::find()
.filter(crate::entity::patient::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::patient::Column::DeletedAt.is_null());
if let Some(ref id) = params.id {
let uid =
Uuid::parse_str(id).map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::patient::Column::Id.eq(uid));
}
if let Some(ref name) = params.name {
query = query.filter(crate::entity::patient::Column::Name.contains(name));
}
if let Some(ref identifier) = params.identifier {
query = query.filter(crate::entity::patient::Column::IdNumber.contains(identifier));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::patient::Column::Id.is_in(uuids));
}
let limit = params.count.unwrap_or(20).min(100);
let offset = params.offset.unwrap_or(0);
let patients = query.limit(limit).offset(offset).all(&state.db).await?;
let entries: Vec<serde_json::Value> = patients
.iter()
.map(|p| serde_json::json!({"resource": converter::patient_to_fhir(p)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_patient(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
enforce_patient_scope(&fhir_ctx, id)?;
let patient = crate::entity::patient::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Patient not found".into()))?;
if patient.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
Ok(Json(converter::patient_to_fhir(&patient)))
}
// ── Observation ────────────────────────────────────────────────────────
pub async fn search_observations(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.device-readings.list")?;
let mut query = crate::entity::device_readings::Entity::find()
.filter(crate::entity::device_readings::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::device_readings::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::device_readings::Column::PatientId.eq(uid));
}
if let Some(ref code) = params.code
&& let Some(dt) = loinc_to_device_type(code)
{
query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt));
}
if let Some(ref category) = params.category {
let types = category_to_device_types(category);
if !types.is_empty() {
query = query.filter(crate::entity::device_readings::Column::DeviceType.is_in(types));
}
}
if let Some(ref date) = params.date {
if let Some(after) = date.strip_prefix("gt") {
if let Ok(dt) = after.parse::<chrono::DateTime<chrono::Utc>>() {
query = query.filter(crate::entity::device_readings::Column::MeasuredAt.gt(dt));
}
} else if let Some(before) = date.strip_prefix("lt") {
if let Ok(dt) = before.parse::<chrono::DateTime<chrono::Utc>>() {
query = query.filter(crate::entity::device_readings::Column::MeasuredAt.lt(dt));
}
} else if let Ok(dt) = date.parse::<chrono::DateTime<chrono::Utc>>() {
let start = dt.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
let end = dt.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
query = query
.filter(crate::entity::device_readings::Column::MeasuredAt.between(start, end));
}
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let readings = query
.order_by_desc(crate::entity::device_readings::Column::MeasuredAt)
.limit(limit)
.all(&state.db)
.await?;
let mut entries = Vec::new();
for reading in &readings {
for obs in converter::device_reading_to_fhir_observations(reading) {
entries.push(serde_json::json!({"resource": obs}));
}
}
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
// ── Device ─────────────────────────────────────────────────────────────
pub async fn search_devices(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.devices.list")?;
let mut query = crate::entity::patient_devices::Entity::find()
.filter(crate::entity::patient_devices::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::patient_devices::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::patient_devices::Column::PatientId.eq(uid));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let devices = query.limit(limit).all(&state.db).await?;
let entries: Vec<serde_json::Value> = devices
.iter()
.map(|d| serde_json::json!({"resource": converter::patient_device_to_fhir(d)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_device(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.devices.list")?;
let device = crate::entity::patient_devices::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Device not found".into()))?;
if device.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, device.patient_id)?;
Ok(Json(converter::patient_device_to_fhir(&device)))
}
// ── Practitioner ───────────────────────────────────────────────────────
pub async fn search_practitioners(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.doctor.list")?;
let mut query = crate::entity::doctor_profile::Entity::find()
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::doctor_profile::Column::DeletedAt.is_null());
if let Some(ref name) = params.name {
query = query.filter(crate::entity::doctor_profile::Column::Name.contains(name));
}
let limit = params.count.unwrap_or(50).min(200);
let doctors = query.limit(limit).all(&state.db).await?;
let entries: Vec<serde_json::Value> = doctors
.iter()
.map(|d| serde_json::json!({"resource": converter::doctor_to_fhir(d)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_practitioner(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.doctor.list")?;
let doctor = crate::entity::doctor_profile::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Practitioner not found".into()))?;
if doctor.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
Ok(Json(converter::doctor_to_fhir(&doctor)))
}
// ── Appointment ────────────────────────────────────────────────────────
pub async fn search_appointments(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.appointment.list")?;
let mut query = crate::entity::appointment::Entity::find()
.filter(crate::entity::appointment::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::appointment::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::appointment::Column::PatientId.eq(uid));
}
if let Some(ref status) = params.status {
query = query.filter(crate::entity::appointment::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let appointments = query
.order_by_desc(crate::entity::appointment::Column::AppointmentDate)
.limit(limit)
.all(&state.db)
.await?;
let entries: Vec<serde_json::Value> = appointments
.iter()
.map(|a| serde_json::json!({"resource": converter::appointment_to_fhir(a)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_appointment(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.appointment.list")?;
let appointment = crate::entity::appointment::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Appointment not found".into()))?;
if appointment.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, appointment.patient_id)?;
Ok(Json(converter::appointment_to_fhir(&appointment)))
}
// ── DiagnosticReport ───────────────────────────────────────────────────
pub async fn search_diagnostic_reports(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.health-data.list")?;
let mut query = crate::entity::lab_report::Entity::find()
.filter(crate::entity::lab_report::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::lab_report::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::lab_report::Column::PatientId.eq(uid));
}
if let Some(ref code) = params.code {
query = query.filter(crate::entity::lab_report::Column::ReportType.eq(code));
}
if let Some(ref status) = params.status {
query = query.filter(crate::entity::lab_report::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let reports = query
.order_by_desc(crate::entity::lab_report::Column::ReportDate)
.limit(limit)
.all(&state.db)
.await?;
let entries: Vec<serde_json::Value> = reports
.iter()
.map(|r| serde_json::json!({"resource": converter::lab_report_to_fhir(r)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_diagnostic_report(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.health-data.list")?;
let report = crate::entity::lab_report::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("DiagnosticReport not found".into()))?;
if report.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, report.patient_id)?;
Ok(Json(converter::lab_report_to_fhir(&report)))
}
// ── Encounter ──────────────────────────────────────────────────────────
pub async fn search_encounters(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.consultation.list")?;
let mut query = crate::entity::consultation_session::Entity::find()
.filter(crate::entity::consultation_session::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::consultation_session::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::consultation_session::Column::PatientId.eq(uid));
}
if let Some(ref status) = params.status {
query = query.filter(crate::entity::consultation_session::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let sessions = query
.order_by_desc(crate::entity::consultation_session::Column::CreatedAt)
.limit(limit)
.all(&state.db)
.await?;
let entries: Vec<serde_json::Value> = sessions
.iter()
.map(|s| serde_json::json!({"resource": converter::consultation_to_fhir(s)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_encounter(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.consultation.list")?;
let session = crate::entity::consultation_session::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Encounter not found".into()))?;
if session.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, session.patient_id)?;
Ok(Json(converter::consultation_to_fhir(&session)))
}
// ── Task ───────────────────────────────────────────────────────────────
pub async fn search_tasks(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.follow-up.list")?;
let mut query = crate::entity::follow_up_task::Entity::find()
.filter(crate::entity::follow_up_task::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::follow_up_task::Column::DeletedAt.is_null());
if let Some(ref patient_id) = params.patient {
let uid = Uuid::parse_str(patient_id)
.map_err(|_| AppError::Validation("Invalid patient id".into()))?;
query = query.filter(crate::entity::follow_up_task::Column::PatientId.eq(uid));
}
if let Some(ref status) = params.status {
query = query.filter(crate::entity::follow_up_task::Column::Status.eq(status));
}
// 强制执行 allowed_patient_ids 范围
if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx)
&& !uuids.is_empty()
{
query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids));
}
let limit = params.count.unwrap_or(50).min(200);
let tasks = query
.order_by_desc(crate::entity::follow_up_task::Column::PlannedDate)
.limit(limit)
.all(&state.db)
.await?;
let entries: Vec<serde_json::Value> = tasks
.iter()
.map(|t| serde_json::json!({"resource": converter::follow_up_to_fhir(t)}))
.collect();
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "searchset",
"total": entries.len(),
"entry": entries,
})))
}
pub async fn get_task(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.follow-up.list")?;
let task = crate::entity::follow_up_task::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Task not found".into()))?;
if task.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
enforce_patient_scope(&fhir_ctx, task.patient_id)?;
Ok(Json(converter::follow_up_to_fhir(&task)))
}
// ── $everything ────────────────────────────────────────────────────────
/// GET /fhir/R4/Patient/{id}/$everything
pub async fn patient_everything(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Extension(fhir_ctx): Extension<FhirAuthContext>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
require_permission(&ctx, "health.patient.list")?;
enforce_patient_scope(&fhir_ctx, id)?;
let patient = crate::entity::patient::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or_else(|| AppError::NotFound("Patient not found".into()))?;
if patient.tenant_id != ctx.tenant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
let mut entries = Vec::new();
// 1. Patient
entries.push(serde_json::json!({
"resource": converter::patient_to_fhir(&patient),
"fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", id),
}));
// 2. Observations设备读数
let readings = crate::entity::device_readings::Entity::find()
.filter(crate::entity::device_readings::Column::PatientId.eq(id))
.filter(crate::entity::device_readings::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::device_readings::Column::DeletedAt.is_null())
.limit(200)
.all(&state.db)
.await?;
for r in &readings {
for obs in converter::device_reading_to_fhir_observations(r) {
entries.push(serde_json::json!({"resource": obs}));
}
}
// 3. Devices
let devices = crate::entity::patient_devices::Entity::find()
.filter(crate::entity::patient_devices::Column::PatientId.eq(id))
.filter(crate::entity::patient_devices::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::patient_devices::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for d in &devices {
entries.push(serde_json::json!({"resource": converter::patient_device_to_fhir(d)}));
}
// 4. Encounters咨询会话
let consultations = crate::entity::consultation_session::Entity::find()
.filter(crate::entity::consultation_session::Column::PatientId.eq(id))
.filter(crate::entity::consultation_session::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::consultation_session::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for c in &consultations {
entries.push(serde_json::json!({"resource": converter::consultation_to_fhir(c)}));
}
// 5. Appointments
let appointments = crate::entity::appointment::Entity::find()
.filter(crate::entity::appointment::Column::PatientId.eq(id))
.filter(crate::entity::appointment::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::appointment::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for a in &appointments {
entries.push(serde_json::json!({"resource": converter::appointment_to_fhir(a)}));
}
// 6. Tasks随访任务
let tasks = crate::entity::follow_up_task::Entity::find()
.filter(crate::entity::follow_up_task::Column::PatientId.eq(id))
.filter(crate::entity::follow_up_task::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::follow_up_task::Column::DeletedAt.is_null())
.limit(50)
.all(&state.db)
.await?;
for t in &tasks {
entries.push(serde_json::json!({"resource": converter::follow_up_to_fhir(t)}));
}
// 7. DiagnosticReports化验报告
let reports = crate::entity::lab_report::Entity::find()
.filter(crate::entity::lab_report::Column::PatientId.eq(id))
.filter(crate::entity::lab_report::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::lab_report::Column::DeletedAt.is_null())
.limit(50)
.all(&state.db)
.await?;
for r in &reports {
entries.push(serde_json::json!({"resource": converter::lab_report_to_fhir(r)}));
}
Ok(Json(serde_json::json!({
"resourceType": "Bundle",
"type": "collection",
"total": entries.len(),
"entry": entries,
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::middleware::FhirAuthContext;
fn default_fhir_ctx() -> FhirAuthContext {
FhirAuthContext {
client_id: Uuid::now_v7(),
tenant_id: Uuid::now_v7(),
scopes: vec!["patient/*.read".to_string()],
allowed_patient_ids: None,
}
}
#[test]
fn test_enforce_patient_scope_none_is_denied() {
let fhir_ctx = FhirAuthContext {
allowed_patient_ids: None,
..default_fhir_ctx()
};
let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7());
assert!(result.is_err(), "None should deny all access");
}
#[test]
fn test_enforce_patient_scope_empty_vec_is_denied() {
let fhir_ctx = FhirAuthContext {
allowed_patient_ids: Some(vec![]),
..default_fhir_ctx()
};
let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7());
assert!(result.is_err(), "Empty list should deny all access");
}
#[test]
fn test_enforce_patient_scope_allowed_access() {
let patient_id = Uuid::now_v7();
let fhir_ctx = FhirAuthContext {
allowed_patient_ids: Some(vec![patient_id.to_string()]),
..default_fhir_ctx()
};
let result = enforce_patient_scope(&fhir_ctx, patient_id);
assert!(result.is_ok(), "Patient in allowed list should succeed");
}
#[test]
fn test_enforce_patient_scope_wrong_patient_denied() {
let fhir_ctx = FhirAuthContext {
allowed_patient_ids: Some(vec![Uuid::now_v7().to_string()]),
..default_fhir_ctx()
};
let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7());
assert!(
result.is_err(),
"Patient not in allowed list should be denied"
);
}
}