功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
159 lines
11 KiB
Markdown
159 lines
11 KiB
Markdown
---
|
||
title: 角色与权限
|
||
description: 权限矩阵、科目维度的收口规则,以及"前端隐藏 ≠ 权限"这条纪律。
|
||
---
|
||
|
||
# 角色与权限
|
||
|
||
## 矩阵在哪
|
||
|
||
**唯一一份**在 `packages/shared/src/permissions.ts`:
|
||
|
||
- 后端 `lib/access.ts` 复用它做断言;
|
||
- 两端前端用它隐藏入口;
|
||
- 验收脚本按它逐条断言。
|
||
|
||
所以不存在"前端一套后端一套"。改权限就改这一个文件(改完记得 `pnpm build:shared`)。
|
||
|
||
## 角色判定
|
||
|
||
角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。
|
||
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
|
||
|
||
| 角色 | 判定方式 | 可见范围 |
|
||
| --------------------------- | ------------------------------------------ | ------------------------------------ |
|
||
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
||
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
||
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
|
||
| `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) |
|
||
| `NONE` | 与这个班没有任何关系 | 什么都看不到 |
|
||
|
||
判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师),
|
||
不是那张已经没有的 `ClassTeacher` 协作表。
|
||
|
||
## 权限矩阵
|
||
|
||
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
|
||
| -------------------------------------------------------- | :----: | :----------: | :------: | :----: |
|
||
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
|
||
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
|
||
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
||
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
|
||
| 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
|
||
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
|
||
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
||
| 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
|
||
| 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
|
||
| 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
|
||
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
|
||
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
|
||
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
|
||
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
|
||
|
||
## 科目维度的收口(本次重构的核心规则)
|
||
|
||
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
|
||
|
||
| 情形 | 谁可以写 |
|
||
| --------------------------------------- | ------------------------------------------ |
|
||
| 管理员 | 全部 |
|
||
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||
| 没挂科目(`courseId` 为空) | 本班**班主任** |
|
||
| ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** |
|
||
|
||
于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
|
||
「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
|
||
|
||
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
|
||
作业 / 成绩 / 叫人三处写入统一走它。
|
||
|
||
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
|
||
|
||
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
|
||
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(39 个,形如 `domain:action`,
|
||
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
|
||
|
||
```ts
|
||
requirePermission('notification:publish') // 需要全部列出的键
|
||
requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可
|
||
```
|
||
|
||
**生效规则(四句话)**:
|
||
|
||
| 账号 | 有效权限 |
|
||
| --------------------------------------- | -------------------------------------------------------------- |
|
||
| `ADMIN` | 全集;其中 `permission:manage` / `system:manage` **不可被剥夺** |
|
||
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
|
||
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
|
||
| 没有被分配任何分组 | 回落到角色默认模板 `defaultPermissionsForRole(role)` |
|
||
|
||
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
|
||
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
|
||
(不缓存),因此管理员改完分组**立刻生效**,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。
|
||
|
||
**管理接口**(`packages/server/src/modules/permissions/`):
|
||
|
||
| 方法与路径 | 权限要求 | 说明 |
|
||
| --------------------------------- | -------------------- | -------------------------------------------------------- |
|
||
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
|
||
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
|
||
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 |
|
||
| `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;拒绝给 ADMIN / CLASS_DEVICE 分配 |
|
||
|
||
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
|
||
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
|
||
**具体到哪个班、哪个科目**仍然由 `assertCanManageSubjectContent` 判(下一节)。
|
||
否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。
|
||
|
||
## 实现要点
|
||
|
||
**路由级只做权限过滤**:`requirePermission('…')`(需要列出的键全有)或
|
||
`requireAnyPermission('…')`(任意一个即可)。改造前这里是 `requireRole('ADMIN','TEACHER')`,
|
||
按键目录里的 `PERMISSION_DEFAULT_ROLES` 逐条映射过来,因此**默认行为没有变化**。
|
||
403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。
|
||
|
||
**班级维度的判断在服务层**:`src/lib/access.ts` 提供
|
||
|
||
```
|
||
assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster
|
||
assertCanManageSchedule / assertCanPublishNotification
|
||
assertCanManageGrades / assertCanManageSubjectContent
|
||
```
|
||
|
||
**列表数据用 `resolveClassScope()` 收敛**(`classId in [...]`),单条接口直接断言;
|
||
越权一律 403。
|
||
|
||
**鉴权中间件会回查数据库**(`middleware/auth.ts`):JWT 校验之后重新读一次账号,
|
||
所以改了角色 / 班级关系会**立即生效**,不用等令牌过期。班级端会话则回查 `Class` 表。
|
||
|
||
**班级端的成绩查询开关**:`Class.studentGradeQueryEnabled`(默认开启)。
|
||
关闭后班级端调 `GET /grades/student/:studentNo` 一律 403,教师端不受影响 ——
|
||
这就是需求「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
|
||
|
||
## 前端隐藏 ≠ 权限
|
||
|
||
前端的隐藏只是"别让人看见入口":
|
||
|
||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
|
||
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
|
||
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
|
||
|
||
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
|
||
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
|
||
再问 `auth.can(key)`(管理员短路为 true),不通过就 **fail-closed** 回仪表盘;
|
||
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
|
||
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
|
||
|
||
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
|
||
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
|
||
|
||
> 新增视图时别忘了两处:路由的 `meta.roles` + 服务层对应 service 里的 `assertCanXxx`。
|
||
> 只做其中一个都等于没做。
|
||
|
||
## 学生没有账号的两个反向约束
|
||
|
||
1. **学生不能登录,而且根本不存在可以登录的学生账号**。`User` 表里没有学生行,
|
||
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
|
||
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
|
||
新建学生也不设密码 —— `Student` 表里压根没有那一列。
|