Files
Edu/apps/portal-shell/src/shared/lib/route-permissions.ts
SpecialX 8ab4fae9d5 feat(portal-shell): lesson-plans 模块 6 页迁移(教师域 §9.1 B2)
§9.1 line 629 教师域 lesson-plans 行:
- /lesson-plans (列表) / /new (表单) / /library (教案库)
- /calendar (日历) / /heatmap (热力图) / /[planId]/edit (工作台)
契约:全  schema 无 → MSW 兜底 + @contract-pending

新增文件:
- src/lib/api/lesson-plans.ts (8 hooks)
- src/lib/api/operations/lesson-plans.graphql.ts (8 documents)
- src/features/teacher/lesson-plans/ (clients + transformations + tests)
- src/app/shell/teacher/lesson-plans/ (6 page.tsx + loading.tsx + error.tsx)

修改文件:
- src/mocks/graphql-data.ts (mock 数据 + handler cases)
- src/messages/{zh-CN,en}.json (lessonPlans i18n 命名空间)
- src/lib/api/{index,operations/index}.ts (导出 lesson-plans)
- src/shared/lib/route-permissions.ts (lesson-plans 路由权限声明)
- scripts/check-page-count.ts (baseline 31 → 37)

DoD 验收(§11.3 11 项):
- typecheck 0 errors
- lint 0 errors
- vitest 405 tests passed (新增 48 tests)
- lint:tokens 0 errors
- check:pages 37 PASS
- route-permissions 已声明
- 三态(loading/error/empty)齐备
- @contract-pending + MSW 兜底
- i18n zh-CN + en 同步

关联:ARCHITECTURE.md §5.3 / §5.4 / §9.1 / §10 P2 / §11.3 / §11.4
契约工单:docs/architecture/issues/contracts/core-edu_contract.md
2026-07-22 19:59:22 +08:00

566 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 路由权限配置表(对齐 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_PERMISSIONSNext.js API Route/api/*
*
* 三层安全边界portal-shell README v2.0 §3.3
* - L1 角色门禁requiredRoles4 角色之一)
* - L2 权限点门禁requiredPermissionsAND 语义,必须全部满足)
* - 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"],
},
// P1-1列表页根路由无尾斜杠需在 EXACT 表登记,
// 否则 middleware checkRoutePermission 落入 /shell/** catch-all 拒绝
"/shell/admin/announcements": {
requiredRoles: ["admin"],
requiredPermissions: ["ANNOUNCEMENT_MANAGE"],
},
"/shell/admin/classes": {
requiredRoles: ["admin"],
anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"],
},
// ── 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"],
},
// P1-1列表页根路由无尾斜杠需在 EXACT 表登记,
// 与 PREFIX 表(带尾斜杠,匹配子路由如 /shell/teacher/exams/123互补
"/shell/teacher/exams": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["EXAM_READ", "EXAM_CREATE", "EXAM_UPDATE", "EXAM_GRADE"],
},
"/shell/teacher/homework": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"],
},
"/shell/teacher/grades": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"],
},
"/shell/teacher/attendance": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"],
},
"/shell/teacher/diagnostics": {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_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"],
},
},
// 教案管理P2 迁移)
{
prefix: "/shell/teacher/lesson-plans/",
config: {
requiredRoles: ["teacher", "admin"],
anyOfPermissions: [
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
],
},
},
// 成绩录入
{
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"],
},
},
// P1-3dev 模板预览页(仅登录可访问,运行时由 notFound() 拒绝生产访问)
// ARCHITECTURE.md §10 P1-3/shell/dev/templates/* 仅 dev 可见
// 注Next.js 私有文件夹以 _ 开头会被排除路由,故使用 `dev` 而非 `_dev`
{
prefix: "/shell/dev/",
config: {}, // 空 config = 仅校验登录身份
},
];
/**
* 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 通常需要更严格的权限校验,因为它们直接操作数据。
*
* P0-2ARCHITECTURE.md §6.2 §3.4 V3-A3新增 auth/graphql 公开端点。
*/
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
// 错误上报端点:所有登录用户可访问(空 config 表示登录即可)
"/api/log": {},
// 健康检查:公开
"/api/healthz": {},
"/api/health": {},
"/api/ready": {},
// 认证端点:公开(未登录也要能调登录接口)
"/api/auth/login": {},
"/api/auth/logout": {},
// GraphQL 同域代理:登录即可(细粒度由后端 resolver 把关)
"/api/graphql": {},
};
/**
* 5. 公共路由白名单P0-2ARCHITECTURE.md §6.2 §3.4 V3-A3 / §11.7 红线 #5
*
* 这些路由允许匿名访问(登录前/无身份也能访问)。
* middleware 对白名单路由跳过身份校验,直接放行。
*
* 注意:白名单外的路由,未登录访问 → middleware 重定向到 /login。
*/
export const PUBLIC_ROUTES: readonly string[] = [
"/",
"/login",
"/shell/forbidden",
"/api/health",
"/api/healthz",
"/api/ready",
"/api/log",
"/api/auth/login",
"/api/auth/logout",
"/api/graphql",
];
/**
* 校验权限配置的合法性(开发时辅助)
*
* 检查所有声明的权限点是否在 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 {
// 0. 公共路由白名单优先(含 /shell/forbidden 自身,避免循环重定向)
if (PUBLIC_ROUTES.includes(pathname)) {
return { allowed: true, matchedPath: "PUBLIC" };
}
// 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 路由默认拒绝fail-closed§11.7 红线 #5
return {
allowed: false,
reason: "no_config",
};
}
// 5. /shell/** 下未登记路由 → 默认拒绝fail-closed§3.4 V3-A3 / §11.7 红线 #5
// 防止"幽灵路由"绕过门禁;新增路由必须显式登记到 EXACT/PREFIX/DASHBOARD 表
if (pathname.startsWith("/shell/") || pathname === "/shell") {
return {
allowed: false,
reason: "no_config",
};
}
// 6. 其他未匹配路由(如 /favicon.ico / 静态资源)默认放行
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;
}