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