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;
}
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> }>(
'/users',
{ params: { page, page_size: pageSize, search: search || undefined } }
{ params: { page, page_size: pageSize, search: search || undefined, exclude_only_roles: excludeOnlyRoles } }
);
return data.data;
}

View File

@@ -52,7 +52,7 @@ export default function Users() {
const {
data: users, total, page, loading, refresh,
} = 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 };
}, 20);

View File

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

View File

@@ -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<UserResp>, 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<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 total = paginator

View File

@@ -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<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(
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<Vec<ModuleStatusResp>> {
let modules = vec![
ModuleStatusResp {
@@ -121,7 +148,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "身份权限".into(),
description: "用户/角色/权限/组织/部门".into(),
active: true,
entity_count: Some(9),
entity_count: Some(13),
route_count: None,
},
ModuleStatusResp {
@@ -129,7 +156,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "系统配置".into(),
description: "字典/菜单/设置/编号规则".into(),
active: true,
entity_count: Some(6),
entity_count: Some(7),
route_count: None,
},
ModuleStatusResp {
@@ -137,7 +164,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "工作流引擎".into(),
description: "BPMN 解析/任务分配".into(),
active: true,
entity_count: Some(5),
entity_count: Some(6),
route_count: None,
},
ModuleStatusResp {
@@ -145,7 +172,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "消息中心".into(),
description: "消息/模板/订阅/通知".into(),
active: true,
entity_count: Some(3),
entity_count: Some(4),
route_count: None,
},
ModuleStatusResp {
@@ -153,7 +180,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "健康管理".into(),
description: "患者/体征/预约/随访/咨询".into(),
active: true,
entity_count: Some(45),
entity_count: Some(59),
route_count: None,
},
ModuleStatusResp {
@@ -161,7 +188,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "AI 分析".into(),
description: "智能分析/化验解读/趋势".into(),
active: true,
entity_count: Some(3),
entity_count: Some(24),
route_count: None,
},
ModuleStatusResp {
@@ -169,7 +196,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "透析管理".into(),
description: "透析记录/处方/用药".into(),
active: true,
entity_count: Some(5),
entity_count: Some(3),
route_count: None,
},
ModuleStatusResp {
@@ -177,7 +204,7 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
display_name: "插件系统".into(),
description: "WASM 运行时/动态表".into(),
active: true,
entity_count: Some(4),
entity_count: Some(6),
route_count: None,
},
];
@@ -185,16 +212,19 @@ pub async fn get_module_status(_state: &HealthState) -> AppResult<Vec<ModuleStat
Ok(modules)
}
/// 用户活跃度统计
// ---------------------------------------------------------------------------
// 用户活跃度(基于 audit_log 真实操作记录)
// ---------------------------------------------------------------------------
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(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<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 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<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(
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<CheckResult> {
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<Option<CountRow>, _> = 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<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,
}
})
}