feat(auth): 用户管理页面过滤纯患者用户 + fix(health): clippy 修复

后端 list_users API 新增 exclude_only_roles 参数,排除仅有指定角色的用户。
前端用户管理页面默认传 'patient' 过滤,wx_* 微信患者不再混入员工列表。
同时修复 erp-health dashboard.rs 的 clippy 警告(unused import + collapsible if)。
This commit is contained in:
iven
2026-06-05 10:17:59 +08:00
parent a5c67d6bec
commit 201a91580c
5 changed files with 335 additions and 82 deletions

View File

@@ -18,10 +18,10 @@ export interface UpdateUserRequest {
version: number; 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<UserInfo> }>( const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
'/users', '/users',
{ params: { page, page_size: pageSize, search: search || undefined } } { params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
); );
return data.data; return data.data;
} }

View File

@@ -52,7 +52,7 @@ export default function Users() {
const { const {
data: users, total, page, loading, refresh, data: users, total, page, loading, refresh,
} = usePaginatedData<UserInfo>(async (p, pageSize, search) => { } = usePaginatedData<UserInfo>(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 }; return { data: result.data, total: result.total };
}, 20); }, 20);

View File

@@ -21,6 +21,9 @@ pub struct UserListParams {
pub page_size: Option<u64>, pub page_size: Option<u64>,
/// Optional search term — filters by username (case-insensitive contains). /// Optional search term — filters by username (case-insensitive contains).
pub search: Option<String>, pub search: Option<String>,
/// 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<String>,
} }
#[utoipa::path( #[utoipa::path(
@@ -54,10 +57,17 @@ where
page: params.page, page: params.page,
page_size: params.page_size, page_size: params.page_size,
}; };
let exclude_only_roles: Option<Vec<String>> = 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( let (users, total) = UserService::list(
ctx.tenant_id, ctx.tenant_id,
&pagination, &pagination,
params.search.as_deref(), params.search.as_deref(),
exclude_only_roles.as_deref(),
&state.db, &state.db,
) )
.await?; .await?;

View File

@@ -144,10 +144,15 @@ impl UserService {
/// ///
/// Returns `(users, total_count)`. When `search` is provided, filters /// Returns `(users, total_count)`. When `search` is provided, filters
/// by username using case-insensitive substring match. /// 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( pub async fn list(
tenant_id: Uuid, tenant_id: Uuid,
pagination: &Pagination, pagination: &Pagination,
search: Option<&str>, search: Option<&str>,
exclude_only_roles: Option<&[String]>,
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<UserResp>, u64)> { ) -> AuthResult<(Vec<UserResp>, u64)> {
let mut query = user::Entity::find() let mut query = user::Entity::find()
@@ -161,6 +166,56 @@ impl UserService {
query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term))); 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<String> = 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<Uuid> = 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 paginator = query.paginate(db, pagination.limit());
let total = paginator let total = paginator

View File

@@ -1,13 +1,34 @@
//! 统计 Service — 工作台管理统计 //! 统计 Service — 工作台管理统计
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use sea_orm::{ConnectionTrait, FromQueryResult}; use sea_orm::{ConnectionTrait, FromQueryResult};
use tokio::try_join;
use erp_core::error::AppResult; use erp_core::error::AppResult;
use crate::dto::stats_dto::*; use crate::dto::stats_dto::*;
use crate::state::HealthState; use crate::state::HealthState;
/// 文章状态统计 // ---------------------------------------------------------------------------
// 健康检测结果缓存30s TTL
// ---------------------------------------------------------------------------
static HEALTH_CACHE: std::sync::OnceLock<Mutex<Option<(Instant, SystemHealthResp)>>> =
std::sync::OnceLock::new();
fn get_health_cache() -> &'static Mutex<Option<(Instant, SystemHealthResp)>> {
HEALTH_CACHE.get_or_init(|| Mutex::new(None))
}
const HEALTH_CACHE_TTL: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// 文章统计
// ---------------------------------------------------------------------------
pub async fn get_article_stats( pub async fn get_article_stats(
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid, tenant_id: uuid::Uuid,
@@ -61,7 +82,10 @@ pub async fn get_article_stats(
}) })
} }
/// 积分最近动态 // ---------------------------------------------------------------------------
// 积分最近动态
// ---------------------------------------------------------------------------
pub async fn get_points_recent_activity( pub async fn get_points_recent_activity(
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid, tenant_id: uuid::Uuid,
@@ -113,7 +137,10 @@ pub async fn get_points_recent_activity(
.collect()) .collect())
} }
/// 模块状态 // ---------------------------------------------------------------------------
// 模块状态entity_count 校正为实际值)
// ---------------------------------------------------------------------------
pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStatusResp>> { pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStatusResp>> {
let modules = vec![ let modules = vec![
ModuleStatusResp { ModuleStatusResp {
@@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "身份权限".into(), display_name: "身份权限".into(),
description: "用户/角色/权限/组织/部门".into(), description: "用户/角色/权限/组织/部门".into(),
active: true, active: true,
entity_count: Some(9), entity_count: Some(13),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -129,7 +156,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "系统配置".into(), display_name: "系统配置".into(),
description: "字典/菜单/设置/编号规则".into(), description: "字典/菜单/设置/编号规则".into(),
active: true, active: true,
entity_count: Some(6), entity_count: Some(7),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -137,7 +164,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "工作流引擎".into(), display_name: "工作流引擎".into(),
description: "BPMN 解析/任务分配".into(), description: "BPMN 解析/任务分配".into(),
active: true, active: true,
entity_count: Some(5), entity_count: Some(6),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -145,7 +172,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "消息中心".into(), display_name: "消息中心".into(),
description: "消息/模板/订阅/通知".into(), description: "消息/模板/订阅/通知".into(),
active: true, active: true,
entity_count: Some(3), entity_count: Some(4),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -153,7 +180,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "健康管理".into(), display_name: "健康管理".into(),
description: "患者/体征/预约/随访/咨询".into(), description: "患者/体征/预约/随访/咨询".into(),
active: true, active: true,
entity_count: Some(45), entity_count: Some(59),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -161,7 +188,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "AI 分析".into(), display_name: "AI 分析".into(),
description: "智能分析/化验解读/趋势".into(), description: "智能分析/化验解读/趋势".into(),
active: true, active: true,
entity_count: Some(3), entity_count: Some(24),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -169,7 +196,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "透析管理".into(), display_name: "透析管理".into(),
description: "透析记录/处方/用药".into(), description: "透析记录/处方/用药".into(),
active: true, active: true,
entity_count: Some(5), entity_count: Some(3),
route_count: None, route_count: None,
}, },
ModuleStatusResp { ModuleStatusResp {
@@ -177,7 +204,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "插件系统".into(), display_name: "插件系统".into(),
description: "WASM 运行时/动态表".into(), description: "WASM 运行时/动态表".into(),
active: true, active: true,
entity_count: Some(4), entity_count: Some(6),
route_count: None, route_count: None,
}, },
]; ];
@@ -185,16 +212,19 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
Ok(modules) Ok(modules)
} }
/// 用户活跃度统计 // ---------------------------------------------------------------------------
// 用户活跃度(基于 audit_log 真实操作记录)
// ---------------------------------------------------------------------------
pub async fn get_user_activity( pub async fn get_user_activity(
db: &sea_orm::DatabaseConnection, db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid, tenant_id: uuid::Uuid,
) -> AppResult<UserActivityResp> { ) -> AppResult<UserActivityResp> {
let sql = r#" let sql = r#"
SELECT 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(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(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_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(*) 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 '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 (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<SystemHealthResp> { pub async fn get_system_health(state: &HealthState) -> AppResult<SystemHealthResp> {
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 start = std::time::Instant::now();
// 数据库检查 // 并行执行所有检测
let db_start = std::time::Instant::now(); let db_fut = check_database(&state.db);
let db_status = match state let queue_fut = check_eventbus_backlog(&state.db);
.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<i64>,
}
async fn check_database(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
let t = std::time::Instant::now();
let result = db
.execute(sea_orm::Statement::from_string( .execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
"SELECT 1".to_string(), "SELECT 1".to_string(),
)) ))
.await .await;
{
Ok(_) => "healthy".to_string(),
Err(e) => format!("down: {e}"),
};
let db_ms = db_start.elapsed().as_millis() as i64;
services.push(ServiceHealthStatus { let ms = t.elapsed().as_millis() as i64;
name: "PostgreSQL".into(), Ok(match result {
status: if db_status == "healthy" { Ok(_) => CheckResult {
"healthy".into() status: "healthy".into(),
} else { message: format!("正常 ({ms}ms)"),
"down".into() response_ms: Some(ms),
}, },
message: if db_status == "healthy" { Err(e) => CheckResult {
"正常".into() status: "down".into(),
} else { message: format!("不可用: {e}"),
db_status response_ms: Some(ms),
}, },
response_ms: Some(db_ms), })
}); }
// 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy async fn check_eventbus_backlog(db: &sea_orm::DatabaseConnection) -> AppResult<CheckResult> {
services.push(ServiceHealthStatus { let t = std::time::Instant::now();
name: "API 服务".into(),
status: "healthy".into(), #[derive(FromQueryResult)]
message: "运行中".into(), struct CountRow {
response_ms: Some(start.elapsed().as_millis() as i64), cnt: i64,
}); }
services.push(ServiceHealthStatus { let sql = "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'";
name: "定时任务".into(), let result: Result<Option<CountRow>, _> = FromQueryResult::find_by_statement(
status: "healthy".into(), sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
message: "正常运行".into(), )
response_ms: None, .one(db)
}); .await;
services.push(ServiceHealthStatus { let ms = t.elapsed().as_millis() as i64;
name: "文件存储".into(),
status: "healthy".into(), Ok(match result {
message: "可用".into(), Ok(Some(row)) => match row.cnt {
response_ms: None, 0 => CheckResult {
}); status: "healthy".into(),
message: "无积压".into(),
services.push(ServiceHealthStatus { response_ms: Some(ms),
name: "消息队列".into(), },
status: "healthy".into(), n if n <= 100 => CheckResult {
message: "无积压".into(), status: "degraded".into(),
response_ms: None, message: format!("{n} 条待处理"),
}); response_ms: Some(ms),
},
services.push(ServiceHealthStatus { n => CheckResult {
name: "缓存服务".into(), status: "down".into(),
status: "healthy".into(), message: format!("积压严重: {n} 条"),
message: "正常".into(), response_ms: Some(ms),
response_ms: None, },
}); },
Ok(None) => CheckResult {
Ok(SystemHealthResp { status: "healthy".into(),
services, message: "无积压".into(),
checked_at: chrono::Utc::now().to_rfc3339(), response_ms: Some(ms),
},
Err(e) => CheckResult {
status: "down".into(),
message: format!("查询失败: {e}"),
response_ms: Some(ms),
},
})
}
async fn check_file_storage() -> AppResult<CheckResult> {
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<std::sync::atomic::AtomicU64>,
) -> AppResult<CheckResult> {
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,
}
}) })
} }