diff --git a/.lintstagedrc.js b/.lintstagedrc.js index b92a2cb..a2b0825 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -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', ], diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 75396ce..5f4ee35 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ; } @@ -98,49 +90,6 @@ function ForbiddenPage() { ); } -const ROUTE_PERMISSIONS: Record = { - '/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); diff --git a/apps/web/src/routeConfig.ts b/apps/web/src/routeConfig.ts new file mode 100644 index 0000000..d4baacd --- /dev/null +++ b/apps/web/src/routeConfig.ts @@ -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 = 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); + } +} diff --git a/crates/erp-auth/src/service/seed.rs b/crates/erp-auth/src/service/seed.rs index b0a158c..f9359ab 100644 --- a/crates/erp-auth/src/service/seed.rs +++ b/crates/erp-auth/src/service/seed.rs @@ -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. diff --git a/crates/erp-core/src/aggregate.rs b/crates/erp-core/src/aggregate.rs new file mode 100644 index 0000000..d6ac05c --- /dev/null +++ b/crates/erp-core/src/aggregate.rs @@ -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( + fut: impl Future>, + label: &str, +) -> T { + match fut.await { + Ok(v) => v, + Err(e) => { + tracing::warn!("聚合子查询 [{label}] 失败,使用默认值: {e}"); + T::default() + } + } +} diff --git a/crates/erp-core/src/lib.rs b/crates/erp-core/src/lib.rs index 346c109..e7859c0 100644 --- a/crates/erp-core/src/lib.rs +++ b/crates/erp-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod aggregate; pub mod audit; pub mod audit_service; pub mod crypto; diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index 2604924..8360500 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -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, diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index 33a4a52..7080f84 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -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, diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index 3e9f7fd..40edc48 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -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:?}" + ); + } + } + } } diff --git a/docs/discussions/2026-05-08-quality-systemic-prevention.md b/docs/discussions/2026-05-08-quality-systemic-prevention.md new file mode 100644 index 0000000..378449d --- /dev/null +++ b/docs/discussions/2026-05-08-quality-systemic-prevention.md @@ -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 天,最彻底但成本最高) + +### 方案 2:API 契约自动同步 + +**问题**:后端 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:状态机定义从代码注释提升为一等公民(宏定义 + 编译期校验) +- 3b:Seed 数据校验测试(验证 seed 的 status 字段值在合法状态列表中) + +**优先级**:3b P1(投入 1 天),3a P2(投入 2-3 天) + +### 方案 4:聚合接口防御性模式 + +**问题**:仪表盘统计 API 内部查 5 个子指标,任一失败 → 整个 500。 + +**方案**:在 `erp-core` 封装 `safe_aggregate` 工具函数,单个子查询失败返回零值/默认值。 + +**优先级**:P1(投入半天) + +### 方案 5:默认拒绝 + 强制守卫 + +**问题**:路由守卫和菜单渲染默认放行,开发者需要主动加限制 → 容易遗漏。 + +**方案**: +- 5a:`createProtectedRoute()` wrapper — 不声明权限码则无法创建路由(编译期强制) +- 5b:CI 扫描未注册权限的后端端点 +- 5c:新端点 lint 检查 + +**优先级**:P0(5a 半天,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+P1(2-3 天见效),P2 作为下一阶段质量基建。 diff --git a/tools/check_permissions.py b/tools/check_permissions.py new file mode 100644 index 0000000..e655e35 --- /dev/null +++ b/tools/check_permissions.py @@ -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()