docs: 事件注册表更新 — 告警降噪 + alert.aggregated 事件

This commit is contained in:
iven
2026-05-04 02:56:40 +08:00
parent 29b47ae4e4
commit 8cfc5709dc
13 changed files with 974 additions and 0 deletions

3
Cargo.lock generated
View File

@@ -1527,6 +1527,7 @@ name = "erp-health"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
@@ -1534,7 +1535,9 @@ dependencies = [
"erp-core",
"hex",
"hmac",
"jsonwebtoken",
"num-traits",
"rand_core 0.6.4",
"sea-orm",
"serde",
"serde_json",

View File

@@ -0,0 +1,29 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "api_clients")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub client_id: String,
pub client_secret_hash: String,
pub client_name: String,
pub scopes: serde_json::Value,
pub allowed_patient_ids: Option<serde_json::Value>,
pub rate_limit_per_minute: i32,
pub is_active: bool,
pub token_lifetime_seconds: i32,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
pub updated_at: chrono::DateTime<chrono::FixedOffset>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub deleted_at: Option<chrono::DateTime<chrono::FixedOffset>>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,4 +1,5 @@
pub mod alert_rules;
pub mod api_client;
pub mod alerts;
pub mod appointment;
pub mod article;

View File

@@ -0,0 +1,419 @@
use crate::entity::{
appointment, consultation_session, device_readings, doctor_profile, follow_up_task,
lab_report, patient, patient_devices,
};
use crate::fhir::types::{device_type_to_category, device_type_to_loinc, device_type_to_unit};
/// HMS Patient → FHIR Patient (JSON Value)
pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
let mut result = serde_json::json!({
"resourceType": "Patient",
"id": p.id.to_string(),
"name": [{
"text": p.name,
}],
"meta": {
"source": "hms",
"lastUpdated": p.updated_at.to_rfc3339(),
},
});
if let Some(ref gender) = p.gender {
result["gender"] = serde_json::json!(match gender.as_str() {
"male" | "M" => "male",
"female" | "F" => "female",
"other" => "other",
_ => "unknown",
});
}
if let Some(bd) = p.birth_date {
result["birthDate"] = serde_json::json!(bd.to_string());
}
if let Some(ref id_number) = p.id_number {
result["identifier"] = serde_json::json!([{
"system": "urn:oid:2.16.156.10011.1.3",
"value": id_number,
}]);
}
result
}
/// HMS DeviceReading → FHIR Observation血压拆分为多个其余为单个
pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<serde_json::Value> {
let mut results = Vec::new();
let patient_ref = serde_json::json!({"reference": format!("Patient/{}", r.patient_id)});
let device_ref = r.device_id.as_ref().map(|d| {
serde_json::json!({"reference": format!("Device/{}", d)})
});
let measured = r.measured_at.to_rfc3339();
let category = device_type_to_category(&r.device_type);
let category_json = serde_json::json!([{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": category,
}]
}]);
match r.device_type.as_str() {
"blood_pressure" => {
let sys = r.raw_value.get("systolic").and_then(|v| v.as_f64());
let dia = r.raw_value.get("diastolic").and_then(|v| v.as_f64());
if let Some(val) = sys {
results.push(make_observation(
&r.id, "8480-6", "Systolic blood pressure",
category_json.clone(), &patient_ref, device_ref.as_ref(),
&measured, val, "mmHg", "mm[Hg]",
));
}
if let Some(val) = dia {
results.push(make_observation(
&r.id, "8462-4", "Diastolic blood pressure",
category_json, &patient_ref, device_ref.as_ref(),
&measured, val, "mmHg", "mm[Hg]",
));
}
}
_ => {
let (loinc_code, loinc_display) = device_type_to_loinc(&r.device_type)
.unwrap_or(("unknown", "Unknown"));
let (unit_display, unit_code) = device_type_to_unit(&r.device_type);
let val = extract_main_value(&r.device_type, &r.raw_value);
if let Some(v) = val {
results.push(make_observation(
&r.id, loinc_code, loinc_display,
category_json, &patient_ref, device_ref.as_ref(),
&measured, v, unit_display, unit_code,
));
}
}
}
results
}
fn make_observation(
reading_id: &uuid::Uuid, code: &str, display: &str,
category: serde_json::Value, subject: &serde_json::Value,
device: Option<&serde_json::Value>, effective: &str,
value: f64, unit_display: &str, unit_code: &str,
) -> serde_json::Value {
let mut obs = serde_json::json!({
"resourceType": "Observation",
"id": reading_id.to_string(),
"status": "final",
"category": category,
"code": {
"coding": [{
"system": "http://loinc.org",
"code": code,
"display": display,
}]
},
"subject": subject,
"effectiveDateTime": effective,
"valueQuantity": {
"value": value,
"unit": unit_display,
"system": "http://unitsofmeasure.org",
"code": unit_code,
},
"meta": { "source": "hms-device" },
});
if let Some(d) = device {
obs["device"] = d.clone();
}
obs
}
fn extract_main_value(device_type: &str, raw: &serde_json::Value) -> Option<f64> {
match device_type {
"heart_rate" => raw.get("heart_rate").and_then(|v| v.as_f64()),
"blood_oxygen" => raw.get("blood_oxygen").and_then(|v| v.as_f64()),
"temperature" => raw.get("temperature").and_then(|v| v.as_f64()),
"blood_glucose" => raw.get("blood_glucose").and_then(|v| v.as_f64()),
"steps" => raw.get("steps").and_then(|v| v.as_f64()),
"sleep" => raw.get("sleep_duration").and_then(|v| v.as_f64()),
"stress" => raw.get("stress_level").and_then(|v| v.as_f64()),
_ => raw.as_f64(),
}
}
/// HMS PatientDevice → FHIR Device
pub fn patient_device_to_fhir(d: &patient_devices::Model) -> serde_json::Value {
let mut device = serde_json::json!({
"resourceType": "Device",
"id": d.id.to_string(),
"identifier": [{
"system": "urn:hms:device-id",
"value": d.device_id,
}],
"status": "active",
"meta": { "source": "hms-device" },
});
if let Some(ref model) = d.device_model {
device["displayName"] = serde_json::json!(model);
}
if let Some(ref dt) = d.device_type {
device["type"] = serde_json::json!({
"coding": [{
"system": "urn:hms:device-type",
"code": dt,
}]
});
}
device
}
/// HMS DoctorProfile → FHIR Practitioner
pub fn doctor_to_fhir(d: &doctor_profile::Model) -> serde_json::Value {
let mut practitioner = serde_json::json!({
"resourceType": "Practitioner",
"id": d.id.to_string(),
"name": [{
"text": d.name,
}],
"meta": { "source": "hms-practitioner" },
});
let mut qualifications = Vec::new();
if let Some(ref title) = d.title {
qualifications.push(serde_json::json!({
"code": {
"coding": [{
"system": "urn:hms:doctor-title",
"display": title,
}]
}
}));
}
if let Some(ref dept) = d.department {
qualifications.push(serde_json::json!({
"code": {
"coding": [{
"system": "urn:hms:department",
"display": dept,
}]
}
}));
}
if !qualifications.is_empty() {
practitioner["qualification"] = serde_json::json!(qualifications);
}
if let Some(ref specialty) = d.specialty {
practitioner["specialty"] = serde_json::json!([{
"coding": [{
"system": "urn:hms:specialty",
"display": specialty,
}]
}]);
}
practitioner
}
/// HMS Appointment → FHIR Appointment
pub fn appointment_to_fhir(a: &appointment::Model) -> serde_json::Value {
let fhir_status = match a.status.as_str() {
"confirmed" | "completed" => a.status.as_str(),
"pending" => "proposed",
"cancelled" => "cancelled",
_ => "booked",
};
let mut participants = vec![
serde_json::json!({
"actor": {"reference": format!("Patient/{}", a.patient_id)},
"status": "accepted",
}),
];
if let Some(ref doctor_id) = a.doctor_id {
participants.push(serde_json::json!({
"actor": {"reference": format!("Practitioner/{}", doctor_id)},
"status": "accepted",
}));
}
let start = format!("{}T{}", a.appointment_date, a.start_time);
let end = format!("{}T{}", a.appointment_date, a.end_time);
serde_json::json!({
"resourceType": "Appointment",
"id": a.id.to_string(),
"status": fhir_status,
"participant": participants,
"start": start,
"end": end,
"meta": { "source": "hms-appointment" },
})
}
/// HMS LabReport → FHIR DiagnosticReport
pub fn lab_report_to_fhir(r: &lab_report::Model) -> serde_json::Value {
let fhir_status = match r.status.as_str() {
"reviewed" => "final",
"pending" => "preliminary",
_ => "registered",
};
serde_json::json!({
"resourceType": "DiagnosticReport",
"id": r.id.to_string(),
"status": fhir_status,
"code": {
"coding": [{
"system": "urn:hms:report-type",
"code": r.report_type,
}]
},
"subject": {"reference": format!("Patient/{}", r.patient_id)},
"effectiveDateTime": r.report_date.to_string(),
"meta": { "source": "hms-diagnostic-report" },
})
}
/// HMS ConsultationSession → FHIR Encounter
pub fn consultation_to_fhir(c: &consultation_session::Model) -> serde_json::Value {
let fhir_status = match c.status.as_str() {
"active" => "in-progress",
"closed" => "finished",
_ => "planned",
};
let class_code = match c.consultation_type.as_str() {
"online" => "VR",
"offline" => "AMB",
_ => "AMB",
};
serde_json::json!({
"resourceType": "Encounter",
"id": c.id.to_string(),
"status": fhir_status,
"class": {
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": class_code,
},
"subject": {"reference": format!("Patient/{}", c.patient_id)},
"meta": { "source": "hms-encounter" },
})
}
/// HMS FollowUpTask → FHIR Task
pub fn follow_up_to_fhir(t: &follow_up_task::Model) -> serde_json::Value {
let fhir_status = match t.status.as_str() {
"pending" => "requested",
"in_progress" => "in-progress",
"completed" => "completed",
"cancelled" => "cancelled",
_ => "requested",
};
let display = t.content_template.as_deref()
.unwrap_or(&t.follow_up_type);
serde_json::json!({
"resourceType": "Task",
"id": t.id.to_string(),
"status": fhir_status,
"intent": "plan",
"focus": {
"display": display,
},
"for": {"reference": format!("Patient/{}", t.patient_id)},
"meta": { "source": "hms-task" },
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_patient_to_fhir_basic() {
let p = patient::Model {
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
tenant_id: uuid::Uuid::now_v7(),
user_id: None,
name: "\u{5f20}\u{4e09}".into(),
gender: Some("male".into()),
birth_date: chrono::NaiveDate::from_ymd_opt(1968, 5, 15),
blood_type: None,
id_number: Some("110101196805150001".into()),
id_number_hash: None,
allergy_history: None,
medical_history_summary: None,
emergency_contact_name: None,
emergency_contact_phone: None,
emergency_contact_phone_hash: None,
key_version: None,
status: "active".into(),
verification_status: "verified".into(),
source: None,
notes: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
created_by: None,
updated_by: None,
deleted_at: None,
version: 1,
};
let fhir = patient_to_fhir(&p);
assert_eq!(fhir["resourceType"], "Patient");
assert_eq!(fhir["id"], "00000000-0000-0000-0000-000000000001");
assert_eq!(fhir["gender"], "male");
assert_eq!(fhir["birthDate"], "1968-05-15");
assert_eq!(fhir["identifier"][0]["value"], "110101196805150001");
}
#[test]
fn test_heart_rate_to_fhir_observation() {
let reading = device_readings::Model {
id: uuid::Uuid::now_v7(),
tenant_id: uuid::Uuid::now_v7(),
patient_id: uuid::Uuid::now_v7(),
device_id: Some("band-001".into()),
device_type: "heart_rate".into(),
metric: None,
device_model: Some("Mi Band 8".into()),
raw_value: serde_json::json!({"heart_rate": 78}),
measured_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
deleted_at: None,
};
let observations = device_reading_to_fhir_observations(&reading);
assert_eq!(observations.len(), 1);
let obs = &observations[0];
assert_eq!(obs["resourceType"], "Observation");
assert_eq!(obs["code"]["coding"][0]["code"], "8867-4");
assert!((obs["valueQuantity"]["value"].as_f64().unwrap() - 78.0).abs() < f64::EPSILON);
}
#[test]
fn test_blood_pressure_to_fhir_observations() {
let reading = device_readings::Model {
id: uuid::Uuid::now_v7(),
tenant_id: uuid::Uuid::now_v7(),
patient_id: uuid::Uuid::now_v7(),
device_id: Some("bp-001".into()),
device_type: "blood_pressure".into(),
metric: None,
device_model: Some("Omron HEM-7322".into()),
raw_value: serde_json::json!({"systolic": 135, "diastolic": 88}),
measured_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
deleted_at: None,
};
let observations = device_reading_to_fhir_observations(&reading);
assert!(observations.len() >= 2);
}
}

View File

@@ -3,9 +3,11 @@ pub mod dto;
pub mod entity;
pub mod error;
pub mod event;
pub mod fhir;
pub mod handler;
pub mod health_provider_impl;
pub mod module;
pub mod oauth;
pub mod service;
pub mod state;

View File

@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// RFC 6749 §4.4 Client Credentials Grant 请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct TokenRequest {
pub grant_type: String,
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub scope: Option<String>,
}
/// RFC 6749 §5.1 成功令牌响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub scope: String,
}
/// RFC 6749 §5.2 错误响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TokenErrorResponse {
pub error: String,
pub error_description: String,
}
impl TokenErrorResponse {
pub fn invalid_client(desc: &str) -> Self {
Self {
error: "invalid_client".into(),
error_description: desc.into(),
}
}
pub fn invalid_grant(desc: &str) -> Self {
Self {
error: "invalid_grant".into(),
error_description: desc.into(),
}
}
pub fn invalid_scope(desc: &str) -> Self {
Self {
error: "invalid_scope".into(),
error_description: desc.into(),
}
}
}
/// 合作方创建请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateApiClientReq {
pub client_name: String,
pub scopes: Vec<String>,
pub allowed_patient_ids: Option<Vec<String>>,
#[serde(default = "default_rate_limit")]
pub rate_limit_per_minute: i32,
#[serde(default = "default_token_lifetime")]
pub token_lifetime_seconds: i32,
}
fn default_rate_limit() -> i32 {
60
}
fn default_token_lifetime() -> i32 {
3600
}
/// 合作方响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiClientResp {
pub id: String,
pub tenant_id: String,
pub client_id: String,
pub client_secret: String,
pub client_name: String,
pub scopes: Vec<String>,
pub allowed_patient_ids: Option<Vec<String>>,
pub rate_limit_per_minute: i32,
pub is_active: bool,
pub token_lifetime_seconds: i32,
pub created_at: String,
}
/// 合作方列表响应(不含 secret
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiClientListItem {
pub id: String,
pub client_id: String,
pub client_name: String,
pub scopes: Vec<String>,
pub rate_limit_per_minute: i32,
pub is_active: bool,
pub token_lifetime_seconds: i32,
pub created_at: String,
}
/// 更新合作方请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiClientReq {
pub client_name: Option<String>,
pub scopes: Option<Vec<String>>,
pub allowed_patient_ids: Option<Option<Vec<String>>>,
pub rate_limit_per_minute: Option<i32>,
pub is_active: Option<bool>,
pub token_lifetime_seconds: Option<i32>,
pub version: i32,
}

View File

@@ -0,0 +1,53 @@
use erp_core::error::AppError;
#[derive(Debug, thiserror::Error)]
pub enum OAuthError {
#[error("无效的客户端凭据")]
InvalidClient,
#[error("客户端未激活")]
ClientInactive,
#[error("请求的 scope 超出允许范围")]
InvalidScope,
#[error("grant_type 只支持 client_credentials")]
UnsupportedGrantType,
#[error("请求过于频繁,超出速率限制")]
RateLimitExceeded,
#[error("客户端未找到")]
ClientNotFound,
#[error("数据库错误: {0}")]
DbError(String),
#[error("哈希错误: {0}")]
HashError(String),
#[error("JWT 错误: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
}
pub type OAuthResult<T> = Result<T, OAuthError>;
impl From<OAuthError> for AppError {
fn from(err: OAuthError) -> Self {
match err {
OAuthError::InvalidClient | OAuthError::ClientInactive => AppError::Unauthorized,
OAuthError::InvalidScope => AppError::Forbidden(err.to_string()),
OAuthError::UnsupportedGrantType => AppError::Validation(err.to_string()),
OAuthError::RateLimitExceeded => AppError::TooManyRequests,
OAuthError::ClientNotFound => AppError::NotFound(err.to_string()),
OAuthError::DbError(_) | OAuthError::HashError(_) => AppError::Internal(err.to_string()),
OAuthError::JwtError(_) => AppError::Unauthorized,
}
}
}
impl From<sea_orm::DbErr> for OAuthError {
fn from(err: sea_orm::DbErr) -> Self {
OAuthError::DbError(err.to_string())
}
}

View File

@@ -0,0 +1,113 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Extension, Json,
};
use erp_core::error::AppError;
use erp_core::types::TenantContext;
use uuid::Uuid;
use crate::oauth::dto::*;
use crate::oauth::error::OAuthError;
use crate::oauth::service::OAuthService;
use crate::state::HealthState;
/// POST /oauth/token — RFC 6749 Client Credentials Grant
pub async fn token(
State(state): State<HealthState>,
Json(req): Json<TokenRequest>,
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-key".to_string());
match OAuthService::token(&state.db, &req, &jwt_secret).await {
Ok(resp) => Ok((StatusCode::OK, Json(resp))),
Err(OAuthError::InvalidClient | OAuthError::ClientInactive) => Err((
StatusCode::UNAUTHORIZED,
Json(TokenErrorResponse::invalid_client("客户端认证失败")),
)),
Err(OAuthError::InvalidScope) => Err((
StatusCode::BAD_REQUEST,
Json(TokenErrorResponse::invalid_scope("请求的 scope 超出允许范围")),
)),
Err(OAuthError::UnsupportedGrantType) => Err((
StatusCode::BAD_REQUEST,
Json(TokenErrorResponse::invalid_grant("仅支持 client_credentials")),
)),
Err(OAuthError::RateLimitExceeded) => Err((
StatusCode::TOO_MANY_REQUESTS,
Json(TokenErrorResponse::invalid_client("速率限制已超出")),
)),
Err(e) => {
tracing::error!(error = %e, "OAuth token 端点内部错误");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(TokenErrorResponse::invalid_client("内部错误")),
))
}
}
}
/// POST /api/v1/health/oauth/clients — 创建合作方
pub async fn create_client(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
Json(req): Json<CreateApiClientReq>,
) -> Result<Json<ApiClientResp>, AppError> {
OAuthService::create_client(&state.db, tenant_ctx.tenant_id, &req, tenant_ctx.user_id)
.await
.map_err(AppError::from)
.map(Json)
}
/// GET /api/v1/health/oauth/clients — 列出合作方
pub async fn list_clients(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
) -> Result<Json<Vec<ApiClientListItem>>, AppError> {
OAuthService::list_clients(&state.db, tenant_ctx.tenant_id)
.await
.map_err(AppError::from)
.map(Json)
}
/// PUT /api/v1/health/oauth/clients/{id} — 更新合作方
pub async fn update_client(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateApiClientReq>,
) -> Result<Json<ApiClientListItem>, AppError> {
OAuthService::update_client(&state.db, tenant_ctx.tenant_id, id, &req, tenant_ctx.user_id)
.await
.map_err(AppError::from)
.map(Json)
}
/// DELETE /api/v1/health/oauth/clients/{id} — 删除合作方
pub async fn delete_client(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
OAuthService::delete_client(&state.db, tenant_ctx.tenant_id, id)
.await
.map_err(AppError::from)?;
Ok(StatusCode::NO_CONTENT)
}
/// POST /api/v1/health/oauth/clients/{id}/regenerate-secret — 重新生成 secret
pub async fn regenerate_secret(
State(state): State<HealthState>,
Extension(tenant_ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
let (client_id, plain) =
OAuthService::regenerate_secret(&state.db, tenant_ctx.tenant_id, id)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::json!({
"client_id": client_id,
"client_secret": plain,
})))
}

View File

@@ -0,0 +1,118 @@
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Client Credentials JWT Claims
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientCredentialsClaims {
/// API 客户端 IDapi_clients 表主键)
pub sub: Uuid,
/// 租户 ID
pub tid: Uuid,
/// 允许的 FHIR scope 列表
pub scopes: Vec<String>,
/// 允许访问的患者 ID 列表None = 该租户下全部患者)
pub allowed_patient_ids: Option<Vec<String>>,
/// 速率限制(每分钟请求数)
pub rate_limit_per_minute: i32,
/// 过期时间
pub exp: i64,
/// 签发时间
pub iat: i64,
/// 令牌类型标识
pub token_type: String,
}
/// FHIR 请求上下文 — 中间件注入到请求扩展中
#[derive(Debug, Clone)]
pub struct FhirAuthContext {
pub client_id: Uuid,
pub tenant_id: Uuid,
pub scopes: Vec<String>,
pub allowed_patient_ids: Option<Vec<String>>,
}
/// FHIR OAuth 认证中间件
pub async fn oauth_auth_middleware(request: Request, next: Next) -> Response {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok());
let token = match auth_header {
Some(header) if header.starts_with("Bearer ") => &header[7..],
_ => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "login",
"diagnostics": "Missing or invalid Authorization header"
}]
})),
)
.into_response();
}
};
let jwt_secret = std::env::var("ERP__AUTH__JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-key".to_string());
let claims = match jsonwebtoken::decode::<ClientCredentialsClaims>(
token,
&jsonwebtoken::DecodingKey::from_secret(jwt_secret.as_bytes()),
&jsonwebtoken::Validation::default(),
) {
Ok(data) => data.claims,
Err(e) => {
tracing::warn!(error = %e, "FHIR OAuth token 验证失败");
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "login",
"diagnostics": "Invalid or expired token"
}]
})),
)
.into_response();
}
};
if claims.token_type != "client_credentials" {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"resourceType": "OperationOutcome",
"issue": [{
"severity": "error",
"code": "login",
"diagnostics": "Token is not a client_credentials token"
}]
})),
)
.into_response();
}
let fhir_ctx = FhirAuthContext {
client_id: claims.sub,
tenant_id: claims.tid,
scopes: claims.scopes,
allowed_patient_ids: claims.allowed_patient_ids,
};
let mut request = request;
request.extensions_mut().insert(fhir_ctx);
next.run(request).await
}

View File

@@ -0,0 +1,7 @@
pub mod dto;
pub mod error;
pub mod handler;
pub mod middleware;
pub mod service;
pub use error::OAuthError;

View File

@@ -0,0 +1,108 @@
use sea_orm_migration::prelude::*;
#[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("api_clients"))
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.default(Expr::val("gen_random_uuid()")),
)
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(
ColumnDef::new(Alias::new("client_id"))
.string_len(128)
.not_null(),
)
.col(
ColumnDef::new(Alias::new("client_secret_hash"))
.string_len(256)
.not_null(),
)
.col(
ColumnDef::new(Alias::new("client_name"))
.string_len(200)
.not_null(),
)
.col(ColumnDef::new(Alias::new("scopes")).json().not_null())
.col(ColumnDef::new(Alias::new("allowed_patient_ids")).json().null())
.col(
ColumnDef::new(Alias::new("rate_limit_per_minute"))
.integer()
.not_null()
.default(60),
)
.col(
ColumnDef::new(Alias::new("is_active"))
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(Alias::new("token_lifetime_seconds"))
.integer()
.not_null()
.default(3600),
)
.col(
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::val("NOW()")),
)
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
.col(
ColumnDef::new(Alias::new("version"))
.integer()
.not_null()
.default(1),
)
.primary_key(Index::create().col(Alias::new("id")))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_api_clients_client_id_unique")
.table(Alias::new("api_clients"))
.col(Alias::new("client_id"))
.unique()
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_api_clients_tenant_id")
.table(Alias::new("api_clients"))
.col(Alias::new("tenant_id"))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Alias::new("api_clients")).to_owned())
.await
}
}

View File

@@ -525,6 +525,7 @@ async fn main() -> anyhow::Result<()> {
// So account lockout check runs FIRST, then IP rate limiting
let public_routes = Router::new()
.merge(erp_auth::AuthModule::public_routes())
.merge(erp_health::HealthModule::public_routes())
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::rate_limit::account_lockout_middleware,
@@ -605,6 +606,7 @@ 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()))
.nest("/uploads", uploads_router)
.layer(axum::middleware::from_fn(middleware::metrics::metrics_middleware))
.layer(cors);

View File

@@ -87,6 +87,13 @@
|---------|--------|--------|------|
| `device.readings.synced` | device_reading_service.rs | erp-health event.rs (告警引擎评估) | OK |
### 告警系统
| 事件类型 | 发布者 | 消费者 | 状态 |
|---------|--------|--------|------|
| `alert.triggered` | alert_engine.rs | erp-health (告警通知) + alert_aggregator (聚合检测) | OK |
| `alert.aggregated` | alert_aggregator | SSE 推送 (聚合通知) | OK |
### 医生管理
| 事件类型 | 发布者 | 消费者 | 状态 |