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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user