功能修复: 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 统一格式化
825 lines
30 KiB
Rust
825 lines
30 KiB
Rust
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"
|
||
);
|
||
}
|
||
}
|