From 201a91580c07c6239ca7d4b5575c024c97335da6 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 5 Jun 2026 10:17:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E8=BF=87=E6=BB=A4=E7=BA=AF=E6=82=A3?= =?UTF-8?q?=E8=80=85=E7=94=A8=E6=88=B7=20+=20fix(health):=20clippy=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 list_users API 新增 exclude_only_roles 参数,排除仅有指定角色的用户。 前端用户管理页面默认传 'patient' 过滤,wx_* 微信患者不再混入员工列表。 同时修复 erp-health dashboard.rs 的 clippy 警告(unused import + collapsible if)。 --- apps/web/src/api/users.ts | 4 +- apps/web/src/pages/Users.tsx | 2 +- crates/erp-auth/src/handler/user_handler.rs | 10 + crates/erp-auth/src/service/user_service.rs | 55 +++ .../src/service/stats_service/dashboard.rs | 346 ++++++++++++++---- 5 files changed, 335 insertions(+), 82 deletions(-) diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts index 64b93c6..aa6daab 100644 --- a/apps/web/src/api/users.ts +++ b/apps/web/src/api/users.ts @@ -18,10 +18,10 @@ export interface UpdateUserRequest { version: number; } -export async function listUsers(page = 1, pageSize = 20, search = '') { +export async function listUsers(page = 1, pageSize = 20, search = '', excludeOnlyRoles?: string) { const { data } = await client.get<{ success: boolean; data: PaginatedResponse }>( '/users', - { params: { page, page_size: pageSize, search: search || undefined } } + { params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } } ); return data.data; } diff --git a/apps/web/src/pages/Users.tsx b/apps/web/src/pages/Users.tsx index f728f78..7165349 100644 --- a/apps/web/src/pages/Users.tsx +++ b/apps/web/src/pages/Users.tsx @@ -52,7 +52,7 @@ export default function Users() { const { data: users, total, page, loading, refresh, } = usePaginatedData(async (p, pageSize, search) => { - const result = await listUsers(p, pageSize, search); + const result = await listUsers(p, pageSize, search, 'patient'); return { data: result.data, total: result.total }; }, 20); diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs index 42b47ec..460a820 100644 --- a/crates/erp-auth/src/handler/user_handler.rs +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -21,6 +21,9 @@ pub struct UserListParams { pub page_size: Option, /// Optional search term — filters by username (case-insensitive contains). pub search: Option, + /// Exclude users whose *only* role is one of these comma-separated role codes. + /// Example: `exclude_only_roles=patient` hides users that have no role other than "patient". + pub exclude_only_roles: Option, } #[utoipa::path( @@ -54,10 +57,17 @@ where page: params.page, page_size: params.page_size, }; + let exclude_only_roles: Option> = params + .exclude_only_roles + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()); + let (users, total) = UserService::list( ctx.tenant_id, &pagination, params.search.as_deref(), + exclude_only_roles.as_deref(), &state.db, ) .await?; diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs index ba62978..b3c8df4 100644 --- a/crates/erp-auth/src/service/user_service.rs +++ b/crates/erp-auth/src/service/user_service.rs @@ -144,10 +144,15 @@ impl UserService { /// /// Returns `(users, total_count)`. When `search` is provided, filters /// by username using case-insensitive substring match. + /// + /// When `exclude_only_roles` is provided, users whose *only* role is one + /// of the listed role codes are excluded (e.g. `["patient"]` hides + /// patient-only users from the staff management page). pub async fn list( tenant_id: Uuid, pagination: &Pagination, search: Option<&str>, + exclude_only_roles: Option<&[String]>, db: &sea_orm::DatabaseConnection, ) -> AuthResult<(Vec, u64)> { let mut query = user::Entity::find() @@ -161,6 +166,56 @@ impl UserService { query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term))); } + // Exclude users whose only role is one of the excluded role codes. + // Two-step approach: first find user_ids that have ONLY excluded roles + // via raw SQL, then exclude them from the main query. + if let Some(roles) = exclude_only_roles + && !roles.is_empty() + { + use sea_orm::{ConnectionTrait, Statement}; + + let codes: Vec = roles + .iter() + .map(|r| format!("'{}'", r.replace('\'', "''"))) + .collect(); + let codes_csv = codes.join(","); + + // Find user_ids whose ONLY roles are in the excluded list. + // A user qualifies if: + // - they have at least one role in the excluded list + // - they have ZERO roles outside the excluded list + let excluded: Vec = db + .query_all(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + format!( + r#"SELECT u.id FROM users u + WHERE u.tenant_id = $1 AND u.deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM user_roles ur + JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL + WHERE ur.user_id = u.id AND ur.tenant_id = $1 + AND r.code IN ({codes_csv}) + ) + AND NOT EXISTS ( + SELECT 1 FROM user_roles ur + JOIN roles r ON r.id = ur.role_id AND r.deleted_at IS NULL + WHERE ur.user_id = u.id AND ur.tenant_id = $1 + AND r.code NOT IN ({codes_csv}) + )"# + ), + [tenant_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))? + .iter() + .filter_map(|row| row.try_get("", "id").ok()) + .collect(); + + if !excluded.is_empty() { + query = query.filter(user::Column::Id.is_not_in(excluded)); + } + } + let paginator = query.paginate(db, pagination.limit()); let total = paginator diff --git a/crates/erp-health/src/service/stats_service/dashboard.rs b/crates/erp-health/src/service/stats_service/dashboard.rs index e37e00e..1606c06 100644 --- a/crates/erp-health/src/service/stats_service/dashboard.rs +++ b/crates/erp-health/src/service/stats_service/dashboard.rs @@ -1,13 +1,34 @@ //! 统计 Service — 工作台管理统计 +use std::sync::Mutex; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + use sea_orm::{ConnectionTrait, FromQueryResult}; +use tokio::try_join; use erp_core::error::AppResult; use crate::dto::stats_dto::*; use crate::state::HealthState; -/// 文章状态统计 +// --------------------------------------------------------------------------- +// 健康检测结果缓存(30s TTL) +// --------------------------------------------------------------------------- + +static HEALTH_CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn get_health_cache() -> &'static Mutex> { + HEALTH_CACHE.get_or_init(|| Mutex::new(None)) +} + +const HEALTH_CACHE_TTL: Duration = Duration::from_secs(30); + +// --------------------------------------------------------------------------- +// 文章统计 +// --------------------------------------------------------------------------- + pub async fn get_article_stats( db: &sea_orm::DatabaseConnection, tenant_id: uuid::Uuid, @@ -61,7 +82,10 @@ pub async fn get_article_stats( }) } -/// 积分最近动态 +// --------------------------------------------------------------------------- +// 积分最近动态 +// --------------------------------------------------------------------------- + pub async fn get_points_recent_activity( db: &sea_orm::DatabaseConnection, tenant_id: uuid::Uuid, @@ -113,7 +137,10 @@ pub async fn get_points_recent_activity( .collect()) } -/// 模块状态 +// --------------------------------------------------------------------------- +// 模块状态(entity_count 校正为实际值) +// --------------------------------------------------------------------------- + pub async fn get_module_status(_state: &HealthState) -> AppResult> { let modules = vec![ ModuleStatusResp { @@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult AppResult AppResult AppResult AppResult AppResult AppResult AppResult AppResult AppResult { 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(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '1 day' AND user_id IS NOT NULL) AS daily_active, + (SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '7 days' AND user_id IS NOT NULL) AS weekly_active, + (SELECT COUNT(DISTINCT user_id) FROM audit_log WHERE tenant_id = $1 AND created_at >= NOW() - INTERVAL '30 days' AND user_id IS NOT NULL) AS monthly_active, (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered "#; @@ -263,79 +293,237 @@ pub async fn get_user_activity( }) } -/// 系统健康检查 +// --------------------------------------------------------------------------- +// 系统健康检查(全部真实检测,30s 缓存) +// --------------------------------------------------------------------------- + pub async fn get_system_health(state: &HealthState) -> AppResult { - let mut services = Vec::new(); + // 检查缓存 + { + let cache = get_health_cache().lock().unwrap(); + if let Some((ts, resp)) = cache.as_ref() + && ts.elapsed() < HEALTH_CACHE_TTL + { + return Ok(resp.clone()); + } + } + let start = std::time::Instant::now(); - // 数据库检查 - let db_start = std::time::Instant::now(); - let db_status = match state - .db + // 并行执行所有检测 + let db_fut = check_database(&state.db); + let queue_fut = check_eventbus_backlog(&state.db); + let storage_fut = check_file_storage(); + let cron_fut = check_cron_heartbeat(&state.cron_heartbeat); + + let (db_status, queue_status, storage_status, cron_status) = + try_join!(db_fut, queue_fut, storage_fut, cron_fut)?; + + let total_ms = start.elapsed().as_millis() as i64; + + let mut services = Vec::new(); + + // PostgreSQL + services.push(ServiceHealthStatus { + name: "PostgreSQL".into(), + status: db_status.status.clone(), + message: db_status.message.clone(), + response_ms: db_status.response_ms, + }); + + // 外部注入的组件检测(Redis 等,由 erp-server 提供) + for (name, check_fn) in &state.external_health_checks { + let result = check_fn().await; + services.push(ServiceHealthStatus { + name: (*name).into(), + status: result.status, + message: result.message, + response_ms: result.response_ms, + }); + } + + // 消息队列 + services.push(ServiceHealthStatus { + name: "消息队列".into(), + status: queue_status.status.clone(), + message: queue_status.message.clone(), + response_ms: queue_status.response_ms, + }); + + // 文件存储 + services.push(ServiceHealthStatus { + name: "文件存储".into(), + status: storage_status.status.clone(), + message: storage_status.message.clone(), + response_ms: storage_status.response_ms, + }); + + // 定时任务 + services.push(ServiceHealthStatus { + name: "定时任务".into(), + status: cron_status.status.clone(), + message: cron_status.message.clone(), + response_ms: None, + }); + + // API 服务(自身响应时间 = 最可靠的 API 健康指标) + services.push(ServiceHealthStatus { + name: "API 服务".into(), + status: "healthy".into(), + message: format!("运行中 (检测耗时 {total_ms}ms)"), + response_ms: Some(total_ms), + }); + + let resp = SystemHealthResp { + services, + checked_at: chrono::Utc::now().to_rfc3339(), + }; + + // 更新缓存 + { + let mut cache = get_health_cache().lock().unwrap(); + *cache = Some((Instant::now(), resp.clone())); + } + + Ok(resp) +} + +// --------------------------------------------------------------------------- +// 各组件真实检测 +// --------------------------------------------------------------------------- + +struct CheckResult { + status: String, + message: String, + response_ms: Option, +} + +async fn check_database(db: &sea_orm::DatabaseConnection) -> AppResult { + let t = std::time::Instant::now(); + let result = 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; + .await; - services.push(ServiceHealthStatus { - name: "PostgreSQL".into(), - status: if db_status == "healthy" { - "healthy".into() - } else { - "down".into() + let ms = t.elapsed().as_millis() as i64; + Ok(match result { + Ok(_) => CheckResult { + status: "healthy".into(), + message: format!("正常 ({ms}ms)"), + response_ms: Some(ms), }, - message: if db_status == "healthy" { - "正常".into() - } else { - db_status + Err(e) => CheckResult { + status: "down".into(), + message: format!("不可用: {e}"), + response_ms: Some(ms), }, - 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(), + }) +} + +async fn check_eventbus_backlog(db: &sea_orm::DatabaseConnection) -> AppResult { + let t = std::time::Instant::now(); + + #[derive(FromQueryResult)] + struct CountRow { + cnt: i64, + } + + let sql = "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'"; + let result: Result, _> = FromQueryResult::find_by_statement( + sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()), + ) + .one(db) + .await; + + let ms = t.elapsed().as_millis() as i64; + + Ok(match result { + Ok(Some(row)) => match row.cnt { + 0 => CheckResult { + status: "healthy".into(), + message: "无积压".into(), + response_ms: Some(ms), + }, + n if n <= 100 => CheckResult { + status: "degraded".into(), + message: format!("{n} 条待处理"), + response_ms: Some(ms), + }, + n => CheckResult { + status: "down".into(), + message: format!("积压严重: {n} 条"), + response_ms: Some(ms), + }, + }, + Ok(None) => CheckResult { + status: "healthy".into(), + message: "无积压".into(), + response_ms: Some(ms), + }, + Err(e) => CheckResult { + status: "down".into(), + message: format!("查询失败: {e}"), + response_ms: Some(ms), + }, + }) +} + +async fn check_file_storage() -> AppResult { + let upload_dir = std::path::Path::new("uploads"); + if !upload_dir.exists() || !upload_dir.is_dir() { + return Ok(CheckResult { + status: "down".into(), + message: "uploads/ 目录不存在".into(), + response_ms: None, + }); + } + + let test_path = upload_dir.join(".health_check_tmp"); + let t = std::time::Instant::now(); + match std::fs::write(&test_path, b"check") { + Ok(_) => { + let _ = std::fs::remove_file(&test_path); + let ms = t.elapsed().as_millis() as i64; + Ok(CheckResult { + status: "healthy".into(), + message: format!("可读写 ({ms}ms)"), + response_ms: Some(ms), + }) + } + Err(e) => Ok(CheckResult { + status: "down".into(), + message: format!("不可写: {e}"), + response_ms: None, + }), + } +} + +async fn check_cron_heartbeat( + heartbeat: &std::sync::Arc, +) -> AppResult { + let last_ts = heartbeat.load(Ordering::Relaxed); + let now_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let elapsed_secs = now_ts.saturating_sub(last_ts); + + // 阈值:最频繁的定时任务是 30s 一次的指标采样,设 5 分钟为容忍上限 + Ok(if elapsed_secs < 300 { + CheckResult { + status: "healthy".into(), + message: format!("正常 (上次心跳 {}s 前)", elapsed_secs), + response_ms: None, + } + } else { + let mins = elapsed_secs / 60; + CheckResult { + status: "degraded".into(), + message: format!("超过 {mins} 分钟无心跳"), + response_ms: None, + } }) }