Files
ClassHelper/docs/dev/permissions.md
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

159 lines
11 KiB
Markdown
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.
---
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` 表里压根没有那一列。