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 appointment_dto;
|
||||||
pub mod alert_dto;
|
pub mod alert_dto;
|
||||||
|
pub mod ble_gateway_dto;
|
||||||
pub mod care_plan_dto;
|
pub mod care_plan_dto;
|
||||||
pub mod article_dto;
|
pub mod article_dto;
|
||||||
pub mod consent_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 alert_rules;
|
||||||
|
pub mod ble_gateway;
|
||||||
pub mod api_client;
|
pub mod api_client;
|
||||||
pub mod alerts;
|
pub mod alerts;
|
||||||
pub mod appointment;
|
pub mod appointment;
|
||||||
@@ -23,6 +24,7 @@ pub mod follow_up_record;
|
|||||||
pub mod follow_up_task;
|
pub mod follow_up_task;
|
||||||
pub mod follow_up_template;
|
pub mod follow_up_template;
|
||||||
pub mod follow_up_template_field;
|
pub mod follow_up_template_field;
|
||||||
|
pub mod gateway_patient_binding;
|
||||||
pub mod health_record;
|
pub mod health_record;
|
||||||
pub mod health_trend;
|
pub mod health_trend;
|
||||||
pub mod lab_report;
|
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 action_inbox_handler;
|
||||||
pub mod alert_handler;
|
pub mod alert_handler;
|
||||||
|
pub mod ble_gateway_handler;
|
||||||
pub mod alert_rule_handler;
|
pub mod alert_rule_handler;
|
||||||
pub mod appointment_handler;
|
pub mod appointment_handler;
|
||||||
pub mod article_category_handler;
|
pub mod article_category_handler;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod entity;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fhir;
|
pub mod fhir;
|
||||||
|
pub mod gateway_auth;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod health_provider_impl;
|
pub mod health_provider_impl;
|
||||||
pub mod module;
|
pub mod module;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
|
|||||||
use crate::handler::{
|
use crate::handler::{
|
||||||
action_inbox_handler,
|
action_inbox_handler,
|
||||||
alert_handler, alert_rule_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,
|
health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, shift_handler, stats_handler,
|
||||||
vital_signs_daily_handler,
|
vital_signs_daily_handler,
|
||||||
};
|
};
|
||||||
@@ -884,6 +885,46 @@ impl HealthModule {
|
|||||||
axum::routing::get(shift_handler::list_handoffs)
|
axum::routing::get(shift_handler::list_handoffs)
|
||||||
.post(shift_handler::create_handoff),
|
.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(),
|
description: "创建/编辑班次、分配患者、创建交接记录".into(),
|
||||||
module: "health".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_action_dispatcher;
|
||||||
pub mod ai_suggestion_loader;
|
pub mod ai_suggestion_loader;
|
||||||
pub mod alert_engine;
|
pub mod alert_engine;
|
||||||
|
pub mod ble_gateway_service;
|
||||||
pub mod alert_rule_service;
|
pub mod alert_rule_service;
|
||||||
pub mod alert_service;
|
pub mod alert_service;
|
||||||
pub mod appointment_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 m20260504_000110_alter_critical_alerts_version_i32;
|
||||||
mod m20260505_000111_create_care_plan;
|
mod m20260505_000111_create_care_plan;
|
||||||
mod m20260505_000112_create_shift_management;
|
mod m20260505_000112_create_shift_management;
|
||||||
|
mod m20260505_000113_create_ble_gateways;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -231,6 +232,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260504_000110_alter_critical_alerts_version_i32::Migration),
|
Box::new(m20260504_000110_alter_critical_alerts_version_i32::Migration),
|
||||||
Box::new(m20260505_000111_create_care_plan::Migration),
|
Box::new(m20260505_000111_create_care_plan::Migration),
|
||||||
Box::new(m20260505_000112_create_shift_management::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
|
manager
|
||||||
.create_foreign_key(
|
.create_foreign_key(
|
||||||
&mut ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_patient_assignments_shift")
|
.name("fk_patient_assignments_shift")
|
||||||
.from(PatientAssignment::Table, PatientAssignment::ShiftId)
|
.from(PatientAssignment::Table, PatientAssignment::ShiftId)
|
||||||
.to(Shift::Table, Shift::Id)
|
.to(Shift::Table, Shift::Id)
|
||||||
@@ -147,7 +147,7 @@ impl MigrationTrait for Migration {
|
|||||||
|
|
||||||
manager
|
manager
|
||||||
.create_foreign_key(
|
.create_foreign_key(
|
||||||
&mut ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_handoff_log_from_shift")
|
.name("fk_handoff_log_from_shift")
|
||||||
.from(HandoffLog::Table, HandoffLog::FromShiftId)
|
.from(HandoffLog::Table, HandoffLog::FromShiftId)
|
||||||
.to(Shift::Table, Shift::Id)
|
.to(Shift::Table, Shift::Id)
|
||||||
@@ -158,7 +158,7 @@ impl MigrationTrait for Migration {
|
|||||||
|
|
||||||
manager
|
manager
|
||||||
.create_foreign_key(
|
.create_foreign_key(
|
||||||
&mut ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_handoff_log_to_shift")
|
.name("fk_handoff_log_to_shift")
|
||||||
.from(HandoffLog::Table, HandoffLog::ToShiftId)
|
.from(HandoffLog::Table, HandoffLog::ToShiftId)
|
||||||
.to(Shift::Table, Shift::Id)
|
.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()
|
let app = Router::new()
|
||||||
.nest("/api/v1", unthrottled_routes.merge(public_routes).merge(protected_routes))
|
.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::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)
|
.nest("/uploads", uploads_router)
|
||||||
.layer(axum::middleware::from_fn(middleware::metrics::metrics_middleware))
|
.layer(axum::middleware::from_fn(middleware::metrics::metrics_middleware))
|
||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|||||||
Reference in New Issue
Block a user