fix(health): 穷尽审计修复 — 权限同步/编译错误/前端bug/审计日志
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled

审计发现并修复的问题:

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:
iven
2026-04-25 08:58:58 +08:00
parent 9ffb938128
commit 07f4ba41ba
31 changed files with 3373 additions and 445 deletions

View File

@@ -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());
}
}

View File

@@ -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)]

View File

@@ -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)]

View File

@@ -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,
}

View File

@@ -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())
}
}

View File

@@ -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(())))
}

View File

@@ -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)))

View File

@@ -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>,

View File

@@ -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>,

View File

@@ -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?;

View File

@@ -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(())))
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 })
}
// ---------------------------------------------------------------------------

View File

@@ -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(())
}

View File

@@ -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 查找关联的 patientpatient.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,
})
}

View File

@@ -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;

View File

@@ -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(())
}

View 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 查找关联的 patientpatient.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,
})
}

View File

@@ -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(),
)

View File

@@ -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, &registry, 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(())
}