- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档 涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、 §6 部署、§10 P0-P3 验收清单 - 新增 middleware.ts:认证 + 路由门禁 httpOnly cookie edu_session(JWT)读取 DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production && NEXT_PUBLIC_DEV_MODE=true) 生产模式 jose JWKS RS256 验签(iss/aud 校验) 路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头 /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden - 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false 防止生产环境误开 DEV_MODE 合成身份 - 新增 app/api/auth/login/route.ts + logout/route.ts 登录走 api-gateway /v1/iam/login 设置 httpOnly + Secure + SameSite=Strict cookie - 新增 app/api/graphql/route.ts:同域 GraphQL 代理 转发到 apollo-router,注入 router-authorization 头 - 新增 app/login/page.tsx + login-form.tsx zod 表单校验,next 参数支持 - 新增 app/shell/forbidden/page.tsx:403 页面 - 更新 route-permissions.ts:补全 P0 路由权限映射 - 更新 permission-bitmap.ts(shared-ts):位图编码/解码 - 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理 - 更新 config-fetcher.ts:config-service 直连降级 - 更新 ApolloProvider.tsx:SSR/RSC 兼容 - 更新 eslint.config.js:design-tokens/no-hardcoded-fonts 白名单调整
279 lines
9.5 KiB
TypeScript
279 lines
9.5 KiB
TypeScript
/**
|
||
* 权限位图编解码(对齐 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 三层安全边界
|
||
*/
|
||
|
||
/**
|
||
* 权限点位图顺序(一经确定不可变,新增只能追加末尾)
|
||
*
|
||
* 命名规范:`<RESOURCE>_<ACTION>`(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<string, bigint> = 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;
|
||
}
|