From 7e57565ecdf2c94efae3794cc6cdf900ef0ac8ec Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 20:28:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20BLE=20=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=85=A5=20=E2=80=94=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E7=AE=A1=E7=90=86=20+=20API=20Key=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=20+=20=E5=A4=9A=E6=82=A3=E8=80=85=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ble_gateways + gateway_patient_bindings 表迁移 (000113) - 网关 CRUD:注册/编辑/删除/重生成 API Key,含患者绑定管理 - API Key 认证中间件(SHA-256 hash + prefix 快速查找) - 网关数据上报端点:多患者批量读数,复用 device_reading_service 管道 - 网关心跳端点:固件版本/IP 更新 + last_heartbeat_at - 10 个管理端路由(JWT)+ 2 个网关端路由(API Key) - health.ble-gateways.list/manage 权限声明 - 修复 000112 迁移 ForeignKey 借用错误 --- crates/erp-health/src/dto/ble_gateway_dto.rs | 141 +++++ crates/erp-health/src/dto/mod.rs | 1 + crates/erp-health/src/entity/ble_gateway.rs | 39 ++ .../src/entity/gateway_patient_binding.rs | 51 ++ crates/erp-health/src/entity/mod.rs | 2 + crates/erp-health/src/gateway_auth.rs | 138 +++++ .../src/handler/ble_gateway_handler.rs | 236 ++++++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/lib.rs | 1 + crates/erp-health/src/module.rs | 56 +- .../src/service/ble_gateway_service.rs | 569 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + crates/erp-server/migration/src/lib.rs | 2 + ...20260505_000112_create_shift_management.rs | 6 +- .../m20260505_000113_create_ble_gateways.rs | 131 ++++ crates/erp-server/src/main.rs | 8 + 16 files changed, 1379 insertions(+), 4 deletions(-) create mode 100644 crates/erp-health/src/dto/ble_gateway_dto.rs create mode 100644 crates/erp-health/src/entity/ble_gateway.rs create mode 100644 crates/erp-health/src/entity/gateway_patient_binding.rs create mode 100644 crates/erp-health/src/gateway_auth.rs create mode 100644 crates/erp-health/src/handler/ble_gateway_handler.rs create mode 100644 crates/erp-health/src/service/ble_gateway_service.rs create mode 100644 crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs diff --git a/crates/erp-health/src/dto/ble_gateway_dto.rs b/crates/erp-health/src/dto/ble_gateway_dto.rs new file mode 100644 index 0000000..dade334 --- /dev/null +++ b/crates/erp-health/src/dto/ble_gateway_dto.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// BLE Gateway DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BleGatewayResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub gateway_id: String, + pub name: String, + pub status: String, + pub firmware_version: Option, + pub ip_address: Option, + pub last_heartbeat_at: Option>, + pub metadata: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, + /// 网关 API Key(仅在创建时返回一次) + pub api_key: Option, + /// 绑定的患者数量 + pub patient_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBleGatewayReq { + pub gateway_id: String, + pub name: String, + pub firmware_version: Option, + pub metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBleGatewayReq { + pub name: Option, + pub status: Option, + pub firmware_version: Option, + pub metadata: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateBleGatewayWithVersion { + pub version: i32, + pub data: UpdateBleGatewayReq, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListBleGatewaysParams { + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +// --------------------------------------------------------------------------- +// GatewayPatientBinding DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayBindingResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub gateway_id_fk: Uuid, + pub patient_id: Uuid, + pub peripheral_mac: Option, + pub device_type: Option, + pub status: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateBindingReq { + pub patient_id: Uuid, + pub peripheral_mac: Option, + pub device_type: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchBindReq { + pub bindings: Vec, +} + +// --------------------------------------------------------------------------- +// 网关数据上报 DTOs +// --------------------------------------------------------------------------- + +/// 网关多患者批量上报请求 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayUploadReq { + pub readings: Vec, +} + +/// 单个患者的读数批次 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatientReadingBatch { + pub patient_id: Uuid, + pub device_id: String, + pub device_model: Option, + pub readings: Vec, +} + +/// 单条读数 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadingEntry { + pub device_type: String, + pub values: serde_json::Value, + pub measured_at: String, +} + +/// 网关上报结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayUploadResp { + pub total_patients: u64, + pub total_readings: u64, + pub total_duplicates: u64, + pub errors: Vec, +} + +/// 网关心跳请求 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HeartbeatReq { + pub firmware_version: Option, + pub ip_address: Option, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index f68e66e..26e3ba6 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -1,5 +1,6 @@ pub mod appointment_dto; pub mod alert_dto; +pub mod ble_gateway_dto; pub mod care_plan_dto; pub mod article_dto; pub mod consent_dto; diff --git a/crates/erp-health/src/entity/ble_gateway.rs b/crates/erp-health/src/entity/ble_gateway.rs new file mode 100644 index 0000000..b9a8f70 --- /dev/null +++ b/crates/erp-health/src/entity/ble_gateway.rs @@ -0,0 +1,39 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ble_gateways")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub gateway_id: String, + pub name: String, + pub api_key_hash: String, + pub api_key_prefix: String, + pub status: String, + pub firmware_version: Option, + pub ip_address: Option, + pub last_heartbeat_at: Option, + pub metadata: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::gateway_patient_binding::Entity")] + GatewayPatientBinding, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::GatewayPatientBinding.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/gateway_patient_binding.rs b/crates/erp-health/src/entity/gateway_patient_binding.rs new file mode 100644 index 0000000..1f5954a --- /dev/null +++ b/crates/erp-health/src/entity/gateway_patient_binding.rs @@ -0,0 +1,51 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "gateway_patient_bindings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub gateway_id_fk: Uuid, + pub patient_id: Uuid, + pub peripheral_mac: Option, + pub device_type: Option, + pub status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::ble_gateway::Entity", + from = "Column::GatewayIdFk", + to = "super::ble_gateway::Column::Id" + )] + BleGateway, + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::BleGateway.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 18cc047..e5a2c0d 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -1,4 +1,5 @@ pub mod alert_rules; +pub mod ble_gateway; pub mod api_client; pub mod alerts; pub mod appointment; @@ -23,6 +24,7 @@ pub mod follow_up_record; pub mod follow_up_task; pub mod follow_up_template; pub mod follow_up_template_field; +pub mod gateway_patient_binding; pub mod health_record; pub mod health_trend; pub mod lab_report; diff --git a/crates/erp-health/src/gateway_auth.rs b/crates/erp-health/src/gateway_auth.rs new file mode 100644 index 0000000..3ee0ccd --- /dev/null +++ b/crates/erp-health/src/gateway_auth.rs @@ -0,0 +1,138 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::QueryFilter; + +use crate::state::HealthState; + +/// 网关认证上下文 — 中间件注入到请求扩展中 +#[derive(Debug, Clone)] +pub struct GatewayAuthContext { + pub gateway_db_id: Uuid, // ble_gateways 表主键 + pub tenant_id: Uuid, + pub gateway_id: String, // 物理设备标识 +} + +/// BLE 网关 API Key 认证中间件 +/// 请求头: `Authorization: Gateway ` 或 `X-Gateway-Key: ` +pub async fn gateway_auth_middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let api_key = extract_gateway_key(&request); + + let api_key = match api_key { + Some(key) => key, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, + "message": "Missing gateway API key. Use Authorization: Gateway or X-Gateway-Key header" + })), + ) + .into_response(); + } + }; + + // 用前 8 位快速定位,再用完整 hash 验证 + let prefix = &api_key[..8.min(api_key.len())]; + let key_hash = sha256_hex(api_key.as_bytes()); + + let gateway = crate::entity::ble_gateway::Entity::find() + .filter(crate::entity::ble_gateway::Column::ApiKeyPrefix.eq(prefix)) + .filter(crate::entity::ble_gateway::Column::ApiKeyHash.eq(&key_hash)) + .filter(crate::entity::ble_gateway::Column::DeletedAt.is_null()) + .filter(crate::entity::ble_gateway::Column::Status.eq("active")) + .one(&state.db) + .await; + + match gateway { + Ok(Some(g)) => { + let ctx = GatewayAuthContext { + gateway_db_id: g.id, + tenant_id: g.tenant_id, + gateway_id: g.gateway_id.clone(), + }; + request.extensions_mut().insert(ctx); + next.run(request).await + } + Ok(None) => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, + "message": "Invalid or inactive gateway API key" + })), + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, "Gateway auth database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "message": "Authentication service error" + })), + ) + .into_response() + } + } +} + +fn extract_gateway_key(request: &Request) -> Option { + // Authorization: Gateway + if let Some(auth) = request + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + { + if let Some(key) = auth.strip_prefix("Gateway ") { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + } + + // X-Gateway-Key: + if let Some(key) = request + .headers() + .get("X-Gateway-Key") + .and_then(|v| v.to_str().ok()) + { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + + None +} + +/// SHA-256 hex digest +pub fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + hex::encode(result) +} + +/// 生成随机 API Key (32 字节 hex = 64 字符) +pub fn generate_api_key() -> (String, String, String) { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let api_key = hex::encode(bytes); + let prefix = api_key[..8].to_string(); + let hash = sha256_hex(api_key.as_bytes()); + (api_key, prefix, hash) +} diff --git a/crates/erp-health/src/handler/ble_gateway_handler.rs b/crates/erp-health/src/handler/ble_gateway_handler.rs new file mode 100644 index 0000000..9bb7eb4 --- /dev/null +++ b/crates/erp-health/src/handler/ble_gateway_handler.rs @@ -0,0 +1,236 @@ +use axum::extract::{FromRef, Json, Path, Query, State}; +use axum::Extension; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::dto::ble_gateway_dto::*; +use crate::dto::DeleteWithVersion; +use crate::gateway_auth::GatewayAuthContext; +use crate::service::ble_gateway_service; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// Gateway 管理(需要用户 JWT 认证) +// --------------------------------------------------------------------------- + +pub async fn list_gateways( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.list")?; + let result = ble_gateway_service::list_gateways(&state, ctx.tenant_id, ¶ms).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_gateway( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.list")?; + let result = ble_gateway_service::get_gateway(&state, ctx.tenant_id, gateway_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_gateway( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + let result = + ble_gateway_service::create_gateway(&state, ctx.tenant_id, Some(ctx.user_id), body) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_gateway( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + let result = ble_gateway_service::update_gateway( + &state, + ctx.tenant_id, + gateway_id, + Some(ctx.user_id), + body, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_gateway( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, + Query(params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + ble_gateway_service::delete_gateway( + &state, + ctx.tenant_id, + gateway_id, + Some(ctx.user_id), + params.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} + +pub async fn regenerate_api_key( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + let result = ble_gateway_service::regenerate_api_key( + &state, + ctx.tenant_id, + gateway_id, + Some(ctx.user_id), + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +// --------------------------------------------------------------------------- +// Binding 管理(需要用户 JWT 认证) +// --------------------------------------------------------------------------- + +pub async fn list_bindings( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.list")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = + ble_gateway_service::list_bindings(&state, ctx.tenant_id, gateway_id, page, page_size) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn bind_patient( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + let result = ble_gateway_service::bind_patient( + &state, + ctx.tenant_id, + gateway_id, + Some(ctx.user_id), + body, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn batch_bind( + State(state): State, + Extension(ctx): Extension, + Path(gateway_id): Path, + Json(body): Json, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + let result = ble_gateway_service::batch_bind( + &state, + ctx.tenant_id, + gateway_id, + Some(ctx.user_id), + body, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn unbind_patient( + State(state): State, + Extension(ctx): Extension, + Path((gateway_id, binding_id)): Path<(Uuid, Uuid)>, + Query(params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.ble-gateways.manage")?; + ble_gateway_service::unbind_patient( + &state, + ctx.tenant_id, + gateway_id, + binding_id, + Some(ctx.user_id), + params.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// 网关端点(API Key 认证,无需用户 JWT) +// --------------------------------------------------------------------------- + +pub async fn gateway_upload( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> { + let result = ble_gateway_service::gateway_upload(&state, &ctx, body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn gateway_heartbeat( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> { + ble_gateway_service::heartbeat(&state, &ctx, body).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 989b645..9e4a8fd 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -1,5 +1,6 @@ pub mod action_inbox_handler; pub mod alert_handler; +pub mod ble_gateway_handler; pub mod alert_rule_handler; pub mod appointment_handler; pub mod article_category_handler; diff --git a/crates/erp-health/src/lib.rs b/crates/erp-health/src/lib.rs index 0023d98..addc959 100644 --- a/crates/erp-health/src/lib.rs +++ b/crates/erp-health/src/lib.rs @@ -4,6 +4,7 @@ pub mod entity; pub mod error; pub mod event; pub mod fhir; +pub mod gateway_auth; pub mod handler; pub mod health_provider_impl; pub mod module; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 0147d4d..d62f5f6 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -8,7 +8,8 @@ use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ action_inbox_handler, alert_handler, alert_rule_handler, - appointment_handler, article_category_handler, article_handler, article_tag_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, + appointment_handler, article_category_handler, article_handler, article_tag_handler, + ble_gateway_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, shift_handler, stats_handler, vital_signs_daily_handler, }; @@ -884,6 +885,46 @@ impl HealthModule { axum::routing::get(shift_handler::list_handoffs) .post(shift_handler::create_handoff), ) + // BLE 网关管理 + .route( + "/health/ble-gateways", + axum::routing::get(ble_gateway_handler::list_gateways) + .post(ble_gateway_handler::create_gateway), + ) + .route( + "/health/ble-gateways/{gateway_id}", + axum::routing::get(ble_gateway_handler::get_gateway) + .put(ble_gateway_handler::update_gateway) + .delete(ble_gateway_handler::delete_gateway), + ) + .route( + "/health/ble-gateways/{gateway_id}/regenerate-key", + axum::routing::post(ble_gateway_handler::regenerate_api_key), + ) + .route( + "/health/ble-gateways/{gateway_id}/bindings", + axum::routing::get(ble_gateway_handler::list_bindings) + .post(ble_gateway_handler::bind_patient), + ) + .route( + "/health/ble-gateways/{gateway_id}/bindings/batch", + axum::routing::post(ble_gateway_handler::batch_bind), + ) + .route( + "/health/ble-gateways/{gateway_id}/bindings/{binding_id}", + axum::routing::delete(ble_gateway_handler::unbind_patient), + ) + } + + /// BLE 网关数据接入路由(裸路由,需在 erp-server 层配合 gateway_auth 中间件使用) + pub fn gateway_routes() -> Router + where + crate::state::HealthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/health/gateway/upload", axum::routing::post(ble_gateway_handler::gateway_upload)) + .route("/health/gateway/heartbeat", axum::routing::post(ble_gateway_handler::gateway_heartbeat)) } } @@ -1330,6 +1371,19 @@ impl ErpModule for HealthModule { description: "创建/编辑班次、分配患者、创建交接记录".into(), module: "health".into(), }, + // BLE 网关管理 + PermissionDescriptor { + code: "health.ble-gateways.list".into(), + name: "查看 BLE 网关".into(), + description: "查看 BLE 网关列表、绑定患者和状态".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.ble-gateways.manage".into(), + name: "管理 BLE 网关".into(), + description: "注册/编辑/删除 BLE 网关、管理患者绑定".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/ble_gateway_service.rs b/crates/erp-health/src/service/ble_gateway_service.rs new file mode 100644 index 0000000..6df50bf --- /dev/null +++ b/crates/erp-health/src/service/ble_gateway_service.rs @@ -0,0 +1,569 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::dto::ble_gateway_dto::*; +use crate::entity::{ble_gateway, gateway_patient_binding, patient}; +use crate::error::{HealthError, HealthResult}; +use crate::gateway_auth::{generate_api_key, sha256_hex}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// Gateway CRUD +// --------------------------------------------------------------------------- + +pub async fn list_gateways( + state: &HealthState, + tenant_id: Uuid, + params: &ListBleGatewaysParams, +) -> HealthResult> { + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = ble_gateway::Entity::find() + .filter(ble_gateway::Column::TenantId.eq(tenant_id)) + .filter(ble_gateway::Column::DeletedAt.is_null()); + + if let Some(ref s) = params.status { + query = query.filter(ble_gateway::Column::Status.eq(s.as_str())); + } + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(ble_gateway::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let mut data = Vec::with_capacity(rows.len()); + for m in rows { + let patient_count = count_boundings(state, m.id).await?; + data.push(BleGatewayResp { + id: m.id, + tenant_id: m.tenant_id, + gateway_id: m.gateway_id, + name: m.name, + status: m.status, + firmware_version: m.firmware_version, + ip_address: m.ip_address, + last_heartbeat_at: m.last_heartbeat_at, + metadata: m.metadata, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + api_key: None, + patient_count: Some(patient_count), + }); + } + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn get_gateway( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, +) -> HealthResult { + let m = find_gateway(state, tenant_id, gateway_db_id).await?; + let patient_count = count_boundings(state, m.id).await?; + Ok(gateway_to_resp(m, None, Some(patient_count))) +} + +pub async fn create_gateway( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateBleGatewayReq, +) -> HealthResult { + // 检查 gateway_id 唯一性 + let existing = ble_gateway::Entity::find() + .filter(ble_gateway::Column::GatewayId.eq(&req.gateway_id)) + .filter(ble_gateway::Column::DeletedAt.is_null()) + .one(&state.db) + .await?; + + if existing.is_some() { + return Err(HealthError::Validation(format!( + "网关 {} 已注册", + req.gateway_id + ))); + } + + let (api_key, prefix, hash) = generate_api_key(); + let now = Utc::now(); + + let active = ble_gateway::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + gateway_id: Set(req.gateway_id), + name: Set(req.name), + api_key_hash: Set(hash), + api_key_prefix: Set(prefix), + status: Set("active".to_string()), + firmware_version: Set(req.firmware_version), + ip_address: Set(None), + last_heartbeat_at: Set(None), + metadata: Set(req.metadata), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "ble_gateway.created", "ble_gateway") + .with_resource_id(m.id), + &state.db, + ) + .await; + + state + .event_bus + .publish( + DomainEvent::new( + "ble_gateway.created", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "gateway_id": m.gateway_id, + "name": m.name, + })), + ), + &state.db, + ) + .await; + + Ok(gateway_to_resp(m, Some(api_key), Some(0))) +} + +pub async fn update_gateway( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + operator_id: Option, + req: UpdateBleGatewayWithVersion, +) -> HealthResult { + let existing = find_gateway(state, tenant_id, gateway_db_id).await?; + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: ble_gateway::ActiveModel = existing.into(); + let now = Utc::now(); + + if let Some(v) = req.data.name { + active.name = Set(v); + } + if let Some(ref v) = req.data.status { + validate_gateway_status(v)?; + active.status = Set(v.clone()); + } + if req.data.firmware_version.is_some() { + active.firmware_version = Set(req.data.firmware_version); + } + if req.data.metadata.is_some() { + active.metadata = Set(req.data.metadata); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + let patient_count = count_boundings(state, m.id).await?; + Ok(gateway_to_resp(m, None, Some(patient_count))) +} + +pub async fn delete_gateway( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let existing = find_gateway(state, tenant_id, gateway_db_id).await?; + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: ble_gateway::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + Ok(()) +} + +pub async fn regenerate_api_key( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + operator_id: Option, +) -> HealthResult { + let existing = find_gateway(state, tenant_id, gateway_db_id).await?; + let (api_key, prefix, hash) = generate_api_key(); + let now = Utc::now(); + let next_ver = existing.version + 1; + + let mut active: ble_gateway::ActiveModel = existing.into(); + active.api_key_hash = Set(hash); + active.api_key_prefix = Set(prefix); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + let patient_count = count_boundings(state, m.id).await?; + Ok(gateway_to_resp(m, Some(api_key), Some(patient_count))) +} + +// --------------------------------------------------------------------------- +// 网关心跳 +// --------------------------------------------------------------------------- + +pub async fn heartbeat( + state: &HealthState, + ctx: &crate::gateway_auth::GatewayAuthContext, + req: HeartbeatReq, +) -> HealthResult<()> { + let gateway = ble_gateway::Entity::find_by_id(ctx.gateway_db_id) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("网关不存在".into()))?; + + let now = Utc::now(); + let mut active: ble_gateway::ActiveModel = gateway.into(); + active.last_heartbeat_at = Set(Some(now)); + if let Some(v) = req.firmware_version { + active.firmware_version = Set(Some(v)); + } + if let Some(v) = req.ip_address { + active.ip_address = Set(Some(v)); + } + active.updated_at = Set(now); + active.update(&state.db).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Patient Binding +// --------------------------------------------------------------------------- + +pub async fn list_bindings( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let _gw = find_gateway(state, tenant_id, gateway_db_id).await?; + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = gateway_patient_binding::Entity::find() + .filter(gateway_patient_binding::Column::TenantId.eq(tenant_id)) + .filter(gateway_patient_binding::Column::GatewayIdFk.eq(gateway_db_id)) + .filter(gateway_patient_binding::Column::DeletedAt.is_null()); + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(gateway_patient_binding::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(binding_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn bind_patient( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + operator_id: Option, + req: CreateBindingReq, +) -> HealthResult { + find_gateway(state, tenant_id, gateway_db_id).await?; + + // 验证患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(req.patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let now = Utc::now(); + let active = gateway_patient_binding::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + gateway_id_fk: Set(gateway_db_id), + patient_id: Set(req.patient_id), + peripheral_mac: Set(req.peripheral_mac), + device_type: Set(req.device_type), + status: Set("active".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + Ok(binding_to_resp(m)) +} + +pub async fn batch_bind( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + operator_id: Option, + req: BatchBindReq, +) -> HealthResult> { + find_gateway(state, tenant_id, gateway_db_id).await?; + + let mut results = Vec::with_capacity(req.bindings.len()); + for b in req.bindings { + // 验证患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(b.patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let now = Utc::now(); + let active = gateway_patient_binding::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + gateway_id_fk: Set(gateway_db_id), + patient_id: Set(b.patient_id), + peripheral_mac: Set(b.peripheral_mac), + device_type: Set(b.device_type), + status: Set("active".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + results.push(binding_to_resp(m)); + } + + Ok(results) +} + +pub async fn unbind_patient( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, + binding_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + find_gateway(state, tenant_id, gateway_db_id).await?; + + let existing = gateway_patient_binding::Entity::find_by_id(binding_id) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("绑定不存在".into()))?; + + if existing.tenant_id != tenant_id || existing.gateway_id_fk != gateway_db_id { + return Err(HealthError::Validation("绑定不存在".into())); + } + + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: gateway_patient_binding::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.status = Set("unbound".to_string()); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 网关数据上报(多患者批量) +// --------------------------------------------------------------------------- + +pub async fn gateway_upload( + state: &HealthState, + ctx: &crate::gateway_auth::GatewayAuthContext, + req: GatewayUploadReq, +) -> HealthResult { + let mut total_readings = 0u64; + let mut total_duplicates = 0u64; + let mut errors = Vec::new(); + let total_patients = req.readings.len() as u64; + + for batch in req.readings { + // 验证该患者是否绑定到该网关 + let binding = gateway_patient_binding::Entity::find() + .filter(gateway_patient_binding::Column::TenantId.eq(ctx.tenant_id)) + .filter(gateway_patient_binding::Column::GatewayIdFk.eq(ctx.gateway_db_id)) + .filter(gateway_patient_binding::Column::PatientId.eq(batch.patient_id)) + .filter(gateway_patient_binding::Column::DeletedAt.is_null()) + .filter(gateway_patient_binding::Column::Status.eq("active")) + .one(&state.db) + .await?; + + if binding.is_none() { + errors.push(format!("患者 {} 未绑定到此网关", batch.patient_id)); + continue; + } + + // 复用现有 device_reading_service 的批量管道 + let device_req = crate::service::device_reading_service::BatchReadingRequest { + device_id: batch.device_id, + device_model: batch.device_model, + readings: batch + .readings + .into_iter() + .map(|r| crate::service::device_reading_service::ReadingInput { + device_type: r.device_type, + values: r.values, + measured_at: r.measured_at, + }) + .collect(), + }; + + match crate::service::device_reading_service::batch_create_readings( + state, + ctx.tenant_id, + batch.patient_id, + device_req, + ) + .await + { + Ok(result) => { + total_readings += result.accepted; + total_duplicates += result.duplicates; + } + Err(e) => { + errors.push(format!("患者 {} 数据上报失败: {}", batch.patient_id, e)); + } + } + } + + Ok(GatewayUploadResp { + total_patients, + total_readings, + total_duplicates, + errors, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn find_gateway( + state: &HealthState, + tenant_id: Uuid, + gateway_db_id: Uuid, +) -> HealthResult { + ble_gateway::Entity::find_by_id(gateway_db_id) + .one(&state.db) + .await? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or(HealthError::Validation("网关不存在".into())) +} + +async fn count_boundings(state: &HealthState, gateway_db_id: Uuid) -> HealthResult { + let count = gateway_patient_binding::Entity::find() + .filter(gateway_patient_binding::Column::GatewayIdFk.eq(gateway_db_id)) + .filter(gateway_patient_binding::Column::DeletedAt.is_null()) + .filter(gateway_patient_binding::Column::Status.eq("active")) + .count(&state.db) + .await?; + Ok(count as i64) +} + +fn gateway_to_resp( + m: ble_gateway::Model, + api_key: Option, + patient_count: Option, +) -> BleGatewayResp { + BleGatewayResp { + id: m.id, + tenant_id: m.tenant_id, + gateway_id: m.gateway_id, + name: m.name, + status: m.status, + firmware_version: m.firmware_version, + ip_address: m.ip_address, + last_heartbeat_at: m.last_heartbeat_at, + metadata: m.metadata, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + api_key, + patient_count, + } +} + +fn binding_to_resp(m: gateway_patient_binding::Model) -> GatewayBindingResp { + GatewayBindingResp { + id: m.id, + tenant_id: m.tenant_id, + gateway_id_fk: m.gateway_id_fk, + patient_id: m.patient_id, + peripheral_mac: m.peripheral_mac, + device_type: m.device_type, + status: m.status, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn validate_gateway_status(status: &str) -> HealthResult<()> { + let valid = ["active", "suspended", "decommissioned"]; + if valid.contains(&status) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "status 必须为以下之一: {}", + valid.join(", ") + ))) + } +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 7a7a26a..871135e 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -3,6 +3,7 @@ pub mod alert_noise_reducer; pub mod ai_action_dispatcher; pub mod ai_suggestion_loader; pub mod alert_engine; +pub mod ble_gateway_service; pub mod alert_rule_service; pub mod alert_service; pub mod appointment_service; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index b0d292b..2515119 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -112,6 +112,7 @@ mod m20260504_000109_add_missing_fk_constraints; mod m20260504_000110_alter_critical_alerts_version_i32; mod m20260505_000111_create_care_plan; mod m20260505_000112_create_shift_management; +mod m20260505_000113_create_ble_gateways; pub struct Migrator; @@ -231,6 +232,7 @@ impl MigratorTrait for Migrator { Box::new(m20260504_000110_alter_critical_alerts_version_i32::Migration), Box::new(m20260505_000111_create_care_plan::Migration), Box::new(m20260505_000112_create_shift_management::Migration), + Box::new(m20260505_000113_create_ble_gateways::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs b/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs index 04f0d19..707a631 100644 --- a/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs +++ b/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs @@ -85,7 +85,7 @@ impl MigrationTrait for Migration { manager .create_foreign_key( - &mut ForeignKey::create() + ForeignKey::create() .name("fk_patient_assignments_shift") .from(PatientAssignment::Table, PatientAssignment::ShiftId) .to(Shift::Table, Shift::Id) @@ -147,7 +147,7 @@ impl MigrationTrait for Migration { manager .create_foreign_key( - &mut ForeignKey::create() + ForeignKey::create() .name("fk_handoff_log_from_shift") .from(HandoffLog::Table, HandoffLog::FromShiftId) .to(Shift::Table, Shift::Id) @@ -158,7 +158,7 @@ impl MigrationTrait for Migration { manager .create_foreign_key( - &mut ForeignKey::create() + ForeignKey::create() .name("fk_handoff_log_to_shift") .from(HandoffLog::Table, HandoffLog::ToShiftId) .to(Shift::Table, Shift::Id) diff --git a/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs b/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs new file mode 100644 index 0000000..49d4a26 --- /dev/null +++ b/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs @@ -0,0 +1,131 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("ble_gateways")) + .col(uuid("id").primary_key()) + .col(uuid("tenant_id").not_null()) + .col(string("gateway_id").unique_key().not_null()) + .col(string("name").not_null()) + .col(string("api_key_hash").not_null()) + .col(string("api_key_prefix").not_null()) + .col(string("status").default("active").not_null()) + .col(string("firmware_version").null()) + .col(string("ip_address").null()) + .col(timestamp_with_time_zone("last_heartbeat_at").null()) + .col(json("metadata").null()) + .col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null()) + .col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null()) + .col(uuid("created_by").null()) + .col(uuid("updated_by").null()) + .col(timestamp_with_time_zone("deleted_at").null()) + .col(integer("version").default(1).not_null()) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Alias::new("gateway_patient_bindings")) + .col(uuid("id").primary_key()) + .col(uuid("tenant_id").not_null()) + .col(uuid("gateway_id_fk").not_null()) + .col(uuid("patient_id").not_null()) + .col(string("peripheral_mac").null()) + .col(string("device_type").null()) + .col(string("status").default("active").not_null()) + .col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null()) + .col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null()) + .col(uuid("created_by").null()) + .col(uuid("updated_by").null()) + .col(timestamp_with_time_zone("deleted_at").null()) + .col(integer("version").default(1).not_null()) + .to_owned(), + ) + .await?; + + // 索引 + manager + .create_index( + Index::create() + .name("idx_ble_gateways_tenant_id") + .table(Alias::new("ble_gateways")) + .col(Alias::new("tenant_id")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_ble_gateways_api_key_prefix") + .table(Alias::new("ble_gateways")) + .col(Alias::new("api_key_prefix")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_gateway_patient_bindings_gateway") + .table(Alias::new("gateway_patient_bindings")) + .col(Alias::new("gateway_id_fk")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_gateway_patient_bindings_patient") + .table(Alias::new("gateway_patient_bindings")) + .col(Alias::new("patient_id")) + .to_owned(), + ) + .await?; + + // 外键约束 + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_gpb_gateway") + .from(Alias::new("gateway_patient_bindings"), Alias::new("gateway_id_fk")) + .to(Alias::new("ble_gateways"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_gpb_patient") + .from(Alias::new("gateway_patient_bindings"), Alias::new("patient_id")) + .to(Alias::new("patients"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("gateway_patient_bindings")).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("ble_gateways")).to_owned()) + .await?; + Ok(()) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index d06f0a5..0282372 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -607,6 +607,14 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .nest("/api/v1", unthrottled_routes.merge(public_routes).merge(protected_routes)) .merge(erp_health::HealthModule::fhir_routes().with_state(state.clone())) + .merge( + erp_health::HealthModule::gateway_routes() + .layer(axum::middleware::from_fn_with_state( + state.clone(), + erp_health::gateway_auth::gateway_auth_middleware, + )) + .with_state(state.clone()), + ) .nest("/uploads", uploads_router) .layer(axum::middleware::from_fn(middleware::metrics::metrics_middleware)) .layer(cors);