fix(health): 穷尽审计修复 — 权限同步/编译错误/前端bug/审计日志
审计发现并修复的问题: HIGH: - H1: ConsultationDetail 使用 getSession(id) 替代错误的列表搜索 - H2: SessionResp 添加 version/updated_at 字段 - H3: 移除 FollowUpRecordList 调用不存在的导出端点 - H4: 新增 articles.ts 前端 API 模块 MEDIUM: - M1: article delete 添加乐观锁 (expected_version) - M2: 取消预约排班释放传播错误 (log::warn -> ?) - M3: FollowUpTaskList 日期格式 Dayjs -> string - M4: 补充 15 个缺失审计日志 LOW: - L1: 替换 follow_up_service 中的 .unwrap() - L2: PatientListItem 添加 version 字段 CRITICAL (新发现): - 权限未同步: 健康模块 14 个权限从未写入数据库,添加启动时自动同步 - migration 表名错误: patients -> patient - 编译错误: health_trend entity 未导入, ToPrimitive trait 未导入 - HealthError 缺少 From<AppError> 实现
This commit is contained in:
@@ -88,3 +88,82 @@ impl HealthCrypto {
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_crypto() -> HealthCrypto {
|
||||
HealthCrypto::dev_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = "110101199001011234";
|
||||
let encrypted = crypto.encrypt(plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted).unwrap();
|
||||
assert_eq!(plaintext, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_produces_different_ciphertexts() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = "110101199001011234";
|
||||
let e1 = crypto.encrypt(plaintext).unwrap();
|
||||
let e2 = crypto.encrypt(plaintext).unwrap();
|
||||
assert_ne!(e1, e2); // 不同 nonce 导致不同密文
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wrong_key_fails() {
|
||||
let crypto1 = HealthCrypto::dev_default();
|
||||
let hex_key = "00".repeat(32); // 64 个 0
|
||||
let crypto2 = HealthCrypto::from_keys(&hex_key, &hex_key).unwrap();
|
||||
let encrypted = crypto1.encrypt("test").unwrap();
|
||||
assert!(crypto2.decrypt(&encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_hash_deterministic() {
|
||||
let crypto = test_crypto();
|
||||
let hash1 = crypto.hmac_hash("110101199001011234");
|
||||
let hash2 = crypto.hmac_hash("110101199001011234");
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_hash_different_inputs() {
|
||||
let crypto = test_crypto();
|
||||
let h1 = crypto.hmac_hash("123456789012345678");
|
||||
let h2 = crypto.hmac_hash("987654321098765432");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_empty_string() {
|
||||
let crypto = test_crypto();
|
||||
let encrypted = crypto.encrypt("").unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted).unwrap();
|
||||
assert_eq!("", decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_too_short_fails() {
|
||||
let crypto = test_crypto();
|
||||
let short = BASE64.encode(b"short");
|
||||
assert!(crypto.decrypt(&short).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_keys_invalid_hex() {
|
||||
let result = HealthCrypto::from_keys("not-hex", "not-hex");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_keys_wrong_length() {
|
||||
let result = HealthCrypto::from_keys("ab", "cd");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct SessionResp {
|
||||
pub unread_count_patient: i32,
|
||||
pub unread_count_doctor: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -4,7 +4,10 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 用 f64 替代 Decimal 以满足 utoipa ToSchema
|
||||
/// 用 f64 表示 Decimal 值以满足 utoipa ToSchema 要求。
|
||||
/// 对于健康数值(血压 60-200mmHg、血糖 3.9-11.1mmol/L、体重 30-300kg),
|
||||
/// f64 的 15 位有效数字精度完全足够,不存在实际精度丢失风险。
|
||||
/// 数据库层仍使用 SeaORM Decimal 类型,转换仅在 DTO 边界进行。
|
||||
type Decimal = f64;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -5,3 +5,8 @@ pub mod doctor_dto;
|
||||
pub mod follow_up_dto;
|
||||
pub mod health_data_dto;
|
||||
pub mod patient_dto;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl From<sea_orm::DbErr> for HealthError {
|
||||
|
||||
impl From<AppError> for HealthError {
|
||||
fn from(err: AppError) -> Self {
|
||||
HealthError::DbError(err.to_string())
|
||||
HealthError::Validation(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,16 +77,22 @@ where
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteArticleReq {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn delete_article<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
Json(req): Json<DeleteArticleReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
|
||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
@@ -44,6 +45,8 @@ pub struct ExportSessionsParams {
|
||||
pub status: Option<String>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn create_session<S>(
|
||||
@@ -83,6 +86,20 @@ where
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_session<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.list")?;
|
||||
let result = consultation_service::get_session(&state, ctx.tenant_id, id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn list_messages<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
@@ -131,10 +148,18 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.consultation.manage")?;
|
||||
let is_doctor = crate::entity::doctor_profile::Entity::find()
|
||||
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
|
||||
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
|
||||
.filter(crate::entity::doctor_profile::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?
|
||||
.is_some();
|
||||
let mut msg_req = CreateMessageReq {
|
||||
session_id: req.session_id,
|
||||
sender_id: ctx.user_id,
|
||||
sender_role: "doctor".to_string(),
|
||||
sender_role: if is_doctor { "doctor" } else { "patient" }.to_string(),
|
||||
content_type: req.content_type,
|
||||
content: req.content,
|
||||
};
|
||||
@@ -150,7 +175,7 @@ pub async fn export_sessions<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ExportSessionsParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<SessionResp>>>, AppError>
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<SessionResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -158,6 +183,7 @@ where
|
||||
require_permission(&ctx, "health.consultation.list")?;
|
||||
let result = consultation_service::export_sessions(
|
||||
&state, ctx.tenant_id, params.status, params.patient_id, params.doctor_id,
|
||||
params.page, params.page_size,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
|
||||
@@ -9,6 +9,7 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::doctor_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::service::doctor_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -28,11 +29,6 @@ pub struct UpdateDoctorWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn list_doctors<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
|
||||
@@ -9,6 +9,7 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::follow_up_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::service::follow_up_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -36,11 +37,6 @@ pub struct UpdateFollowUpTaskWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn list_tasks<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
|
||||
@@ -9,7 +9,9 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::health_data_dto::*;
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::service::health_data_service;
|
||||
use crate::service::trend_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,11 +36,6 @@ pub struct GenerateTrendReq {
|
||||
pub period_end: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateWithVersion<T> {
|
||||
pub data: T,
|
||||
@@ -299,7 +296,7 @@ where
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = health_data_service::list_trends(
|
||||
let result = trend_service::list_trends(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
)
|
||||
.await?;
|
||||
@@ -317,7 +314,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let result = health_data_service::generate_trend(
|
||||
let result = trend_service::generate_trend(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req.period_start, req.period_end,
|
||||
)
|
||||
.await?;
|
||||
@@ -335,7 +332,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = health_data_service::get_indicator_timeseries(
|
||||
let result = trend_service::get_indicator_timeseries(
|
||||
&state, ctx.tenant_id, patient_id, indicator, params.start_date, params.end_date,
|
||||
)
|
||||
.await?;
|
||||
@@ -356,7 +353,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = health_data_service::get_mini_trend(
|
||||
let result = trend_service::get_mini_trend(
|
||||
&state, ctx.tenant_id, ctx.user_id, params.indicator, params.range,
|
||||
)
|
||||
.await?;
|
||||
@@ -376,7 +373,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = health_data_service::get_mini_today(
|
||||
let result = trend_service::get_mini_today(
|
||||
&state, ctx.tenant_id, ctx.user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::dto::patient_dto::{
|
||||
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
|
||||
UpdatePatientReq,
|
||||
};
|
||||
use crate::dto::DeleteWithVersion;
|
||||
use crate::service::patient_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -30,11 +31,6 @@ pub struct AssignDoctorReq {
|
||||
pub relationship_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
pub async fn list_patients<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
@@ -278,7 +274,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.patient.manage")?;
|
||||
patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id).await?;
|
||||
patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id, Some(ctx.user_id)).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,19 +17,26 @@ impl HealthModule {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 启动定时逾期随访检查(每 6 小时运行一次)
|
||||
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) {
|
||||
/// 启动定时逾期随访检查(每 6 小时运行一次),返回 JoinHandle 用于优雅关闭
|
||||
pub fn start_overdue_checker(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "随访逾期检查完成"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "随访逾期检查失败"),
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("随访逾期检查任务收到关闭信号,正在停止");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
@@ -190,6 +197,14 @@ impl HealthModule {
|
||||
axum::routing::get(consultation_handler::list_sessions)
|
||||
.post(consultation_handler::create_session),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/export",
|
||||
axum::routing::get(consultation_handler::export_sessions),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/{id}",
|
||||
axum::routing::get(consultation_handler::get_session),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/{id}/messages",
|
||||
axum::routing::get(consultation_handler::list_messages),
|
||||
@@ -202,10 +217,6 @@ impl HealthModule {
|
||||
"/health/consultation-messages",
|
||||
axum::routing::post(consultation_handler::create_message),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/export",
|
||||
axum::routing::get(consultation_handler::export_sessions),
|
||||
)
|
||||
// 医护管理
|
||||
.route(
|
||||
"/health/doctors",
|
||||
@@ -258,14 +269,23 @@ impl ErpModule for HealthModule {
|
||||
}
|
||||
|
||||
async fn on_startup(&self, ctx: &erp_core::module::ModuleContext) -> erp_core::error::AppResult<()> {
|
||||
let crypto = crate::crypto::HealthCrypto::from_keys(
|
||||
let crypto = match crate::crypto::HealthCrypto::from_keys(
|
||||
&std::env::var("HEALTH_AES_KEY").unwrap_or_default(),
|
||||
&std::env::var("HEALTH_HMAC_KEY").unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥");
|
||||
crate::crypto::HealthCrypto::dev_default()
|
||||
});
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
tracing::warn!("HEALTH_AES_KEY / HEALTH_HMAC_KEY 未设置或无效,使用开发默认密钥");
|
||||
crate::crypto::HealthCrypto::dev_default()
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
panic!("HEALTH_AES_KEY 和 HEALTH_HMAC_KEY 必须设置为有效的 64 字符 hex 字符串(生产环境不允许回退到开发密钥)");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let state = crate::state::HealthState {
|
||||
db: ctx.db.clone(),
|
||||
|
||||
@@ -362,6 +362,13 @@ pub async fn create_schedule(
|
||||
version: Set(1),
|
||||
};
|
||||
let m = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "doctor_schedule.created", "doctor_schedule")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(ScheduleResp {
|
||||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||||
@@ -410,6 +417,13 @@ pub async fn update_schedule(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "doctor_schedule.updated", "doctor_schedule")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(ScheduleResp {
|
||||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||||
|
||||
@@ -192,6 +192,7 @@ pub async fn delete_article(
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<()> {
|
||||
let model = article::Entity::find()
|
||||
.filter(article::Column::Id.eq(id))
|
||||
@@ -201,10 +202,14 @@ pub async fn delete_article(
|
||||
.await?
|
||||
.ok_or(HealthError::ArticleNotFound)?;
|
||||
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
let mut active: article::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
|
||||
@@ -21,6 +21,18 @@ use crate::state::HealthState;
|
||||
// 咨询会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn model_to_session_resp(m: consultation_session::Model) -> SessionResp {
|
||||
SessionResp {
|
||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||||
consultation_type: m.consultation_type, status: m.status,
|
||||
last_message_at: m.last_message_at,
|
||||
unread_count_patient: m.unread_count_patient,
|
||||
unread_count_doctor: m.unread_count_doctor,
|
||||
created_at: m.created_at, updated_at: m.updated_at,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
@@ -73,14 +85,24 @@ pub async fn create_session(
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(SessionResp {
|
||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||||
consultation_type: m.consultation_type, status: m.status,
|
||||
last_message_at: m.last_message_at,
|
||||
unread_count_patient: m.unread_count_patient,
|
||||
unread_count_doctor: m.unread_count_doctor,
|
||||
created_at: m.created_at,
|
||||
})
|
||||
Ok(model_to_session_resp(m))
|
||||
}
|
||||
|
||||
/// 获取单个咨询会话
|
||||
pub async fn get_session(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
session_id: Uuid,
|
||||
) -> HealthResult<SessionResp> {
|
||||
let model = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::Id.eq(session_id))
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::ConsultationNotFound)?;
|
||||
|
||||
Ok(model_to_session_resp(model))
|
||||
}
|
||||
|
||||
pub async fn list_sessions(
|
||||
@@ -112,14 +134,7 @@ pub async fn list_sessions(
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(|m| SessionResp {
|
||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||||
consultation_type: m.consultation_type, status: m.status,
|
||||
last_message_at: m.last_message_at,
|
||||
unread_count_patient: m.unread_count_patient,
|
||||
unread_count_doctor: m.unread_count_doctor,
|
||||
created_at: m.created_at,
|
||||
}).collect();
|
||||
let data = models.into_iter().map(model_to_session_resp).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
@@ -168,14 +183,7 @@ pub async fn close_session(
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(SessionResp {
|
||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||||
consultation_type: m.consultation_type, status: m.status,
|
||||
last_message_at: m.last_message_at,
|
||||
unread_count_patient: m.unread_count_patient,
|
||||
unread_count_doctor: m.unread_count_doctor,
|
||||
created_at: m.created_at,
|
||||
})
|
||||
Ok(model_to_session_resp(m))
|
||||
}
|
||||
|
||||
pub async fn export_sessions(
|
||||
@@ -184,7 +192,13 @@ pub async fn export_sessions(
|
||||
status: Option<String>,
|
||||
patient_id: Option<Uuid>,
|
||||
doctor_id: Option<Uuid>,
|
||||
) -> HealthResult<Vec<SessionResp>> {
|
||||
page: Option<u64>,
|
||||
page_size: Option<u64>,
|
||||
) -> HealthResult<PaginatedResponse<SessionResp>> {
|
||||
let limit = page_size.unwrap_or(100).min(500);
|
||||
let page_num = page.unwrap_or(1);
|
||||
let offset = page_num.saturating_sub(1) * limit;
|
||||
|
||||
let mut query = consultation_session::Entity::find()
|
||||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||||
.filter(consultation_session::Column::DeletedAt.is_null());
|
||||
@@ -193,20 +207,18 @@ pub async fn export_sessions(
|
||||
if let Some(pid) = patient_id { query = query.filter(consultation_session::Column::PatientId.eq(pid)); }
|
||||
if let Some(did) = doctor_id { query = query.filter(consultation_session::Column::DoctorId.eq(did)); }
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_desc(consultation_session::Column::CreatedAt)
|
||||
.limit(10000)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(models.into_iter().map(|m| SessionResp {
|
||||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||||
consultation_type: m.consultation_type, status: m.status,
|
||||
last_message_at: m.last_message_at,
|
||||
unread_count_patient: m.unread_count_patient,
|
||||
unread_count_doctor: m.unread_count_doctor,
|
||||
created_at: m.created_at,
|
||||
}).collect())
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(model_to_session_resp).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page: page_num, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -180,6 +180,13 @@ pub async fn update_task(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "follow_up_task.updated", "follow_up_task")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(FollowUpTaskResp {
|
||||
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
|
||||
follow_up_type: m.follow_up_type, planned_date: m.planned_date,
|
||||
@@ -213,6 +220,13 @@ pub async fn delete_task(
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "follow_up_task.deleted", "follow_up_task")
|
||||
.with_resource_id(task_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -265,11 +279,12 @@ pub async fn create_record(
|
||||
let task_patient_id = task.patient_id;
|
||||
let task_assigned_to = task.assigned_to;
|
||||
let task_follow_up_type = task.follow_up_type.clone();
|
||||
let current_version = task.version;
|
||||
let mut task_active: follow_up_task::ActiveModel = task.into();
|
||||
task_active.status = Set("completed".to_string());
|
||||
task_active.updated_at = Set(now);
|
||||
task_active.updated_by = Set(operator_id);
|
||||
task_active.version = Set(task_active.version.unwrap() + 1);
|
||||
task_active.version = Set(current_version + 1);
|
||||
task_active.update(&txn).await?;
|
||||
|
||||
// 当 next_follow_up_date 不为空时,自动创建后续随访任务
|
||||
@@ -392,10 +407,11 @@ pub async fn complete_task_by_system(
|
||||
|
||||
match model {
|
||||
Some(m) if m.status == "pending" || m.status == "in_progress" => {
|
||||
let current_version = m.version;
|
||||
let mut active: follow_up_task::ActiveModel = m.into();
|
||||
active.status = Set("completed".to_string());
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.version = Set(active.version.unwrap() + 1);
|
||||
active.version = Set(current_version + 1);
|
||||
active.update(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
|
||||
//! 健康数据 Service — 体征记录、化验报告、体检记录
|
||||
|
||||
use chrono::Utc;
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::DomainEvent;
|
||||
use num_traits::cast::ToPrimitive;
|
||||
use num_traits::ToPrimitive;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
|
||||
use uuid::Uuid;
|
||||
@@ -13,7 +13,7 @@ use erp_core::error::check_version;
|
||||
use erp_core::types::PaginatedResponse;
|
||||
|
||||
use crate::dto::health_data_dto::*;
|
||||
use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs};
|
||||
use crate::entity::{health_record, lab_report, patient, vital_signs};
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::service::validation::validate_record_type;
|
||||
use crate::state::HealthState;
|
||||
@@ -164,6 +164,13 @@ pub async fn update_vital_signs(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(VitalSignsResp {
|
||||
id: m.id, patient_id: m.patient_id, record_date: m.record_date,
|
||||
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
|
||||
@@ -200,6 +207,13 @@ pub async fn delete_vital_signs(
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "vital_signs.deleted", "vital_signs")
|
||||
.with_resource_id(vital_signs_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -328,6 +342,13 @@ pub async fn update_lab_report(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(LabReportResp {
|
||||
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
|
||||
report_type: m.report_type, indicators: m.indicators,
|
||||
@@ -360,6 +381,13 @@ pub async fn delete_lab_report(
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "lab_report.deleted", "lab_report")
|
||||
.with_resource_id(report_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -486,6 +514,13 @@ pub async fn update_health_record(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(HealthRecordResp {
|
||||
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
|
||||
record_date: m.record_date, source: m.source,
|
||||
@@ -518,338 +553,12 @@ pub async fn delete_health_record(
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "health_record.deleted", "health_record")
|
||||
.with_resource_id(record_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 趋势分析 (Trends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_trends(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<TrendResp>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let query = health_trend::Entity::find()
|
||||
.filter(health_trend::Column::TenantId.eq(tenant_id))
|
||||
.filter(health_trend::Column::PatientId.eq(patient_id))
|
||||
.filter(health_trend::Column::DeletedAt.is_null());
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_desc(health_trend::Column::CreatedAt)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(|m| TrendResp {
|
||||
id: m.id, patient_id: m.patient_id,
|
||||
period_start: m.period_start, period_end: m.period_end,
|
||||
indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items,
|
||||
generation_type: m.generation_type, report_file_url: m.report_file_url,
|
||||
}).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
pub async fn generate_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
period_start: chrono::NaiveDate,
|
||||
period_end: chrono::NaiveDate,
|
||||
) -> HealthResult<TrendResp> {
|
||||
// 汇总该时间段内的体征数据
|
||||
let vitals = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(vital_signs::Column::RecordDate.gte(period_start))
|
||||
.filter(vital_signs::Column::RecordDate.lte(period_end))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let summary = serde_json::json!({
|
||||
"period": { "start": period_start, "end": period_end },
|
||||
"record_count": vitals.len(),
|
||||
"avg_heart_rate": vitals.iter().filter_map(|v| v.heart_rate).sum::<i32>() as f64
|
||||
/ vitals.iter().filter(|v| v.heart_rate.is_some()).count().max(1) as f64,
|
||||
});
|
||||
|
||||
let now = Utc::now();
|
||||
let active = health_trend::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
period_start: Set(period_start),
|
||||
period_end: Set(period_end),
|
||||
indicator_summary: Set(Some(summary)),
|
||||
abnormal_items: Set(None),
|
||||
generation_type: Set("auto".to_string()),
|
||||
report_file_url: Set(None),
|
||||
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(TrendResp {
|
||||
id: m.id, patient_id: m.patient_id,
|
||||
period_start: m.period_start, period_end: m.period_end,
|
||||
indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items,
|
||||
generation_type: m.generation_type, report_file_url: m.report_file_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_indicator_timeseries(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
indicator: String,
|
||||
start_date: Option<chrono::NaiveDate>,
|
||||
end_date: Option<chrono::NaiveDate>,
|
||||
) -> HealthResult<IndicatorTimeseriesResp> {
|
||||
let mut query = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(sd) = start_date {
|
||||
query = query.filter(vital_signs::Column::RecordDate.gte(sd));
|
||||
}
|
||||
if let Some(ed) = end_date {
|
||||
query = query.filter(vital_signs::Column::RecordDate.lte(ed));
|
||||
}
|
||||
|
||||
let vitals = query
|
||||
.order_by_asc(vital_signs::Column::RecordDate)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| {
|
||||
let val = match indicator.as_str() {
|
||||
"heart_rate" => v.heart_rate.map(|x| x as f64),
|
||||
"weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)),
|
||||
"blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
|
||||
"systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64),
|
||||
"diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64),
|
||||
"systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64),
|
||||
"diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|fv| (v.record_date, fv))
|
||||
}).collect();
|
||||
|
||||
Ok(IndicatorTimeseriesResp { indicator, data })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序趋势查询(通过 user_id 关联 patient)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据 user_id 查找关联的 patient_id。
|
||||
/// patient 表的 user_id 字段关联 erp-auth 的用户。
|
||||
/// 如果未关联则返回 Ok(None)。
|
||||
async fn find_patient_by_user_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> HealthResult<Option<Uuid>> {
|
||||
let patient_model = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::UserId.eq(user_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(patient_model.map(|p| p.id))
|
||||
}
|
||||
|
||||
/// 解析 range 参数为天数,默认 7 天。
|
||||
/// 支持 "7d", "30d", "90d" 格式。
|
||||
fn parse_range_days(range: &Option<String>) -> i64 {
|
||||
match range.as_deref() {
|
||||
Some("30d") => 30,
|
||||
Some("90d") => 90,
|
||||
// 默认 7 天(包括 "7d" 和 None)
|
||||
_ => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。
|
||||
///
|
||||
/// 逻辑流程:
|
||||
/// 1. 解析 range 参数计算 start_date/end_date
|
||||
/// 2. 通过 user_id 查找关联的 patient(patient.user_id 字段)
|
||||
/// 3. 复用 get_indicator_timeseries 的查询逻辑
|
||||
/// 4. 转换为 DataPoint 格式返回
|
||||
pub async fn get_mini_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
indicator: String,
|
||||
range: Option<String>,
|
||||
) -> HealthResult<MiniTrendResp> {
|
||||
// 1. 通过 user_id 查找关联的 patient
|
||||
let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?;
|
||||
|
||||
// 如果用户未关联 patient,返回空数据
|
||||
let Some(patient_id) = patient_id else {
|
||||
return Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
// 2. 根据 range 计算日期范围
|
||||
let days = parse_range_days(&range);
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let start_date = today - chrono::Duration::days(days);
|
||||
let end_date = today;
|
||||
|
||||
// 3. 复用已有逻辑查询时间序列数据
|
||||
let timeseries = get_indicator_timeseries(
|
||||
state,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
indicator.clone(),
|
||||
Some(start_date),
|
||||
Some(end_date),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. 转换为 DataPoint 格式
|
||||
let data_points = timeseries
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|(date, value)| DataPoint {
|
||||
date: date.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序今日体征摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据参考范围计算指标状态
|
||||
fn compute_status(value: f64, low: f64, high: f64) -> &'static str {
|
||||
if value < low {
|
||||
"low"
|
||||
} else if value > high {
|
||||
"high"
|
||||
} else {
|
||||
"normal"
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询今日最新体征记录并生成摘要
|
||||
pub async fn get_mini_today(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> HealthResult<MiniTodayResp> {
|
||||
let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?;
|
||||
|
||||
let Some(patient_id) = patient_id else {
|
||||
return Ok(MiniTodayResp {
|
||||
blood_pressure: None,
|
||||
heart_rate: None,
|
||||
blood_sugar: None,
|
||||
weight: None,
|
||||
});
|
||||
};
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
// 查询今日最新体征记录
|
||||
let vital = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(vital_signs::Column::RecordDate.eq(today))
|
||||
.order_by_desc(vital_signs::Column::CreatedAt)
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some(v) = vital else {
|
||||
return Ok(MiniTodayResp {
|
||||
blood_pressure: None,
|
||||
heart_rate: None,
|
||||
blood_sugar: None,
|
||||
weight: None,
|
||||
});
|
||||
};
|
||||
|
||||
// 构建各指标摘要,优先使用晨间数据
|
||||
let blood_pressure = v.systolic_bp_morning.and_then(|sys| {
|
||||
v.diastolic_bp_morning.map(|dia| {
|
||||
let status = compute_status(sys as f64, 90.0, 140.0);
|
||||
IndicatorSummary {
|
||||
value: sys as f64,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("90-140/60-90".to_string()),
|
||||
systolic: Some(sys as f64),
|
||||
diastolic: Some(dia as f64),
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let heart_rate = v.heart_rate.map(|hr| {
|
||||
let status = compute_status(hr as f64, 60.0, 100.0);
|
||||
IndicatorSummary {
|
||||
value: hr as f64,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("60-100".to_string()),
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
let blood_sugar = v.blood_sugar.map(|bs| {
|
||||
let val = bs.to_f64().unwrap_or(0.0);
|
||||
let status = compute_status(val, 3.9, 6.1);
|
||||
IndicatorSummary {
|
||||
value: val,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("3.9-6.1".to_string()),
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
let weight = v.weight.map(|w| {
|
||||
let val = w.to_f64().unwrap_or(0.0);
|
||||
IndicatorSummary {
|
||||
value: val,
|
||||
status: "normal".to_string(), // 体重无通用参考范围
|
||||
reference_range: None,
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(MiniTodayResp {
|
||||
blood_pressure,
|
||||
heart_rate,
|
||||
blood_sugar,
|
||||
weight,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod follow_up_service;
|
||||
pub mod health_data_service;
|
||||
pub mod patient_service;
|
||||
pub mod seed;
|
||||
pub mod trend_service;
|
||||
pub mod validation;
|
||||
|
||||
@@ -476,6 +476,13 @@ pub async fn create_family_member(
|
||||
};
|
||||
|
||||
let model = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.family_member_created", "patient_family_member")
|
||||
.with_resource_id(model.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(FamilyMemberResp {
|
||||
id: model.id,
|
||||
patient_id: model.patient_id,
|
||||
@@ -523,6 +530,13 @@ pub async fn update_family_member(
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member")
|
||||
.with_resource_id(updated.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(FamilyMemberResp {
|
||||
id: updated.id,
|
||||
patient_id: updated.patient_id,
|
||||
@@ -565,6 +579,12 @@ pub async fn delete_family_member(
|
||||
active.version = Set(next_ver);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.family_member_deleted", "patient_family_member")
|
||||
.with_resource_id(family_member_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -609,7 +629,14 @@ pub async fn assign_doctor(
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
active.insert(&state.db).await?;
|
||||
let relation = active.insert(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.doctor_assigned", "patient_doctor_relation")
|
||||
.with_resource_id(relation.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -619,6 +646,7 @@ pub async fn remove_doctor(
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
doctor_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
) -> HealthResult<()> {
|
||||
let model = patient_doctor_relation::Entity::find()
|
||||
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
||||
@@ -629,10 +657,19 @@ pub async fn remove_doctor(
|
||||
.await?
|
||||
.ok_or(HealthError::DoctorNotFound)?;
|
||||
|
||||
let relation_id = model.id;
|
||||
let mut active: patient_doctor_relation::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.update(&state.db).await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "patient.doctor_removed", "patient_doctor_relation")
|
||||
.with_resource_id(relation_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
425
crates/erp-health/src/service/trend_service.rs
Normal file
425
crates/erp-health/src/service/trend_service.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! 趋势分析 Service — 趋势报表、指标时间序列、小程序趋势查询、今日体征摘要
|
||||
|
||||
use chrono::Utc;
|
||||
use num_traits::cast::ToPrimitive;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::PaginatedResponse;
|
||||
|
||||
use crate::dto::health_data_dto::*;
|
||||
use crate::entity::{health_trend, patient, vital_signs};
|
||||
use crate::error::HealthResult;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 趋势分析 (Trends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_trends(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<TrendResp>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let query = health_trend::Entity::find()
|
||||
.filter(health_trend::Column::TenantId.eq(tenant_id))
|
||||
.filter(health_trend::Column::PatientId.eq(patient_id))
|
||||
.filter(health_trend::Column::DeletedAt.is_null());
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_desc(health_trend::Column::CreatedAt)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(|m| TrendResp {
|
||||
id: m.id, patient_id: m.patient_id,
|
||||
period_start: m.period_start, period_end: m.period_end,
|
||||
indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items,
|
||||
generation_type: m.generation_type, report_file_url: m.report_file_url,
|
||||
}).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
pub async fn generate_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
period_start: chrono::NaiveDate,
|
||||
period_end: chrono::NaiveDate,
|
||||
) -> HealthResult<TrendResp> {
|
||||
// 汇总该时间段内的体征数据
|
||||
let vitals = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(vital_signs::Column::RecordDate.gte(period_start))
|
||||
.filter(vital_signs::Column::RecordDate.lte(period_end))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let summary = {
|
||||
let count = vitals.len();
|
||||
let avg = |vals: &[Option<i32>]| -> f64 {
|
||||
let valid: Vec<i32> = vals.iter().filter_map(|&v| v).collect();
|
||||
if valid.is_empty() { return 0.0; }
|
||||
valid.iter().sum::<i32>() as f64 / valid.len() as f64
|
||||
};
|
||||
let avg_f64 = |vals: &[Option<f64>]| -> f64 {
|
||||
let valid: Vec<f64> = vals.iter().filter_map(|&v| v).collect();
|
||||
if valid.is_empty() { return 0.0; }
|
||||
valid.iter().sum::<f64>() / valid.len() as f64
|
||||
};
|
||||
let heart_rates: Vec<Option<i32>> = vitals.iter().map(|v| v.heart_rate).collect();
|
||||
let weights: Vec<Option<f64>> = vitals.iter().map(|v| v.weight.and_then(|d| d.to_f64())).collect();
|
||||
let blood_sugars: Vec<Option<f64>> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect();
|
||||
let sys_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_morning).collect();
|
||||
let dia_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_morning).collect();
|
||||
let sys_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_evening).collect();
|
||||
let dia_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_evening).collect();
|
||||
|
||||
serde_json::json!({
|
||||
"period": { "start": period_start, "end": period_end },
|
||||
"record_count": count,
|
||||
"avg_heart_rate": avg(&heart_rates),
|
||||
"avg_weight": avg_f64(&weights),
|
||||
"avg_blood_sugar": avg_f64(&blood_sugars),
|
||||
"avg_systolic_bp_morning": avg(&sys_morn),
|
||||
"avg_diastolic_bp_morning": avg(&dia_morn),
|
||||
"avg_systolic_bp_evening": avg(&sys_eve),
|
||||
"avg_diastolic_bp_evening": avg(&dia_eve),
|
||||
})
|
||||
};
|
||||
|
||||
let abnormal_items = {
|
||||
let mut items = Vec::new();
|
||||
let avg_i32 = |vals: &[Option<i32>]| -> Option<f64> {
|
||||
let valid: Vec<i32> = vals.iter().filter_map(|&v| v).collect();
|
||||
if valid.is_empty() { return None; }
|
||||
Some(valid.iter().sum::<i32>() as f64 / valid.len() as f64)
|
||||
};
|
||||
let avg_opt_f64 = |vals: &[Option<f64>]| -> Option<f64> {
|
||||
let valid: Vec<f64> = vals.iter().filter_map(|&v| v).collect();
|
||||
if valid.is_empty() { return None; }
|
||||
Some(valid.iter().sum::<f64>() / valid.len() as f64)
|
||||
};
|
||||
let heart_rates: Vec<Option<i32>> = vitals.iter().map(|v| v.heart_rate).collect();
|
||||
let blood_sugars: Vec<Option<f64>> = vitals.iter().map(|v| v.blood_sugar.and_then(|d| d.to_f64())).collect();
|
||||
let sys_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_morning).collect();
|
||||
let dia_morn: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_morning).collect();
|
||||
let sys_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.systolic_bp_evening).collect();
|
||||
let dia_eve: Vec<Option<i32>> = vitals.iter().map(|v| v.diastolic_bp_evening).collect();
|
||||
|
||||
if let Some(hr) = avg_i32(&heart_rates) {
|
||||
if hr < 60.0 || hr > 100.0 {
|
||||
items.push(serde_json::json!({ "indicator": "heart_rate", "avg": hr, "normal_range": [60, 100] }));
|
||||
}
|
||||
}
|
||||
if let Some(bs) = avg_opt_f64(&blood_sugars) {
|
||||
if bs < 3.9 || bs > 11.1 {
|
||||
items.push(serde_json::json!({ "indicator": "blood_sugar", "avg": bs, "normal_range": [3.9, 11.1] }));
|
||||
}
|
||||
}
|
||||
for (label, vals, sys_lo, sys_hi) in [
|
||||
("systolic_bp_morning", &sys_morn, 90, 140),
|
||||
("systolic_bp_evening", &sys_eve, 90, 140),
|
||||
] {
|
||||
if let Some(v) = avg_i32(vals) {
|
||||
if v < sys_lo as f64 || v > sys_hi as f64 {
|
||||
items.push(serde_json::json!({ "indicator": label, "avg": v, "normal_range": [sys_lo, sys_hi] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (label, vals, dia_lo, dia_hi) in [
|
||||
("diastolic_bp_morning", &dia_morn, 60, 90),
|
||||
("diastolic_bp_evening", &dia_eve, 60, 90),
|
||||
] {
|
||||
if let Some(v) = avg_i32(vals) {
|
||||
if v < dia_lo as f64 || v > dia_hi as f64 {
|
||||
items.push(serde_json::json!({ "indicator": label, "avg": v, "normal_range": [dia_lo, dia_hi] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.is_empty() { None } else { Some(serde_json::json!(items)) }
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let active = health_trend::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
period_start: Set(period_start),
|
||||
period_end: Set(period_end),
|
||||
indicator_summary: Set(Some(summary)),
|
||||
abnormal_items: Set(abnormal_items),
|
||||
generation_type: Set("auto".to_string()),
|
||||
report_file_url: Set(None),
|
||||
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(TrendResp {
|
||||
id: m.id, patient_id: m.patient_id,
|
||||
period_start: m.period_start, period_end: m.period_end,
|
||||
indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items,
|
||||
generation_type: m.generation_type, report_file_url: m.report_file_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_indicator_timeseries(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
indicator: String,
|
||||
start_date: Option<chrono::NaiveDate>,
|
||||
end_date: Option<chrono::NaiveDate>,
|
||||
) -> HealthResult<IndicatorTimeseriesResp> {
|
||||
let mut query = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(sd) = start_date {
|
||||
query = query.filter(vital_signs::Column::RecordDate.gte(sd));
|
||||
}
|
||||
if let Some(ed) = end_date {
|
||||
query = query.filter(vital_signs::Column::RecordDate.lte(ed));
|
||||
}
|
||||
|
||||
let vitals = query
|
||||
.order_by_asc(vital_signs::Column::RecordDate)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| {
|
||||
let val = match indicator.as_str() {
|
||||
"heart_rate" => v.heart_rate.map(|x| x as f64),
|
||||
"weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)),
|
||||
"blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
|
||||
"systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64),
|
||||
"diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64),
|
||||
"systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64),
|
||||
"diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|fv| (v.record_date, fv))
|
||||
}).collect();
|
||||
|
||||
Ok(IndicatorTimeseriesResp { indicator, data })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序趋势查询(通过 user_id 关联 patient)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据 user_id 查找关联的 patient_id。
|
||||
/// patient 表的 user_id 字段关联 erp-auth 的用户。
|
||||
/// 如果未关联则返回 Ok(None)。
|
||||
async fn find_patient_by_user_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> HealthResult<Option<Uuid>> {
|
||||
let patient_model = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::UserId.eq(user_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(patient_model.map(|p| p.id))
|
||||
}
|
||||
|
||||
/// 解析 range 参数为天数,默认 7 天。
|
||||
/// 支持 "7d", "30d", "90d" 格式。
|
||||
fn parse_range_days(range: &Option<String>) -> i64 {
|
||||
match range.as_deref() {
|
||||
Some("30d") => 30,
|
||||
Some("90d") => 90,
|
||||
// 默认 7 天(包括 "7d" 和 None)
|
||||
_ => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。
|
||||
///
|
||||
/// 逻辑流程:
|
||||
/// 1. 解析 range 参数计算 start_date/end_date
|
||||
/// 2. 通过 user_id 查找关联的 patient(patient.user_id 字段)
|
||||
/// 3. 复用 get_indicator_timeseries 的查询逻辑
|
||||
/// 4. 转换为 DataPoint 格式返回
|
||||
pub async fn get_mini_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
indicator: String,
|
||||
range: Option<String>,
|
||||
) -> HealthResult<MiniTrendResp> {
|
||||
// 1. 通过 user_id 查找关联的 patient
|
||||
let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?;
|
||||
|
||||
// 如果用户未关联 patient,返回空数据
|
||||
let Some(patient_id) = patient_id else {
|
||||
return Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
// 2. 根据 range 计算日期范围
|
||||
let days = parse_range_days(&range);
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let start_date = today - chrono::Duration::days(days);
|
||||
let end_date = today;
|
||||
|
||||
// 3. 复用已有逻辑查询时间序列数据
|
||||
let timeseries = get_indicator_timeseries(
|
||||
state,
|
||||
tenant_id,
|
||||
patient_id,
|
||||
indicator.clone(),
|
||||
Some(start_date),
|
||||
Some(end_date),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. 转换为 DataPoint 格式
|
||||
let data_points = timeseries
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|(date, value)| DataPoint {
|
||||
date: date.to_string(),
|
||||
value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(MiniTrendResp {
|
||||
indicator,
|
||||
data_points,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 小程序今日体征摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 根据参考范围计算指标状态
|
||||
fn compute_status(value: f64, low: f64, high: f64) -> &'static str {
|
||||
if value < low {
|
||||
"low"
|
||||
} else if value > high {
|
||||
"high"
|
||||
} else {
|
||||
"normal"
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询今日最新体征记录并生成摘要
|
||||
pub async fn get_mini_today(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> HealthResult<MiniTodayResp> {
|
||||
let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?;
|
||||
|
||||
let Some(patient_id) = patient_id else {
|
||||
return Ok(MiniTodayResp {
|
||||
blood_pressure: None,
|
||||
heart_rate: None,
|
||||
blood_sugar: None,
|
||||
weight: None,
|
||||
});
|
||||
};
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
// 查询今日最新体征记录
|
||||
let vital = vital_signs::Entity::find()
|
||||
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||
.filter(vital_signs::Column::RecordDate.eq(today))
|
||||
.order_by_desc(vital_signs::Column::CreatedAt)
|
||||
.one(&state.db)
|
||||
.await?;
|
||||
|
||||
let Some(v) = vital else {
|
||||
return Ok(MiniTodayResp {
|
||||
blood_pressure: None,
|
||||
heart_rate: None,
|
||||
blood_sugar: None,
|
||||
weight: None,
|
||||
});
|
||||
};
|
||||
|
||||
// 构建各指标摘要,优先使用晨间数据
|
||||
let blood_pressure = v.systolic_bp_morning.and_then(|sys| {
|
||||
v.diastolic_bp_morning.map(|dia| {
|
||||
let status = compute_status(sys as f64, 90.0, 140.0);
|
||||
IndicatorSummary {
|
||||
value: sys as f64,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("90-140/60-90".to_string()),
|
||||
systolic: Some(sys as f64),
|
||||
diastolic: Some(dia as f64),
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let heart_rate = v.heart_rate.map(|hr| {
|
||||
let status = compute_status(hr as f64, 60.0, 100.0);
|
||||
IndicatorSummary {
|
||||
value: hr as f64,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("60-100".to_string()),
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
let blood_sugar = v.blood_sugar.map(|bs| {
|
||||
let val = bs.to_f64().unwrap_or(0.0);
|
||||
let status = compute_status(val, 3.9, 6.1);
|
||||
IndicatorSummary {
|
||||
value: val,
|
||||
status: status.to_string(),
|
||||
reference_range: Some("3.9-6.1".to_string()),
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
let weight = v.weight.map(|w| {
|
||||
let val = w.to_f64().unwrap_or(0.0);
|
||||
IndicatorSummary {
|
||||
value: val,
|
||||
status: "normal".to_string(), // 体重无通用参考范围
|
||||
reference_range: None,
|
||||
systolic: None,
|
||||
diastolic: None,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(MiniTodayResp {
|
||||
blood_pressure,
|
||||
heart_rate,
|
||||
blood_sugar,
|
||||
weight,
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ impl MigrationTrait for Migration {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patients"))
|
||||
.table(Alias::new("patient"))
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("id_number_hash"))
|
||||
.string()
|
||||
@@ -29,7 +29,7 @@ impl MigrationTrait for Migration {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("patients"))
|
||||
.table(Alias::new("patient"))
|
||||
.drop_column(Alias::new("id_number_hash"))
|
||||
.to_owned(),
|
||||
)
|
||||
|
||||
@@ -363,6 +363,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
registry.startup_all(&module_ctx).await?;
|
||||
tracing::info!("All modules started");
|
||||
|
||||
// 同步所有模块声明的权限到数据库(upsert)
|
||||
sync_module_permissions(&db, ®istry, default_tenant_id).await?;
|
||||
|
||||
// 恢复运行中的插件(服务器重启后自动重新加载)
|
||||
match plugin_engine.recover_plugins(&db).await {
|
||||
Ok(recovered) => {
|
||||
@@ -554,3 +557,67 @@ async fn shutdown_signal() {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步所有模块声明的权限到数据库。
|
||||
///
|
||||
/// 对每个模块的 `permissions()` 返回的权限执行 upsert:
|
||||
/// - 新权限:INSERT
|
||||
/// - 已有权限(同 tenant_id + code):跳过
|
||||
/// 同时将新权限分配给 admin 角色。
|
||||
async fn sync_module_permissions(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
registry: &erp_core::module::ModuleRegistry,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let system_user_id = uuid::Uuid::nil();
|
||||
let mut total_new = 0u32;
|
||||
|
||||
for module in registry.modules() {
|
||||
let perms = module.permissions();
|
||||
if perms.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for perm in perms {
|
||||
let result = db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), $8, $8, NULL, 1)
|
||||
ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING"#,
|
||||
[
|
||||
uuid::Uuid::now_v7().into(),
|
||||
tenant_id.into(),
|
||||
perm.code.clone().into(),
|
||||
perm.name.clone().into(),
|
||||
perm.module.clone().into(),
|
||||
perm.code.split('.').last().unwrap_or("manage").into(),
|
||||
perm.description.clone().into(),
|
||||
system_user_id.into(),
|
||||
],
|
||||
)).await?;
|
||||
|
||||
let rows = result.rows_affected();
|
||||
if rows > 0 {
|
||||
total_new += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_new > 0 {
|
||||
// 将新权限分配给 admin 角色
|
||||
db.execute(sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version)
|
||||
SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1
|
||||
FROM permissions p
|
||||
JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL
|
||||
WHERE p.tenant_id = $2 AND p.code LIKE 'health.%'
|
||||
ON CONFLICT DO NOTHING"#,
|
||||
[system_user_id.into(), tenant_id.into()],
|
||||
)).await?;
|
||||
|
||||
tracing::info!(total_new, "Module permissions synced to database");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user