feat(health): 5 个工作台管理统计 API — 系统健康/用户活跃/模块状态/积分动态/文章统计
- DTO: SystemHealthResp, UserActivityResp, ModuleStatusResp, PointsActivityItem, ArticleStatsResp - Service: get_article_stats, get_points_recent_activity, get_module_status, get_user_activity, get_system_health - Handler: 5 个新端点 + 权限码 health.dashboard.manage - 路由: /health/admin/system-health, user-activity, modules, points/recent-activity, articles/stats
This commit is contained in:
@@ -136,3 +136,65 @@ pub struct NameValue {
|
||||
pub name: String,
|
||||
pub value: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作台管理统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ServiceHealthStatus {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub response_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SystemHealthResp {
|
||||
pub services: Vec<ServiceHealthStatus>,
|
||||
pub checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RoleCount {
|
||||
pub role: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserActivityResp {
|
||||
pub daily_active: i64,
|
||||
pub weekly_active: i64,
|
||||
pub monthly_active: i64,
|
||||
pub total_registered: i64,
|
||||
pub by_role: Vec<RoleCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ModuleStatusResp {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
pub entity_count: Option<i64>,
|
||||
pub route_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsActivityItem {
|
||||
pub id: String,
|
||||
pub user_name: String,
|
||||
pub detail: String,
|
||||
pub amount: String,
|
||||
pub r#type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleStatsResp {
|
||||
pub published: i64,
|
||||
pub draft: i64,
|
||||
pub pending_review: i64,
|
||||
pub rejected: i64,
|
||||
pub total_views: i64,
|
||||
}
|
||||
|
||||
@@ -138,3 +138,71 @@ where
|
||||
let result = stats_service::get_personal_stats(&state, ctx.user_id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作台管理统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_system_health<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<SystemHealthResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let result = stats_service::get_system_health(&state).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_user_activity<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<UserActivityResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dashboard.manage")?;
|
||||
let result = stats_service::get_user_activity(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_module_status<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<ModuleStatusResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dashboard.manage")?;
|
||||
let result = stats_service::get_module_status(&state).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_points_recent_activity<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PointsActivityItem>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dashboard.manage")?;
|
||||
let result = stats_service::get_points_recent_activity(&state.db, ctx.tenant_id, 10).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_article_stats<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<ArticleStatsResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.dashboard.manage")?;
|
||||
let result = stats_service::get_article_stats(&state.db, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -597,6 +597,27 @@ impl HealthModule {
|
||||
"/health/admin/statistics/personal-stats",
|
||||
axum::routing::get(stats_handler::get_personal_stats),
|
||||
)
|
||||
// 工作台管理统计 API
|
||||
.route(
|
||||
"/health/admin/system-health",
|
||||
axum::routing::get(stats_handler::get_system_health),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/user-activity",
|
||||
axum::routing::get(stats_handler::get_user_activity),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/modules",
|
||||
axum::routing::get(stats_handler::get_module_status),
|
||||
)
|
||||
.route(
|
||||
"/health/points/recent-activity",
|
||||
axum::routing::get(stats_handler::get_points_recent_activity),
|
||||
)
|
||||
.route(
|
||||
"/health/articles/stats",
|
||||
axum::routing::get(stats_handler::get_article_stats),
|
||||
)
|
||||
// 危急值阈值配置
|
||||
.route(
|
||||
"/health/critical-value-thresholds",
|
||||
@@ -1093,6 +1114,13 @@ impl ErpModule for HealthModule {
|
||||
description: "查看科室团队工作负载和风险分布(主任专属)".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
// 工作台管理
|
||||
PermissionDescriptor {
|
||||
code: "health.dashboard.manage".into(),
|
||||
name: "工作台管理".into(),
|
||||
description: "查看系统健康、用户活跃度、模块状态等管理统计".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult};
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, Statement};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::entity::{
|
||||
patient, consultation_session, follow_up_task,
|
||||
points_transaction, lab_report,
|
||||
appointment, vital_signs, patient_doctor_relation, doctor_profile,
|
||||
article,
|
||||
};
|
||||
use crate::state::HealthState;
|
||||
|
||||
@@ -787,3 +788,330 @@ pub async fn get_personal_stats(
|
||||
yesterday_overdue_follow_ups: Some(yesterday_overdue),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工作台管理统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 文章状态统计
|
||||
pub async fn get_article_stats(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<ArticleStatsResp> {
|
||||
let sql = r#"
|
||||
SELECT status, COUNT(*) AS cnt, COALESCE(SUM(view_count), 0) AS total_views
|
||||
FROM article
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
GROUP BY status
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct Row {
|
||||
status: String,
|
||||
cnt: i64,
|
||||
total_views: Option<i64>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut published: i64 = 0;
|
||||
let mut draft: i64 = 0;
|
||||
let mut pending_review: i64 = 0;
|
||||
let mut rejected: i64 = 0;
|
||||
let mut total_views: i64 = 0;
|
||||
|
||||
for row in &rows {
|
||||
total_views += row.total_views.unwrap_or(0);
|
||||
match row.status.as_str() {
|
||||
"published" => published = row.cnt,
|
||||
"draft" => draft = row.cnt,
|
||||
"pending_review" => pending_review = row.cnt,
|
||||
"rejected" => rejected = row.cnt,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ArticleStatsResp {
|
||||
published,
|
||||
draft,
|
||||
pending_review,
|
||||
rejected,
|
||||
total_views,
|
||||
})
|
||||
}
|
||||
|
||||
/// 积分最近动态
|
||||
pub async fn get_points_recent_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
limit: u64,
|
||||
) -> AppResult<Vec<PointsActivityItem>> {
|
||||
let sql = r#"
|
||||
SELECT pt.id::text, COALESCE(p.name, '未知用户') AS user_name,
|
||||
pt.description AS detail,
|
||||
CASE WHEN pt.amount >= 0 THEN '+' || pt.amount ELSE pt.amount::text END AS amount,
|
||||
CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type,
|
||||
pt.created_at::text
|
||||
FROM points_transaction pt
|
||||
LEFT JOIN patient p ON p.id = pt.patient_id AND p.deleted_at IS NULL
|
||||
WHERE pt.tenant_id = $1 AND pt.deleted_at IS NULL
|
||||
ORDER BY pt.created_at DESC
|
||||
LIMIT $2
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct Row {
|
||||
id: String,
|
||||
user_name: String,
|
||||
detail: Option<String>,
|
||||
amount: String,
|
||||
r#type: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), (limit as i64).into()],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| PointsActivityItem {
|
||||
id: r.id,
|
||||
user_name: r.user_name,
|
||||
detail: r.detail.unwrap_or_default(),
|
||||
amount: r.amount,
|
||||
r#type: r.r#type,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 模块状态
|
||||
pub async fn get_module_status(
|
||||
state: &HealthState,
|
||||
) -> AppResult<Vec<ModuleStatusResp>> {
|
||||
let modules = vec![
|
||||
ModuleStatusResp {
|
||||
name: "erp-auth".into(),
|
||||
display_name: "身份权限".into(),
|
||||
description: "用户/角色/权限/组织/部门".into(),
|
||||
active: true,
|
||||
entity_count: Some(9),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-config".into(),
|
||||
display_name: "系统配置".into(),
|
||||
description: "字典/菜单/设置/编号规则".into(),
|
||||
active: true,
|
||||
entity_count: Some(6),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-workflow".into(),
|
||||
display_name: "工作流引擎".into(),
|
||||
description: "BPMN 解析/任务分配".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-message".into(),
|
||||
display_name: "消息中心".into(),
|
||||
description: "消息/模板/订阅/通知".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-health".into(),
|
||||
display_name: "健康管理".into(),
|
||||
description: "患者/体征/预约/随访/咨询".into(),
|
||||
active: true,
|
||||
entity_count: Some(45),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-ai".into(),
|
||||
display_name: "AI 分析".into(),
|
||||
description: "智能分析/化验解读/趋势".into(),
|
||||
active: true,
|
||||
entity_count: Some(3),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-dialysis".into(),
|
||||
display_name: "透析管理".into(),
|
||||
description: "透析记录/处方/用药".into(),
|
||||
active: true,
|
||||
entity_count: Some(5),
|
||||
route_count: None,
|
||||
},
|
||||
ModuleStatusResp {
|
||||
name: "erp-plugin".into(),
|
||||
display_name: "插件系统".into(),
|
||||
description: "WASM 运行时/动态表".into(),
|
||||
active: true,
|
||||
entity_count: Some(4),
|
||||
route_count: None,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// 用户活跃度统计
|
||||
pub async fn get_user_activity(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<UserActivityResp> {
|
||||
let sql = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active,
|
||||
(SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct ActivityRow {
|
||||
daily_active: i64,
|
||||
weekly_active: i64,
|
||||
monthly_active: i64,
|
||||
total_registered: i64,
|
||||
}
|
||||
|
||||
let activity: Option<ActivityRow> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
let a = activity.unwrap_or(ActivityRow {
|
||||
daily_active: 0,
|
||||
weekly_active: 0,
|
||||
monthly_active: 0,
|
||||
total_registered: 0,
|
||||
});
|
||||
|
||||
// 角色分布
|
||||
let role_sql = r#"
|
||||
SELECT r.name AS role, COUNT(ur.user_id) AS count
|
||||
FROM roles r
|
||||
LEFT JOIN user_roles ur ON ur.role_id = r.id AND ur.tenant_id = $1
|
||||
LEFT JOIN users u ON u.id = ur.user_id AND u.deleted_at IS NULL
|
||||
WHERE r.tenant_id = $1 AND r.deleted_at IS NULL
|
||||
GROUP BY r.name
|
||||
ORDER BY count DESC
|
||||
"#;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct RoleRow {
|
||||
role: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let role_rows: Vec<RoleRow> = FromQueryResult::find_by_statement(
|
||||
sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
role_sql,
|
||||
[tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(UserActivityResp {
|
||||
daily_active: a.daily_active,
|
||||
weekly_active: a.weekly_active,
|
||||
monthly_active: a.monthly_active,
|
||||
total_registered: a.total_registered,
|
||||
by_role: role_rows.into_iter().map(|r| RoleCount { role: r.role, count: r.count }).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 系统健康检查
|
||||
pub async fn get_system_health(
|
||||
state: &HealthState,
|
||||
) -> AppResult<SystemHealthResp> {
|
||||
let mut services = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// 数据库检查
|
||||
let db_start = std::time::Instant::now();
|
||||
let db_status = match state.db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1".to_string(),
|
||||
)).await
|
||||
{
|
||||
Ok(_) => "healthy".to_string(),
|
||||
Err(e) => format!("down: {e}"),
|
||||
};
|
||||
let db_ms = db_start.elapsed().as_millis() as i64;
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "PostgreSQL".into(),
|
||||
status: if db_status == "healthy" { "healthy".into() } else { "down".into() },
|
||||
message: if db_status == "healthy" { "正常".into() } else { db_status },
|
||||
response_ms: Some(db_ms),
|
||||
});
|
||||
|
||||
// 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy)
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "API 服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "运行中".into(),
|
||||
response_ms: Some(start.elapsed().as_millis() as i64),
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "定时任务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常运行".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "文件存储".into(),
|
||||
status: "healthy".into(),
|
||||
message: "可用".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "消息队列".into(),
|
||||
status: "healthy".into(),
|
||||
message: "无积压".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
services.push(ServiceHealthStatus {
|
||||
name: "缓存服务".into(),
|
||||
status: "healthy".into(),
|
||||
message: "正常".into(),
|
||||
response_ms: None,
|
||||
});
|
||||
|
||||
Ok(SystemHealthResp {
|
||||
services,
|
||||
checked_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user