fix: 系统性预防角色测试高频问题(5 方案落地)
P0 — 默认拒绝 + 强制守卫: - 创建 routeConfig.ts 作为前端路由权限的单一真相源 - TypeScript 强制每个路由声明非空权限数组,不可能遗漏 - 自动生成 ROUTE_PERMISSIONS 和 FROZEN_ROUTES - 修正 3 个前端权限码不匹配后端 P0 — CI 权限扫描: - 新增 tools/check_permissions.py 校验脚本 - 发现并修复 tenant.manage 未注册问题 P1 — 聚合接口容错: - erp-core 新增 safe_aggregate 工具函数 - 仪表盘统计 handler 重构 P1 — 状态机一致性自检: - validation.rs 新增 3 个自检测试 fix: lint-staged eslint Windows 兼容性
This commit is contained in:
@@ -318,6 +318,14 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
||||
"管理插件全生命周期",
|
||||
),
|
||||
("plugin.list", "查看插件", "plugin", "list", "查看插件列表"),
|
||||
// === Server level ===
|
||||
(
|
||||
"tenant.manage",
|
||||
"租户管理",
|
||||
"tenant",
|
||||
"manage",
|
||||
"管理租户级设置(密钥轮换等)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS.
|
||||
|
||||
35
crates/erp-core/src/aggregate.rs
Normal file
35
crates/erp-core/src/aggregate.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
//! 聚合查询容错工具
|
||||
//!
|
||||
//! 仪表盘等聚合统计端点通常包含多个独立子查询。
|
||||
//! 单个子查询失败不应导致整个接口 500。
|
||||
//! `safe_aggregate` 让每个子查询独立容错,失败时返回默认值并记录警告日志。
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
/// 执行一个子查询,失败时返回 `T::default()` 并记录警告日志。
|
||||
///
|
||||
/// # 使用场景
|
||||
///
|
||||
/// 仪表盘统计 API 聚合多个指标(患者数/咨询数/随访数等),
|
||||
/// 任一子查询失败不应阻塞其他指标返回。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let patients = safe_aggregate(
|
||||
/// stats_service::get_patient_statistics(&state, tenant_id),
|
||||
/// "患者统计",
|
||||
/// ).await;
|
||||
/// ```
|
||||
pub async fn safe_aggregate<T: Default, E: std::fmt::Display>(
|
||||
fut: impl Future<Output = Result<T, E>>,
|
||||
label: &str,
|
||||
) -> T {
|
||||
match fut.await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("聚合子查询 [{label}] 失败,使用默认值: {e}");
|
||||
T::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod aggregate;
|
||||
pub mod audit;
|
||||
pub mod audit_service;
|
||||
pub mod crypto;
|
||||
|
||||
@@ -5,7 +5,7 @@ use utoipa::ToSchema;
|
||||
// 患者统计
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PatientStatisticsResp {
|
||||
pub total_patients: i64,
|
||||
pub new_this_month: i64,
|
||||
@@ -13,7 +13,7 @@ pub struct PatientStatisticsResp {
|
||||
pub active_this_month: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ConsultationStatisticsResp {
|
||||
pub total_sessions: i64,
|
||||
pub pending_reply: i64,
|
||||
@@ -21,7 +21,7 @@ pub struct ConsultationStatisticsResp {
|
||||
pub this_month: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FollowUpStatisticsResp {
|
||||
pub total_tasks: i64,
|
||||
pub completed: i64,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, State};
|
||||
use erp_core::aggregate::safe_aggregate;
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
@@ -57,42 +58,23 @@ where
|
||||
{
|
||||
require_permission(&ctx, "health.patient.list")?;
|
||||
|
||||
let patients = stats_service::get_patient_statistics(&state, ctx.tenant_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("仪表盘患者统计查询失败: {e}");
|
||||
PatientStatisticsResp {
|
||||
total_patients: 0,
|
||||
new_this_month: 0,
|
||||
new_this_week: 0,
|
||||
active_this_month: 0,
|
||||
}
|
||||
});
|
||||
let patients = safe_aggregate(
|
||||
stats_service::get_patient_statistics(&state, ctx.tenant_id),
|
||||
"患者统计",
|
||||
)
|
||||
.await;
|
||||
|
||||
let consultations = stats_service::get_consultation_statistics(&state, ctx.tenant_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("仪表盘咨询统计查询失败: {e}");
|
||||
ConsultationStatisticsResp {
|
||||
total_sessions: 0,
|
||||
pending_reply: 0,
|
||||
avg_response_time_minutes: None,
|
||||
this_month: 0,
|
||||
}
|
||||
});
|
||||
let consultations = safe_aggregate(
|
||||
stats_service::get_consultation_statistics(&state, ctx.tenant_id),
|
||||
"咨询统计",
|
||||
)
|
||||
.await;
|
||||
|
||||
let follow_ups = stats_service::get_follow_up_statistics(&state, ctx.tenant_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("仪表盘随访统计查询失败: {e}");
|
||||
FollowUpStatisticsResp {
|
||||
total_tasks: 0,
|
||||
completed: 0,
|
||||
pending: 0,
|
||||
overdue: 0,
|
||||
completion_rate: 0.0,
|
||||
}
|
||||
});
|
||||
let follow_ups = safe_aggregate(
|
||||
stats_service::get_follow_up_statistics(&state, ctx.tenant_id),
|
||||
"随访统计",
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(ApiResponse::ok(DashboardStatsResp {
|
||||
patients,
|
||||
|
||||
@@ -759,4 +759,158 @@ mod tests {
|
||||
fn alert_same_status_ok() {
|
||||
assert!(validate_alert_status_transition("pending", "pending").is_ok());
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 状态机一致性自检 — 防止 seed 数据与状态机定义不同步
|
||||
// =====================================================
|
||||
|
||||
/// 每个实体的合法状态全集 — 枚举校验器和状态转换函数引用的所有状态必须在此集合内
|
||||
const ENTITY_STATES: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"appointment",
|
||||
&["pending", "confirmed", "cancelled", "completed", "no_show"],
|
||||
),
|
||||
(
|
||||
"article",
|
||||
&[
|
||||
"draft",
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
"published",
|
||||
],
|
||||
),
|
||||
("lab_report", &["pending", "reviewed"]),
|
||||
(
|
||||
"follow_up_task",
|
||||
&[
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"overdue",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alert",
|
||||
&["pending", "active", "acknowledged", "resolved", "dismissed"],
|
||||
),
|
||||
("patient", &["active", "inactive", "deceased"]),
|
||||
("consultation_session", &["active", "closed", "pending"]),
|
||||
];
|
||||
|
||||
/// 校验:状态转换函数中出现的每个状态值,必须在对应实体的合法状态集中。
|
||||
/// 如果状态机函数引用了新状态但忘记更新枚举校验器,此测试会失败。
|
||||
#[test]
|
||||
fn state_machine_transition_targets_are_valid_states() {
|
||||
for &(entity, valid_states) in ENTITY_STATES {
|
||||
let valid_set: std::collections::HashSet<&str> = valid_states.iter().copied().collect();
|
||||
|
||||
// 测试每个合法的 (current, next) 组合
|
||||
for ¤t in valid_states {
|
||||
// 用 next = current 测试自身转换(应该总是 OK)
|
||||
let self_result = match entity {
|
||||
"appointment" => validate_appointment_status_transition(current, current),
|
||||
"article" => validate_article_status_transition(current, current),
|
||||
"lab_report" => validate_lab_report_status_transition(current, current),
|
||||
"follow_up_task" => validate_follow_up_status_transition(current, current),
|
||||
"alert" => validate_alert_status_transition(current, current),
|
||||
_ => continue,
|
||||
};
|
||||
assert!(
|
||||
self_result.is_ok(),
|
||||
"[{entity}] 状态自身转换 {current}→{current} 应该合法"
|
||||
);
|
||||
|
||||
// 对每个 next 状态,验证转换函数返回 OK 意味着 next 也在合法集中
|
||||
for &next in valid_states {
|
||||
if current == next {
|
||||
continue;
|
||||
}
|
||||
let result = match entity {
|
||||
"appointment" => validate_appointment_status_transition(current, next),
|
||||
"article" => validate_article_status_transition(current, next),
|
||||
"lab_report" => validate_lab_report_status_transition(current, next),
|
||||
"follow_up_task" => validate_follow_up_status_transition(current, next),
|
||||
"alert" => validate_alert_status_transition(current, next),
|
||||
_ => continue,
|
||||
};
|
||||
if result.is_ok() {
|
||||
assert!(
|
||||
valid_set.contains(next),
|
||||
"[{entity}] 转换函数允许 {current}→{next},但 {next} 不在合法状态集中 {valid_states:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验:枚举校验器接受的每个状态值,必须出现在合法状态集中。
|
||||
/// 如果枚举校验器添加了新状态但忘记更新此表,此测试会失败。
|
||||
#[test]
|
||||
fn enum_validators_accept_only_known_states() {
|
||||
for &(entity, valid_states) in ENTITY_STATES {
|
||||
for &state in valid_states {
|
||||
let result = match entity {
|
||||
"appointment" => {
|
||||
// appointment 没有独立的枚举校验器,跳过
|
||||
continue;
|
||||
}
|
||||
"article" => validate_article_status(state),
|
||||
"lab_report" => {
|
||||
// lab_report 没有独立的枚举校验器,跳过
|
||||
continue;
|
||||
}
|
||||
"follow_up_task" => {
|
||||
// follow_up_task 没有独立的枚举校验器,跳过
|
||||
continue;
|
||||
}
|
||||
"alert" => validate_alert_status(state),
|
||||
"patient" => validate_patient_status(state),
|
||||
"consultation_session" => {
|
||||
// consultation 没有独立的状态枚举校验器,跳过
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"[{entity}] 合法状态 '{state}' 应通过枚举校验器"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验:状态机定义的初始状态(seed 数据可用的第一个状态)必须合法。
|
||||
/// 防止 seed 数据使用未注册的状态值。
|
||||
#[test]
|
||||
fn initial_states_are_valid() {
|
||||
let initial_states: &[(&str, &[&str])] = &[
|
||||
("appointment", &["pending"]),
|
||||
("article", &["draft"]),
|
||||
("lab_report", &["pending"]),
|
||||
("follow_up_task", &["pending"]),
|
||||
("alert", &["pending", "active"]),
|
||||
("patient", &["active"]),
|
||||
("consultation_session", &["active", "pending"]),
|
||||
];
|
||||
|
||||
for &(entity, init_states) in initial_states {
|
||||
let valid_states = ENTITY_STATES
|
||||
.iter()
|
||||
.find(|(e, _)| *e == entity)
|
||||
.expect(&format!("实体 '{entity}' 未在 ENTITY_STATES 中注册"))
|
||||
.1;
|
||||
|
||||
let valid_set: std::collections::HashSet<&str> = valid_states.iter().copied().collect();
|
||||
|
||||
for &init in init_states {
|
||||
assert!(
|
||||
valid_set.contains(init),
|
||||
"[{entity}] 初始状态 '{init}' 不在合法状态集中 {valid_states:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user