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

@@ -3,7 +3,10 @@ module.exports = {
'cargo fmt --check --',
() => 'cargo clippy -p erp-health -p erp-server -- -D warnings',
],
'apps/web/src/**/*.{ts,tsx}': ['cd apps/web && npx eslint --fix'],
'apps/web/src/**/*.{ts,tsx}': () =>
process.platform === 'win32'
? 'pushd apps/web && npx eslint --fix src/ & popd'
: 'cd apps/web && npx eslint --fix src/',
'apps/web/src/**/*.test.{ts,tsx}': [
'cd apps/web && npx vitest run --reporter=verbose',
],

View File

@@ -8,6 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary';
import { useAuthStore } from './stores/auth';
import { useAppStore } from './stores/app';
import type { ThemeName } from './stores/app';
import { ROUTE_PERMISSIONS, FROZEN_ROUTES } from './routeConfig';
const Home = lazy(() => import('./pages/Home'));
const Users = lazy(() => import('./pages/Users'));
@@ -71,15 +72,6 @@ const ArticleEditor = lazy(() => import('./pages/health/ArticleEditor'));
const ArticleCategoryManage = lazy(() => import('./pages/health/ArticleCategoryManage'));
const ArticleTagManage = lazy(() => import('./pages/health/ArticleTagManage'));
const FROZEN_ROUTES = [
'/health/care-plans',
'/health/shifts',
'/health/family-proxy',
'/health/medications',
'/health/dialysis',
'/health/schedules',
];
function FrozenRoute() {
return <Result status="info" title="功能暂未开放" subTitle="该功能正在优化中,敬请期待" />;
}
@@ -98,49 +90,6 @@ function ForbiddenPage() {
);
}
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/users': ['user.list', 'user.manage'],
'/roles': ['role.list', 'role.manage'],
'/organizations': ['organization.list', 'organization.manage'],
'/workflow': ['workflow.list', 'workflow.read'],
'/messages': ['message.list'],
'/settings': ['config.settings.list', 'config.settings.manage'],
'/plugins/admin': ['plugin.list', 'plugin.manage'],
'/plugins/market': ['plugin.list', 'plugin.manage'],
'/health/patients': ['health.patient.list', 'health.patient.manage'],
'/health/doctors': ['health.doctor.list', 'health.doctor.manage'],
'/health/appointments': ['health.appointment.list', 'health.appointment.manage'],
'/health/follow-up-tasks': ['health.follow-up.list', 'health.follow-up.manage'],
'/health/follow-up-records': ['health.follow-up.list', 'health.follow-up.manage'],
'/health/consultations': ['health.consultation.list', 'health.consultation.manage'],
'/health/action-inbox': ['health.action-inbox.list', 'health.action-inbox.manage'],
'/health/follow-up-templates': ['health.follow-up-templates.list', 'health.follow-up-templates.manage'],
'/health/diagnoses': ['health.health-data.list', 'health.health-data.manage'],
'/health/consents': ['health.consent.list', 'health.consent.manage'],
'/health/realtime-monitor': ['health.device-readings.list', 'health.device-readings.manage'],
'/health/alert-dashboard': ['health.alerts.list', 'health.alerts.manage'],
'/health/alerts': ['health.alerts.list', 'health.alerts.manage'],
'/health/devices': ['health.devices.list', 'health.devices.manage'],
'/health/ble-gateways': ['health.ble-gateways.list', 'health.ble-gateways.manage'],
'/health/critical-value-thresholds': ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'],
'/health/articles': ['health.articles.list', 'health.articles.manage'],
'/health/article-categories': ['health.articles.list', 'health.articles.manage'],
'/health/article-tags': ['health.articles.list', 'health.articles.manage'],
'/health/points-rules': ['health.points.list', 'health.points.manage'],
'/health/points-products': ['health.points.list', 'health.points.manage'],
'/health/points-orders': ['health.points.list', 'health.points.manage'],
'/health/offline-events': ['health.points.list', 'health.points.manage'],
'/health/ai-prompts': ['ai.prompt.list', 'ai.prompt.manage'],
'/health/ai-analysis': ['ai.analysis.list', 'ai.analysis.manage'],
'/health/ai-usage': ['ai.usage.list'],
'/health/oauth-clients': ['health.oauth.list', 'health.oauth.manage'],
'/health/statistics': ['health.health-data.list', 'health.dashboard.manage'],
'/health/tags': ['health.patient.list', 'health.patient.manage'],
'/health/daily-monitoring': ['health.device-readings.list', 'health.device-readings.manage'],
'/health/alert-rules': ['health.alert-rules.list', 'health.alert-rules.manage'],
'/health/medication-records': ['health.medication-records.manage'],
};
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const permissions = useAuthStore((s) => s.permissions);

109
apps/web/src/routeConfig.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* 路由权限配置 — 权限声明的单一真相源
*
* 规则:
* 1. 每个受保护路由必须在此声明至少一个权限码TypeScript 强制非空数组)
* 2. 子路由(如 /health/patients/:id通过前缀匹配自动继承父路由权限无需重复声明
* 3. 新增路由必须在此添加对应条目,否则 PrivateRoute 默认 403
* 4. 冻结路由标记 frozen: true自动归入 FROZEN_ROUTES
*
* 排列规则:精确路径优先于前缀路径(如 /plugins/admin 在 /plugins 之前)
*/
// 非空数组类型 — 确保每个路由至少有一个权限码
type Permissions = [string, ...string[]];
interface RoutePermissionEntry {
path: string;
permissions: Permissions;
frozen?: boolean;
}
const ENTRIES: RoutePermissionEntry[] = [
// ===== 基础模块 =====
{ path: '/users', permissions: ['user.list', 'user.update'] },
{ path: '/roles', permissions: ['role.list', 'role.update'] },
{ path: '/organizations', permissions: ['organization.list', 'organization.update'] },
{ path: '/workflow', permissions: ['workflow.list', 'workflow.read'] },
{ path: '/messages', permissions: ['message.list'] },
{ path: '/settings', permissions: ['setting.read', 'setting.update'] },
// ===== 插件模块(精确路径优先于前缀通配) =====
{ path: '/plugins/admin', permissions: ['plugin.admin'] },
{ path: '/plugins/market', permissions: ['plugin.admin'] },
// 动态路由 catch-all: /plugins/:pluginId/:entityName 等
{ path: '/plugins', permissions: ['plugin.list', 'plugin.admin'] },
// ===== 健康管理 — 患者与医生 =====
{ path: '/health/patients', permissions: ['health.patient.list', 'health.patient.manage'] },
{ path: '/health/tags', permissions: ['health.patient.list', 'health.patient.manage'] },
{ path: '/health/doctors', permissions: ['health.doctor.list', 'health.doctor.manage'] },
{ path: '/health/appointments', permissions: ['health.appointment.list', 'health.appointment.manage'] },
// ===== 健康管理 — 随访与咨询 =====
{ path: '/health/follow-up-tasks', permissions: ['health.follow-up.list', 'health.follow-up.manage'] },
{ path: '/health/follow-up-records', permissions: ['health.follow-up.list', 'health.follow-up.manage'] },
{ path: '/health/follow-up-templates', permissions: ['health.follow-up-templates.list', 'health.follow-up-templates.manage'] },
{ path: '/health/consultations', permissions: ['health.consultation.list', 'health.consultation.manage'] },
{ path: '/health/action-inbox', permissions: ['health.action-inbox.list', 'health.action-inbox.manage'] },
// ===== 健康管理 — 告警与设备 =====
{ path: '/health/alerts', permissions: ['health.alerts.list', 'health.alerts.manage'] },
{ path: '/health/alert-dashboard', permissions: ['health.alerts.list', 'health.alerts.manage'] },
{ path: '/health/alert-rules', permissions: ['health.alert-rules.list', 'health.alert-rules.manage'] },
{ path: '/health/devices', permissions: ['health.devices.list', 'health.devices.manage'] },
{ path: '/health/realtime-monitor', permissions: ['health.device-readings.list', 'health.device-readings.manage'] },
{ path: '/health/ble-gateways', permissions: ['health.ble-gateways.list', 'health.ble-gateways.manage'] },
{ path: '/health/critical-value-thresholds', permissions: ['health.critical-value-thresholds.list', 'health.critical-value-thresholds.manage'] },
{ path: '/health/daily-monitoring', permissions: ['health.device-readings.list', 'health.device-readings.manage'] },
// ===== 健康管理 — 诊断与知情同意 =====
{ path: '/health/diagnoses', permissions: ['health.health-data.list', 'health.health-data.manage'] },
{ path: '/health/consents', permissions: ['health.consent.list', 'health.consent.manage'] },
// ===== 健康管理 — AI 模块 =====
{ path: '/health/ai-prompts', permissions: ['ai.prompt.list', 'ai.prompt.manage'] },
{ path: '/health/ai-analysis', permissions: ['ai.analysis.list', 'ai.analysis.manage'] },
{ path: '/health/ai-usage', permissions: ['ai.usage.list'] },
// ===== 健康管理 — 积分商城 =====
{ path: '/health/points-rules', permissions: ['health.points.list', 'health.points.manage'] },
{ path: '/health/points-products', permissions: ['health.points.list', 'health.points.manage'] },
{ path: '/health/points-orders', permissions: ['health.points.list', 'health.points.manage'] },
{ path: '/health/offline-events', permissions: ['health.points.list', 'health.points.manage'] },
// ===== 健康管理 — 内容管理 =====
{ path: '/health/articles', permissions: ['health.articles.list', 'health.articles.manage'] },
{ path: '/health/article-categories', permissions: ['health.articles.list', 'health.articles.manage'] },
{ path: '/health/article-tags', permissions: ['health.articles.list', 'health.articles.manage'] },
// ===== 健康管理 — 其他 =====
{ path: '/health/oauth-clients', permissions: ['health.oauth.list', 'health.oauth.manage'] },
{ path: '/health/statistics', permissions: ['health.health-data.list', 'health.dashboard.manage'] },
{ path: '/health/medication-records', permissions: ['health.medication-records.manage'] },
// ===== 冻结路由 =====
{ path: '/health/care-plans', permissions: ['health.care-plan.list', 'health.care-plan.manage'], frozen: true },
{ path: '/health/shifts', permissions: ['health.shifts.list', 'health.shifts.manage'], frozen: true },
{ path: '/health/family-proxy', permissions: ['health.family-proxy.list', 'health.family-proxy.manage'], frozen: true },
{ path: '/health/medications', permissions: ['health.medication-records.list', 'health.medication-records.manage'], frozen: true },
{ path: '/health/dialysis', permissions: ['health.dialysis.list', 'health.dialysis.manage'], frozen: true },
{ path: '/health/schedules', permissions: ['health.appointment.list', 'health.appointment.manage'], frozen: true },
];
/** 活跃路由的权限映射 — 自动从配置生成,供 PrivateRoute 使用 */
export const ROUTE_PERMISSIONS: Record<string, string[]> = Object.fromEntries(
ENTRIES.filter((e) => !e.frozen).map((e) => [e.path, [...e.permissions]]),
);
/** 冻结路由路径列表 — 自动从配置生成 */
export const FROZEN_ROUTES: string[] = ENTRIES.filter((e) => e.frozen).map((e) => e.path);
/** 开发模式下校验:检查是否有路由路径重复 */
if (import.meta.env.DEV) {
const paths = ENTRIES.map((e) => e.path);
const dupes = paths.filter((p, i) => paths.indexOf(p) !== i);
if (dupes.length > 0) {
console.error('[routeConfig] 检测到重复路径:', dupes);
}
}

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

View File

@@ -0,0 +1,85 @@
# 质量问题系统性预防方案
> 日期: 2026-05-08 | 参与者: iven, Claude
> 触发: 4 轮角色测试发现 27 个问题,归纳出 5 种高频模式,分析根因后制定根治方案
## 背景
2026-05-06 至 2026-05-07 进行了 4 轮角色测试5 角色 × 220+ API 项 + 175 前端交互项),共发现 27 个独立问题,已修复 22 个,剩余 5 个待处理。
归纳出 5 种高频模式:
1. 权限配置散落9/27 = 33%
2. 后端类型/状态不一致5/27 = 19%
3. 前后端 API 路径分叉4/27 = 19%
4. 缺少全局容错3/27 = 11%
5. 默认放行模式6/27 = 22%
共性深层问题:**系统缺乏"定义一次,到处生效"的约束机制**。
## 讨论要点
### 方案 1权限注册表单一真相源
**问题**:权限码散落在 4 处handler 字符串 / seed 迁移 SQL / 前端路由 meta / 菜单可见性),每加一个页面需手动同步 4 处。
**方案**:创建 `permissions.yaml` 作为唯一定义源,构建时自动生成:
- Rust 侧权限常量
- seed 迁移 INSERT 语句
- 前端路由权限映射
- 菜单可见性配置
**优先级**P2投入 3-5 天,最彻底但成本最高)
### 方案 2API 契约自动同步
**问题**:后端 utoipa 生成 OpenAPI spec前端完全不用手动硬编码路径。
**方案**:从 OpenAPI spec 自动生成前端 API 客户端openapi-typescript消除路径拼写错误和类型不匹配。
**优先级**P2投入 2-3 天,需改造 service 层)
### 方案 3状态机 + 类型安全 Seed
**问题**:告警 seed 数据 `status=active`,代码状态机只认 `pending` → 全部操作 422`escalation_level` INT2 vs Entity i32 → 查询 500。
**方案**
- 3a状态机定义从代码注释提升为一等公民宏定义 + 编译期校验)
- 3bSeed 数据校验测试(验证 seed 的 status 字段值在合法状态列表中)
**优先级**3b P1投入 1 天3a P2投入 2-3 天)
### 方案 4聚合接口防御性模式
**问题**:仪表盘统计 API 内部查 5 个子指标,任一失败 → 整个 500。
**方案**:在 `erp-core` 封装 `safe_aggregate` 工具函数,单个子查询失败返回零值/默认值。
**优先级**P1投入半天
### 方案 5默认拒绝 + 强制守卫
**问题**:路由守卫和菜单渲染默认放行,开发者需要主动加限制 → 容易遗漏。
**方案**
- 5a`createProtectedRoute()` wrapper — 不声明权限码则无法创建路由(编译期强制)
- 5bCI 扫描未注册权限的后端端点
- 5c新端点 lint 检查
**优先级**P05a 半天5b 1-2 天)
## 实施优先级
| 优先级 | 方案 | 投入 | 收益 |
|--------|------|------|------|
| P0 | 5a — `createProtectedRoute` wrapper | 半天 | 杜绝路由守卫遗漏 |
| P0 | 5b — CI 扫描未注册权限的端点 | 1-2 天 | 立即堵住权限遗漏 |
| P1 | 4 — `safe_aggregate` 工具函数 | 半天 | 杜绝聚合接口 500 |
| P1 | 3b — Seed 数据状态校验测试 | 1 天 | 杜绝 seed/代码不一致 |
| P2 | 2 — OpenAPI 生成前端客户端 | 2-3 天 | 杜绝路径分叉 |
| P2 | 1 — 权限注册表 YAML | 3-5 天 | 统一权限管理 |
## 结论
核心思路:让"对的事情"变成"唯一能做的事情"。靠人记得加权限、记得同步路径、记得容错,注定会遗漏。把约束嵌入构建流程和代码框架,错自然就犯不了。
先做 P0+P12-3 天见效P2 作为下一阶段质量基建。

178
tools/check_permissions.py Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
权限一致性校验脚本 — CI 守门
检查两个维度:
1. Handler 中的 require_permission() 权限码是否在模块 permissions() 中声明
2. 前端 routeConfig.ts 中的权限码是否在后端模块中声明
用法:
python tools/check_permissions.py
# 或在 CI 中:
python tools/check_permissions.py --ci (发现问题时 exit 1)
"""
import re
import sys
import os
from pathlib import Path
from collections import defaultdict
ROOT = Path(__file__).resolve().parent.parent
CRATES_DIR = ROOT / "crates"
WEB_DIR = ROOT / "apps" / "web" / "src"
# ============================================================================
# 1. 提取后端 handler 中的权限码require_permission 调用)
# ============================================================================
def extract_handler_permissions():
"""从 Rust handler 文件中提取 require_permission() 的权限码"""
permissions = defaultdict(list) # permission -> [file:line]
pattern = re.compile(r'require_permission\s*\(\s*&ctx\s*,\s*"([^"]+)"\s*\)')
pattern2 = re.compile(r'require_permission\s*\(\s*ctx\s*,\s*"([^"]+)"\s*\)')
pattern_any = re.compile(r'require_any_permission\s*\(\s*&ctx\s*,\s*&\[([^\]]+)\]')
pattern_any2 = re.compile(r'require_any_permission\s*\(\s*ctx\s*,\s*&\[([^\]]+)\]')
for rs_file in CRATES_DIR.rglob("*.rs"):
if "test" in str(rs_file) and "tests/" in str(rs_file):
continue
try:
content = rs_file.read_text(encoding="utf-8")
except Exception:
continue
rel = rs_file.relative_to(ROOT)
for i, line in enumerate(content.splitlines(), 1):
for m in pattern.finditer(line):
permissions[m.group(1)].append(f"{rel}:{i}")
for m in pattern2.finditer(line):
permissions[m.group(1)].append(f"{rel}:{i}")
for m in pattern_any.finditer(line):
for p in re.findall(r'"([^"]+)"', m.group(1)):
permissions[p].append(f"{rel}:{i}")
for m in pattern_any2.finditer(line):
for p in re.findall(r'"([^"]+)"', m.group(1)):
permissions[p].append(f"{rel}:{i}")
return permissions
# ============================================================================
# 2. 提取模块 permissions() 声明
# ============================================================================
def extract_module_permissions():
"""从各模块的 module.rs 中提取 permissions() 声明的权限码"""
declared = set()
# permissions() 返回 vec![PermissionDescriptor { ... code: "xxx".into() ... }]
pattern = re.compile(r'code\s*:\s*"([^"]+)"\s*\.into\(\)')
for module_file in CRATES_DIR.rglob("module.rs"):
try:
content = module_file.read_text(encoding="utf-8")
except Exception:
continue
for m in pattern.finditer(content):
declared.add(m.group(1))
# 也检查 seed.rs 中的 DEFAULT_PERMISSIONS
seed_file = CRATES_DIR / "erp-auth" / "src" / "service" / "seed.rs"
if seed_file.exists():
try:
content = seed_file.read_text(encoding="utf-8")
for m in re.finditer(r'code\s*:\s*"([^"]+)"', content):
declared.add(m.group(1))
except Exception:
pass
return declared
# ============================================================================
# 3. 提取前端 routeConfig.ts 中的权限码
# ============================================================================
def extract_frontend_permissions():
"""从前端 routeConfig.ts 中提取权限码"""
config_file = WEB_DIR / "routeConfig.ts"
permissions = []
if not config_file.exists():
# 旧模式:从 App.tsx 中提取
config_file = WEB_DIR / "App.tsx"
try:
content = config_file.read_text(encoding="utf-8")
except Exception:
return permissions
for m in re.finditer(r"permissions:\s*\[([^\]]+)\]", content):
for p in re.findall(r"'([^']+)'", m.group(1)):
permissions.append(p)
return set(permissions)
# ============================================================================
# 主逻辑
# ============================================================================
def main():
is_ci = "--ci" in sys.argv
errors = []
handler_perms = extract_handler_permissions()
module_perms = extract_module_permissions()
frontend_perms = extract_frontend_permissions()
print("=" * 60)
print("权限一致性校验报告")
print("=" * 60)
# --- 检查 1: Handler 权限码是否在模块中声明 ---
print(f"\n[检查 1] Handler 权限码 → 模块声明 (handler: {len(handler_perms)} 个, module: {len(module_perms)} 个)")
undeclared = []
for perm, locations in sorted(handler_perms.items()):
if perm not in module_perms:
undeclared.append((perm, locations))
if undeclared:
print(f" ❌ 发现 {len(undeclared)} 个未声明的权限码:")
for perm, locations in undeclared:
print(f" - {perm} (使用于 {locations[0]})")
errors.append(f"handler 中有 {len(undeclared)} 个权限码未在模块 permissions() 中声明")
else:
print(" ✅ 所有 handler 权限码均已声明")
# --- 检查 2: 前端权限码是否在后端声明 ---
print(f"\n[检查 2] 前端权限码 → 后端声明 (frontend: {len(frontend_perms)} 个)")
frontend_only = frontend_perms - module_perms - handler_perms.keys()
if frontend_only:
print(f" ⚠️ 前端引用了 {len(frontend_only)} 个后端未声明的权限码:")
for p in sorted(frontend_only):
print(f" - {p}")
errors.append(f"前端引用了 {len(frontend_only)} 个后端未声明的权限码")
else:
print(" ✅ 所有前端权限码后端均已声明")
# --- 检查 3: 模块声明了但 handler 未使用的权限码(信息性) ---
used_in_handler = set(handler_perms.keys())
declared_but_unused = module_perms - used_in_handler
print(f"\n[信息] 已声明但 handler 未直接使用的权限码: {len(declared_but_unused)}")
if declared_but_unused:
for p in sorted(declared_but_unused):
print(f" - {p}")
print("\n" + "=" * 60)
if errors:
print("❌ 校验失败:")
for e in errors:
print(f" - {e}")
if is_ci:
sys.exit(1)
else:
print("✅ 权限一致性校验通过")
if __name__ == "__main__":
main()