feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
470
apps/portal-shell/src/shared/lib/route-permissions.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 路由权限配置表(对齐 CICD 项目 route-permissions.ts)
|
||||
*
|
||||
* 4 张表按优先级顺序匹配(精确 > 前缀 > 仪表盘 > API):
|
||||
* 1. EXACT_ROUTE_PERMISSIONS:精确路由(如 /shell/admin/users)
|
||||
* 2. PREFIX_ROUTE_PERMISSIONS:前缀路由(如 /shell/admin/*)
|
||||
* 3. DASHBOARD_ROUTE_PERMISSIONS:仪表盘路由(按角色分发)
|
||||
* 4. API_ROUTE_PERMISSIONS:Next.js API Route(/api/*)
|
||||
*
|
||||
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||
* - L1 角色门禁:requiredRoles(4 角色之一)
|
||||
* - L2 权限点门禁:requiredPermissions(AND 语义,必须全部满足)
|
||||
* - L3 数据范围:运行时由插件/page 内 usePermission 校验
|
||||
*
|
||||
* 使用方式(middleware / page / layout):
|
||||
* ```ts
|
||||
* import { checkRoutePermission } from "@/shared/lib/route-permissions";
|
||||
*
|
||||
* const result = checkRoutePermission(pathname, userBitmap, userRole);
|
||||
* if (!result.allowed) redirect("/shell/forbidden");
|
||||
* ```
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §3.3、project_rules §3.1(禁止 role === "xxx" 硬编码)
|
||||
*/
|
||||
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
import {
|
||||
hasAllPermissionsInBitmap,
|
||||
hasAnyPermissionInBitmap,
|
||||
isValidPermission,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
|
||||
/**
|
||||
* 路由权限配置项
|
||||
*/
|
||||
export interface RoutePermissionConfig {
|
||||
/** 所需角色(任一满足即可;空数组表示不限制角色) */
|
||||
requiredRoles?: Role[];
|
||||
/**
|
||||
* 所需权限点(AND 语义,必须全部满足)
|
||||
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
/**
|
||||
* 所需权限点(OR 语义,任一满足即可)
|
||||
* 与 requiredPermissions 同时存在时,先 AND 再 OR
|
||||
*/
|
||||
anyOfPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查结果
|
||||
*/
|
||||
export interface RoutePermissionResult {
|
||||
/** 是否允许访问 */
|
||||
allowed: boolean;
|
||||
/** 拒绝原因(allowed=false 时填充) */
|
||||
reason?: "missing_role" | "missing_permission" | "no_config";
|
||||
/** 匹配到的配置(用于调试) */
|
||||
matchedPath?: string;
|
||||
/** 缺失的权限点(allowed=false 且 reason=missing_permission 时填充) */
|
||||
missingPermissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 精确路由权限表
|
||||
*
|
||||
* 高优先级,pathname 完全匹配时生效。
|
||||
* 适用于功能明确、URL 固定的页面(用户管理、RBAC、审计日志等)。
|
||||
*/
|
||||
export const EXACT_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// ── admin 专属 ────────────────────────────────────────────
|
||||
"/shell/admin/users": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["USER_MANAGE"],
|
||||
},
|
||||
"/shell/admin/roles": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ROLE_MANAGE"],
|
||||
},
|
||||
"/shell/admin/permissions": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PERMISSION_MANAGE"],
|
||||
},
|
||||
"/shell/admin/audit-logs": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["AUDIT_LOG_READ"],
|
||||
},
|
||||
"/shell/admin/school": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["SCHOOL_MANAGE"],
|
||||
},
|
||||
"/shell/admin/plugins": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"],
|
||||
},
|
||||
"/shell/admin/invitation-codes": {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"],
|
||||
},
|
||||
|
||||
// ── teacher 专属 ──────────────────────────────────────────
|
||||
"/shell/teacher/lesson-plans": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: [
|
||||
"LESSON_PLAN_READ",
|
||||
"LESSON_PLAN_CREATE",
|
||||
"LESSON_PLAN_UPDATE",
|
||||
],
|
||||
},
|
||||
"/shell/teacher/question-bank": {
|
||||
requiredRoles: ["teacher"],
|
||||
anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE"],
|
||||
},
|
||||
"/shell/teacher/textbooks": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
requiredPermissions: ["TEXTBOOK_READ"],
|
||||
},
|
||||
"/shell/teacher/scheduling-rules": {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"],
|
||||
},
|
||||
|
||||
// ── student 专属 ──────────────────────────────────────────
|
||||
"/shell/student/error-book": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["ERROR_BOOK_READ"],
|
||||
},
|
||||
"/shell/student/learning-path": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["LEARNING_PATH_READ"],
|
||||
},
|
||||
"/shell/student/electives": {
|
||||
requiredRoles: ["student"],
|
||||
anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_SELECT"],
|
||||
},
|
||||
"/shell/student/ai-tutor": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["AI_TUTOR_USE"],
|
||||
},
|
||||
|
||||
// ── parent 专属 ───────────────────────────────────────────
|
||||
"/shell/parent/children": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["GRADE_READ_CHILD"],
|
||||
},
|
||||
"/shell/parent/leave-approval": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["LEAVE_APPROVAL_MANAGE"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. 前缀路由权限表
|
||||
*
|
||||
* 中优先级,pathname 以指定前缀开头时生效。
|
||||
* 适用于功能集合下的所有子路由(/shell/admin/* /shell/teacher/exams/* 等)。
|
||||
*
|
||||
* 注意:前缀必须以 / 结尾,避免误匹配(如 /shell/admin 不能匹配 /shell/admin-users)。
|
||||
*/
|
||||
export const PREFIX_ROUTE_PERMISSIONS: Array<{
|
||||
prefix: string;
|
||||
config: RoutePermissionConfig;
|
||||
}> = [
|
||||
// admin 区所有子路由默认要求 admin 角色
|
||||
{
|
||||
prefix: "/shell/admin/",
|
||||
config: { requiredRoles: ["admin"] },
|
||||
},
|
||||
// 考试管理
|
||||
{
|
||||
prefix: "/shell/teacher/exams/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: [
|
||||
"EXAM_READ",
|
||||
"EXAM_CREATE",
|
||||
"EXAM_UPDATE",
|
||||
"EXAM_GRADE",
|
||||
],
|
||||
},
|
||||
},
|
||||
// 作业管理
|
||||
{
|
||||
prefix: "/shell/teacher/homework/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
|
||||
},
|
||||
},
|
||||
// 成绩录入
|
||||
{
|
||||
prefix: "/shell/teacher/grades/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
|
||||
},
|
||||
},
|
||||
// 考勤
|
||||
{
|
||||
prefix: "/shell/teacher/attendance/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 班级管理
|
||||
{
|
||||
prefix: "/shell/admin/classes/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 学情诊断
|
||||
{
|
||||
prefix: "/shell/teacher/diagnostics/",
|
||||
config: {
|
||||
requiredRoles: ["teacher", "admin"],
|
||||
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"],
|
||||
},
|
||||
},
|
||||
// 公告管理
|
||||
{
|
||||
prefix: "/shell/admin/announcements/",
|
||||
config: {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 3. 仪表盘路由权限表
|
||||
*
|
||||
* 低优先级,按角色分发的根仪表盘。
|
||||
* 当 pathname 不匹配前两张表时,检查是否为角色仪表盘根路径。
|
||||
*/
|
||||
export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
||||
string,
|
||||
RoutePermissionConfig
|
||||
> = {
|
||||
"/shell/admin": {
|
||||
requiredRoles: ["admin"],
|
||||
requiredPermissions: ["DASHBOARD_ADMIN_READ"],
|
||||
},
|
||||
"/shell/teacher": {
|
||||
requiredRoles: ["teacher"],
|
||||
requiredPermissions: ["DASHBOARD_TEACHER_READ"],
|
||||
},
|
||||
"/shell/student": {
|
||||
requiredRoles: ["student"],
|
||||
requiredPermissions: ["DASHBOARD_STUDENT_READ"],
|
||||
},
|
||||
"/shell/parent": {
|
||||
requiredRoles: ["parent"],
|
||||
requiredPermissions: ["DASHBOARD_PARENT_READ"],
|
||||
},
|
||||
// 通用仪表盘
|
||||
"/shell": {
|
||||
requiredPermissions: ["DASHBOARD_READ"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 4. Next.js API Route 权限表
|
||||
*
|
||||
* 用于 /api/* 路径的权限校验。
|
||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||
*/
|
||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// 错误上报端点:所有登录用户可访问
|
||||
"/api/log": {},
|
||||
// 健康检查:公开
|
||||
"/api/healthz": {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验权限配置的合法性(开发时辅助)
|
||||
*
|
||||
* 检查所有声明的权限点是否在 PERMISSION_BITMAP_ORDER 中。
|
||||
* 在 dev 模式下打 warning,生产构建时可阻断。
|
||||
*
|
||||
* @returns 非法权限点列表(空数组表示全部合法)
|
||||
*/
|
||||
export function validateRoutePermissionConfigs(): string[] {
|
||||
const invalid: string[] = [];
|
||||
const allConfigs: Array<{ source: string; config: RoutePermissionConfig }> = [
|
||||
...Object.entries(EXACT_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `EXACT:${path}`,
|
||||
config,
|
||||
})),
|
||||
...PREFIX_ROUTE_PERMISSIONS.map(({ prefix, config }) => ({
|
||||
source: `PREFIX:${prefix}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(DASHBOARD_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `DASHBOARD:${path}`,
|
||||
config,
|
||||
})),
|
||||
...Object.entries(API_ROUTE_PERMISSIONS).map(([path, config]) => ({
|
||||
source: `API:${path}`,
|
||||
config,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const { source, config } of allConfigs) {
|
||||
for (const perm of config.requiredPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:requiredPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
for (const perm of config.anyOfPermissions ?? []) {
|
||||
if (!isValidPermission(perm)) {
|
||||
invalid.push(`${source}:anyOfPermissions:${perm}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查主函数
|
||||
*
|
||||
* 按优先级顺序匹配 4 张表,返回检查结果。
|
||||
*
|
||||
* @param pathname 当前路径(如 /shell/admin/users)
|
||||
* @param userBitmap 用户权限位图(base36 字符串,从 JWT cookie 解析)
|
||||
* @param userRole 用户角色
|
||||
* @returns 检查结果,allowed=true 表示放行
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = checkRoutePermission("/shell/admin/users", "abc123", "admin");
|
||||
* if (!result.allowed) {
|
||||
* redirect("/shell/forbidden");
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function checkRoutePermission(
|
||||
pathname: string,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): RoutePermissionResult {
|
||||
// 1. 匹配精确路由
|
||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||
if (exactConfig) {
|
||||
return evaluateConfig(exactConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 2. 匹配前缀路由
|
||||
for (const { prefix, config } of PREFIX_ROUTE_PERMISSIONS) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
return evaluateConfig(config, userBitmap, userRole, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 匹配仪表盘路由
|
||||
const dashboardConfig = DASHBOARD_ROUTE_PERMISSIONS[pathname];
|
||||
if (dashboardConfig) {
|
||||
return evaluateConfig(dashboardConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
|
||||
// 4. 匹配 API 路由
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const apiConfig = API_ROUTE_PERMISSIONS[pathname];
|
||||
if (apiConfig) {
|
||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
// 未配置的 API 路由默认拒绝
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个权限配置
|
||||
*/
|
||||
function evaluateConfig(
|
||||
config: RoutePermissionConfig,
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
matchedPath: string,
|
||||
): RoutePermissionResult {
|
||||
// L1 角色门禁
|
||||
if (config.requiredRoles && config.requiredRoles.length > 0) {
|
||||
if (!config.requiredRoles.includes(userRole)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_role",
|
||||
matchedPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - AND 语义
|
||||
const missingPermissions: string[] = [];
|
||||
if (config.requiredPermissions && config.requiredPermissions.length > 0) {
|
||||
for (const perm of config.requiredPermissions) {
|
||||
// 使用 hasAllPermissionsInBitmap 不合适(它返回 boolean 不告知哪些缺失)
|
||||
// 这里手动遍历以便收集缺失项
|
||||
const bit = hasPermissionInBitmapSimple(userBitmap, perm);
|
||||
if (!bit) {
|
||||
missingPermissions.push(perm);
|
||||
}
|
||||
}
|
||||
if (missingPermissions.length > 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// L2 权限点门禁 - OR 语义
|
||||
if (config.anyOfPermissions && config.anyOfPermissions.length > 0) {
|
||||
if (!hasAnyPermissionInBitmap(userBitmap, config.anyOfPermissions)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "missing_permission",
|
||||
matchedPath,
|
||||
missingPermissions: config.anyOfPermissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, matchedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版单权限检查(避免循环依赖 hasAllPermissionsInBitmap)
|
||||
*
|
||||
* 直接调用 hasAllPermissionsInBitmap 检查单个权限点
|
||||
*/
|
||||
function hasPermissionInBitmapSimple(
|
||||
bitmap: string,
|
||||
permission: string,
|
||||
): boolean {
|
||||
return hasAllPermissionsInBitmap(bitmap, [permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查用户是否拥有所有指定路由的访问权限
|
||||
*
|
||||
* 用于侧边栏导航项过滤:一次性检查多个路由,避免重复调用。
|
||||
*
|
||||
* @param paths 路径列表
|
||||
* @param userBitmap 用户权限位图
|
||||
* @param userRole 用户角色
|
||||
* @returns 路径 → 是否允许 的映射
|
||||
*/
|
||||
export function batchCheckRoutePermission(
|
||||
paths: readonly string[],
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const path of paths) {
|
||||
result[path] = checkRoutePermission(path, userBitmap, userRole).allowed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user