/** * 权限位图编解码(对齐 CICD 项目 permission-bitmap.ts) * * 核心价值:将 N 个权限点压缩为 base36 字符串,JWT cookie 体积减少 ~99%。 * - 67 权限点数组(JSON ~1.1KB)→ base36 字符串(~14 字符) * - Edge Runtime 单点检查 hasPermissionInBitmap 无需完整解码 * * 关键约束(不可破坏): * - PERMISSION_BITMAP_ORDER 顺序一经确定不可变,新增权限只能追加末尾 * - 因 Number 仅支持 53 bit 精度,使用 BigInt 实现 * - 未知权限静默忽略,无效字符返回空数组 * * 编码原理: * - 每个权限点对应一个 bit 位(按 ORDER 数组下标) * - permissions 数组 → BigInt(每个有权限的 bit 置 1)→ base36 字符串 * - 解码:base36 → BigInt → 遍历 ORDER,bit 为 1 的权限加入结果 * * 关联:project_rules §3.1(前端禁止 role === "xxx" 硬编码)、 * portal-shell README v2.0 §3.3 三层安全边界 */ /** * 权限点位图顺序(一经确定不可变,新增只能追加末尾) * * 命名规范:`_`(F7 裁决) * 数据范围后缀:`_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`) * * 注意:顺序变更会破坏所有已签发的 JWT cookie,只能在版本升级时追加。 */ export const PERMISSION_BITMAP_ORDER = [ // ── 仪表盘(5)───────────────────────────────────────── "DASHBOARD_ADMIN_READ", "DASHBOARD_TEACHER_READ", "DASHBOARD_STUDENT_READ", "DASHBOARD_PARENT_READ", "DASHBOARD_READ", // ── 用户管理(4)────────────────────────────────────── "USER_MANAGE", "USER_CREATE", "USER_UPDATE", "USER_DELETE", // ── 角色权限(4)────────────────────────────────────── "ROLE_READ", "ROLE_MANAGE", "PERMISSION_READ", "PERMISSION_MANAGE", // ── 审计与邀请(3)──────────────────────────────────── "AUDIT_LOG_READ", "INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE", // ── 学校设置(2)────────────────────────────────────── "SCHOOL_READ", "SCHOOL_MANAGE", // ── 班级与年级(4)──────────────────────────────────── "CLASS_READ", "CLASS_MANAGE", "GRADE_READ", "GRADE_MANAGE", // ── 考试(5)────────────────────────────────────────── "EXAM_READ", "EXAM_CREATE", "EXAM_UPDATE", "EXAM_DELETE", "EXAM_GRADE", // ── 作业(4)────────────────────────────────────────── "HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_SUBMIT", "HOMEWORK_GRADE", // ── 成绩(4 + 1 占位)───────────────────────────────── // GRADE_READ 曾在此重复定义(ARCHITECTURE.md §3.4 V3-A3 P0-6 去重)。 // 索引 31 标记为 _RESERVED_31 永不复用,避免破坏已签发 JWT 的 bit 位语义。 "_RESERVED_31", "GRADE_READ_OWN", "GRADE_READ_CHILD", "GRADE_RECORD_MANAGE", "GRADE_RECORD_READ", // ── 考勤(3)────────────────────────────────────────── "ATTENDANCE_READ", "ATTENDANCE_MANAGE", "ATTENDANCE_RECORD", // ── 课表与排课(4)──────────────────────────────────── "SCHEDULE_READ", "SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE", // ── 备课与教材(5)──────────────────────────────────── "LESSON_PLAN_READ", "LESSON_PLAN_CREATE", "LESSON_PLAN_UPDATE", "LESSON_PLAN_DELETE", "TEXTBOOK_READ", // ── 题库(4)────────────────────────────────────────── "QUESTION_READ", "QUESTION_CREATE", "QUESTION_UPDATE", "QUESTION_DELETE", // ── 学情诊断(2)────────────────────────────────────── "DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE", // ── 选修课(3)──────────────────────────────────────── "ELECTIVE_READ", "ELECTIVE_MANAGE", "ELECTIVE_SELECT", // ── 错题本与学习路径(2)────────────────────────────── "ERROR_BOOK_READ", "LEARNING_PATH_READ", // ── AI 辅导(2)─────────────────────────────────────── "AI_CHAT", "AI_TUTOR_USE", // ── 公告与消息(4)──────────────────────────────────── "ANNOUNCEMENT_READ", "ANNOUNCEMENT_MANAGE", "MESSAGE_READ", "MESSAGE_SEND", // ── 请假(2)────────────────────────────────────────── "LEAVE_REQUEST_CREATE", "LEAVE_APPROVAL_MANAGE", // ── 插件与布局(4)──────────────────────────────────── "PLUGIN_REGISTRY_READ", "PLUGIN_REGISTRY_MANAGE", "LAYOUT_TEMPLATE_MANAGE", "ROLE_LAYOUT_MANAGE", ] as const; /** 权限点类型(从 ORDER 数组推导) */ export type Permission = (typeof PERMISSION_BITMAP_ORDER)[number]; /** 权限点 → bit 位映射表(启动时构建一次) */ const PERMISSION_BIT_INDEX: ReadonlyMap = new Map( PERMISSION_BITMAP_ORDER.map((perm, idx) => [perm, 1n << BigInt(idx)]), ); /** * 将权限点数组编码为 base36 字符串 * * @example * encodePermissionsBitmap(["USER_MANAGE", "ROLE_READ"]) * // => "j" (前 14 个权限点中 USER_MANAGE=bit5, ROLE_READ=bit9 → 0b10100100000 → base36="j") */ export function encodePermissionsBitmap( permissions: readonly string[], ): string { let bits = 0n; for (const perm of permissions) { const bit = PERMISSION_BIT_INDEX.get(perm); if (bit !== undefined) { bits |= bit; } // 未知权限静默忽略 } return bits.toString(36); } /** * 将 base36 字符串解码为权限点数组 * * 容错:无效字符返回空数组,未知 bit 位静默忽略 */ export function decodePermissionsBitmap(bitmap: string): Permission[] { if (!bitmap || !/^[0-9a-z]+$/.test(bitmap)) { return []; } const bits = parseBase36BigInt(bitmap); if (bits === null) { return []; } const result: Permission[] = []; for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) { const bit = 1n << BigInt(i); if ((bits & bit) !== 0n) { result.push(PERMISSION_BITMAP_ORDER[i]!); } } return result; } /** * 单点权限检查(不解码整个位图,性能优) * * 适用场景:Edge Runtime / middleware / proxy 等高频检查 * * @example * hasPermissionInBitmap("j", "USER_MANAGE") // true * hasPermissionInBitmap("j", "EXAM_CREATE") // false */ export function hasPermissionInBitmap( bitmap: string, permission: string, ): boolean { const bit = PERMISSION_BIT_INDEX.get(permission); if (bit === undefined) { return false; // 未知权限点 } const bits = parseBase36BigInt(bitmap); if (bits === null) { return false; } return (bits & bit) !== 0n; } /** * 批量权限检查(任一满足即 true) */ export function hasAnyPermissionInBitmap( bitmap: string, permissions: readonly string[], ): boolean { return permissions.some((p) => hasPermissionInBitmap(bitmap, p)); } /** * 批量权限检查(全部满足才 true) */ export function hasAllPermissionsInBitmap( bitmap: string, permissions: readonly string[], ): boolean { return permissions.every((p) => hasPermissionInBitmap(bitmap, p)); } /** * 解析 base36 字符串为 BigInt * * base36 字符集:0-9, a-z(小写) * 实现原理:从高位到低位逐字符累加 */ function parseBase36BigInt(str: string): bigint | null { if (!str) return 0n; let result = 0n; const base = 36n; for (let i = 0; i < str.length; i++) { const ch = str[i]!; let digit: number; if (ch >= "0" && ch <= "9") { digit = ch.charCodeAt(0) - 48; // '0' = 48 } else if (ch >= "a" && ch <= "z") { digit = ch.charCodeAt(0) - 87; // 'a' = 97, 97-10=87 } else if (ch >= "A" && ch <= "Z") { digit = ch.charCodeAt(0) - 55; // 'A' = 65, 65-10=55 } else { return null; // 非法字符 } result = result * base + BigInt(digit); } return result; } /** * 校验权限点是否在位图顺序表中(且非 _RESERVED_* 占位) * * 用于开发时校验 manifest 声明的权限点是否合法。 * _RESERVED_* 占位保留 bit 位语义但不视为合法权限点(防误用)。 */ export function isValidPermission(permission: string): boolean { if (!PERMISSION_BIT_INDEX.has(permission)) return false; if (permission.startsWith("_RESERVED_")) return false; return true; }