fix: 系统性预防角色测试高频问题(5 方案落地)
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

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:
iven
2026-05-08 08:52:16 +08:00
parent 645ec39e8b
commit c82f7bda1d
11 changed files with 594 additions and 90 deletions

View File

@@ -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.

View 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()
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod aggregate;
pub mod audit;
pub mod audit_service;
pub mod crypto;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 &current 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:?}"
);
}
}
}
}