From 8cfc5709dc3b8a6996d58eb2de8c5f12da4bc77a Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 02:56:40 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E4=BA=8B=E4=BB=B6=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E6=9B=B4=E6=96=B0=20=E2=80=94=20=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E9=99=8D=E5=99=AA=20+=20alert.aggregated=20=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 3 + crates/erp-health/src/entity/api_client.rs | 29 ++ crates/erp-health/src/entity/mod.rs | 1 + crates/erp-health/src/fhir/converter.rs | 419 ++++++++++++++++++ crates/erp-health/src/lib.rs | 2 + crates/erp-health/src/oauth/dto.rs | 112 +++++ crates/erp-health/src/oauth/error.rs | 53 +++ crates/erp-health/src/oauth/handler.rs | 113 +++++ crates/erp-health/src/oauth/middleware.rs | 118 +++++ crates/erp-health/src/oauth/mod.rs | 7 + .../m20260504_000106_create_api_clients.rs | 108 +++++ crates/erp-server/src/main.rs | 2 + docs/event-registry.md | 7 + 13 files changed, 974 insertions(+) create mode 100644 crates/erp-health/src/entity/api_client.rs create mode 100644 crates/erp-health/src/fhir/converter.rs create mode 100644 crates/erp-health/src/oauth/dto.rs create mode 100644 crates/erp-health/src/oauth/error.rs create mode 100644 crates/erp-health/src/oauth/handler.rs create mode 100644 crates/erp-health/src/oauth/middleware.rs create mode 100644 crates/erp-health/src/oauth/mod.rs create mode 100644 crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs diff --git a/Cargo.lock b/Cargo.lock index b0de519..b8e2595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/erp-health/src/entity/api_client.rs b/crates/erp-health/src/entity/api_client.rs new file mode 100644 index 0000000..e3cd251 --- /dev/null +++ b/crates/erp-health/src/entity/api_client.rs @@ -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, + pub rate_limit_per_minute: i32, + pub is_active: bool, + pub token_lifetime_seconds: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option>, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index cf18c4a..5ba1a26 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -1,4 +1,5 @@ pub mod alert_rules; +pub mod api_client; pub mod alerts; pub mod appointment; pub mod article; diff --git a/crates/erp-health/src/fhir/converter.rs b/crates/erp-health/src/fhir/converter.rs new file mode 100644 index 0000000..2114c36 --- /dev/null +++ b/crates/erp-health/src/fhir/converter.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/erp-health/src/lib.rs b/crates/erp-health/src/lib.rs index d88c50a..0023d98 100644 --- a/crates/erp-health/src/lib.rs +++ b/crates/erp-health/src/lib.rs @@ -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; diff --git a/crates/erp-health/src/oauth/dto.rs b/crates/erp-health/src/oauth/dto.rs new file mode 100644 index 0000000..5ecde23 --- /dev/null +++ b/crates/erp-health/src/oauth/dto.rs @@ -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, +} + +/// 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, + pub allowed_patient_ids: Option>, + #[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, + pub allowed_patient_ids: Option>, + 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, + 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, + pub scopes: Option>, + pub allowed_patient_ids: Option>>, + pub rate_limit_per_minute: Option, + pub is_active: Option, + pub token_lifetime_seconds: Option, + pub version: i32, +} diff --git a/crates/erp-health/src/oauth/error.rs b/crates/erp-health/src/oauth/error.rs new file mode 100644 index 0000000..986da62 --- /dev/null +++ b/crates/erp-health/src/oauth/error.rs @@ -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 = Result; + +impl From 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 for OAuthError { + fn from(err: sea_orm::DbErr) -> Self { + OAuthError::DbError(err.to_string()) + } +} diff --git a/crates/erp-health/src/oauth/handler.rs b/crates/erp-health/src/oauth/handler.rs new file mode 100644 index 0000000..d510e8b --- /dev/null +++ b/crates/erp-health/src/oauth/handler.rs @@ -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, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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, + Extension(tenant_ctx): Extension, + Json(req): Json, +) -> Result, 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, + Extension(tenant_ctx): Extension, +) -> Result>, 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, + Extension(tenant_ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result, 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, + Extension(tenant_ctx): Extension, + Path(id): Path, +) -> Result { + 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, + Extension(tenant_ctx): Extension, + Path(id): Path, +) -> Result, 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, + }))) +} diff --git a/crates/erp-health/src/oauth/middleware.rs b/crates/erp-health/src/oauth/middleware.rs new file mode 100644 index 0000000..6a216c0 --- /dev/null +++ b/crates/erp-health/src/oauth/middleware.rs @@ -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 客户端 ID(api_clients 表主键) + pub sub: Uuid, + /// 租户 ID + pub tid: Uuid, + /// 允许的 FHIR scope 列表 + pub scopes: Vec, + /// 允许访问的患者 ID 列表(None = 该租户下全部患者) + pub allowed_patient_ids: Option>, + /// 速率限制(每分钟请求数) + 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, + pub allowed_patient_ids: Option>, +} + +/// 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::( + 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 +} diff --git a/crates/erp-health/src/oauth/mod.rs b/crates/erp-health/src/oauth/mod.rs new file mode 100644 index 0000000..79b630a --- /dev/null +++ b/crates/erp-health/src/oauth/mod.rs @@ -0,0 +1,7 @@ +pub mod dto; +pub mod error; +pub mod handler; +pub mod middleware; +pub mod service; + +pub use error::OAuthError; diff --git a/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs new file mode 100644 index 0000000..363c90f --- /dev/null +++ b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs @@ -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 + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 0a380d3..d06f0a5 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -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); diff --git a/docs/event-registry.md b/docs/event-registry.md index 84d31fc..4209154 100644 --- a/docs/event-registry.md +++ b/docs/event-registry.md @@ -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 | + ### 医生管理 | 事件类型 | 发布者 | 消费者 | 状态 |