feat(health): BLE 网关后端接入 — 网关管理 + API Key 认证 + 多患者批量上报
- 新增 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 借用错误
This commit is contained in:
141
crates/erp-health/src/dto/ble_gateway_dto.rs
Normal file
141
crates/erp-health/src/dto/ble_gateway_dto.rs
Normal file
@@ -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<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub last_heartbeat_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
/// 网关 API Key(仅在创建时返回一次)
|
||||
pub api_key: Option<String>,
|
||||
/// 绑定的患者数量
|
||||
pub patient_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBleGatewayReq {
|
||||
pub gateway_id: String,
|
||||
pub name: String,
|
||||
pub firmware_version: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateBleGatewayReq {
|
||||
pub name: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub firmware_version: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<String>,
|
||||
pub device_type: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBindingReq {
|
||||
pub patient_id: Uuid,
|
||||
pub peripheral_mac: Option<String>,
|
||||
pub device_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchBindReq {
|
||||
pub bindings: Vec<CreateBindingReq>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 网关数据上报 DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 网关多患者批量上报请求
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GatewayUploadReq {
|
||||
pub readings: Vec<PatientReadingBatch>,
|
||||
}
|
||||
|
||||
/// 单个患者的读数批次
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PatientReadingBatch {
|
||||
pub patient_id: Uuid,
|
||||
pub device_id: String,
|
||||
pub device_model: Option<String>,
|
||||
pub readings: Vec<ReadingEntry>,
|
||||
}
|
||||
|
||||
/// 单条读数
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// 网关心跳请求
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeartbeatReq {
|
||||
pub firmware_version: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
39
crates/erp-health/src/entity/ble_gateway.rs
Normal file
39
crates/erp-health/src/entity/ble_gateway.rs
Normal file
@@ -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<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub last_heartbeat_at: Option<DateTimeUtc>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::gateway_patient_binding::Entity")]
|
||||
GatewayPatientBinding,
|
||||
}
|
||||
|
||||
impl Related<super::gateway_patient_binding::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::GatewayPatientBinding.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-health/src/entity/gateway_patient_binding.rs
Normal file
51
crates/erp-health/src/entity/gateway_patient_binding.rs
Normal file
@@ -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<String>,
|
||||
pub device_type: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
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<super::ble_gateway::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::BleGateway.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -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;
|
||||
|
||||
138
crates/erp-health/src/gateway_auth.rs
Normal file
138
crates/erp-health/src/gateway_auth.rs
Normal file
@@ -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 <api_key>` 或 `X-Gateway-Key: <api_key>`
|
||||
pub async fn gateway_auth_middleware(
|
||||
State(state): State<HealthState>,
|
||||
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 <key> 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<String> {
|
||||
// Authorization: Gateway <key>
|
||||
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: <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)
|
||||
}
|
||||
236
crates/erp-health/src/handler/ble_gateway_handler.rs
Normal file
236
crates/erp-health/src/handler/ble_gateway_handler.rs
Normal file
@@ -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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListBleGatewaysParams>,
|
||||
) -> Result<Json<ApiResponse<erp_core::types::PaginatedResponse<BleGatewayResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<BleGatewayResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateBleGatewayReq>,
|
||||
) -> Result<Json<ApiResponse<BleGatewayResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
Json(body): Json<UpdateBleGatewayWithVersion>,
|
||||
) -> Result<Json<ApiResponse<BleGatewayResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
Query(params): Query<DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<BleGatewayResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
Query(params): Query<crate::handler::shift_handler::PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<erp_core::types::PaginatedResponse<GatewayBindingResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
Json(body): Json<CreateBindingReq>,
|
||||
) -> Result<Json<ApiResponse<GatewayBindingResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(gateway_id): Path<Uuid>,
|
||||
Json(body): Json<BatchBindReq>,
|
||||
) -> Result<Json<ApiResponse<Vec<GatewayBindingResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((gateway_id, binding_id)): Path<(Uuid, Uuid)>,
|
||||
Query(params): Query<DeleteWithVersion>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<HealthState>,
|
||||
Extension(ctx): Extension<GatewayAuthContext>,
|
||||
Json(body): Json<GatewayUploadReq>,
|
||||
) -> Result<Json<ApiResponse<GatewayUploadResp>>, 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<HealthState>,
|
||||
Extension(ctx): Extension<GatewayAuthContext>,
|
||||
Json(body): Json<HeartbeatReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
ble_gateway_service::heartbeat(&state, &ctx, body).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
569
crates/erp-health/src/service/ble_gateway_service.rs
Normal file
569
crates/erp-health/src/service/ble_gateway_service.rs
Normal file
@@ -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<PaginatedResponse<BleGatewayResp>> {
|
||||
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<ble_gateway::Model> = 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<BleGatewayResp> {
|
||||
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<Uuid>,
|
||||
req: CreateBleGatewayReq,
|
||||
) -> HealthResult<BleGatewayResp> {
|
||||
// 检查 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<Uuid>,
|
||||
req: UpdateBleGatewayWithVersion,
|
||||
) -> HealthResult<BleGatewayResp> {
|
||||
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<Uuid>,
|
||||
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<Uuid>,
|
||||
) -> HealthResult<BleGatewayResp> {
|
||||
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<PaginatedResponse<GatewayBindingResp>> {
|
||||
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<gateway_patient_binding::Model> = 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<Uuid>,
|
||||
req: CreateBindingReq,
|
||||
) -> HealthResult<GatewayBindingResp> {
|
||||
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<Uuid>,
|
||||
req: BatchBindReq,
|
||||
) -> HealthResult<Vec<GatewayBindingResp>> {
|
||||
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<Uuid>,
|
||||
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<GatewayUploadResp> {
|
||||
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::Model> {
|
||||
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<i64> {
|
||||
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<String>,
|
||||
patient_count: Option<i64>,
|
||||
) -> 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(", ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user