feat(authz): 重写权限模板分配与班级授权边界

拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
LoveChengke committed 2026-10-31 09:40:23 +08:00
1 parent 682818dd4a
commit 66be3a63b1
115 files changed
+4463 -2747

No files matched your search

+7 -7
View File
@@ -28,9 +28,9 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。 - 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。
- 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。 - 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。
- 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。 - 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。
- 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `assertCanManageSubjectContent`。班级端可录作业,不可写成绩;员工还须对应功能权限。 - 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `lib/authz/assert.ts` 的 `assertContentWrite`。班级端可录作业,不可写成绩;员工还须对应功能权限。
- 功能权限取 `lib/permissions.ts` 的 `loadEffectivePermissions()`:已分组取并集;未分组使用数据库对应角色的可编辑默认组;管理员永远保留 `permission:manage/system:manage`;班级端为空集。 - 功能、范围、关系统一由 `lib/authz/` 判定,所有服务显式传权限键。ADMIN 固定全部能力;TEACHER 永久忽略管理员专用键;CLASS_DEVICE 为空集,接口明确允许并校验本班。权限管理仅 ADMIN。
- 岗位组不新增 `User.role`。班主任/教师/级长/部长仍为 TEACHER;范围在 `PermissionGroup.scope/scopeValue`,由 `lib/permission-scope.ts` 收口,空年级/部范围拒绝。设备写权限只合并含对应键的分组范围,范围不替代科目授权。 - 岗位模板只管能力与建议范围,生效范围存 `UserPermissionGroup.scope/scopeValue`,使用 TEACHING/HEAD/GRADE/DIVISION/ALL。无显式分配的教师使用默认模板与固定 TEACHING;空或未知年级拒绝。按键独立合并范围,无键查询为空集;班级账号/学期设置永远要求本班班主任,科目写检查原/目标科目。具体断言和迁移见 `docs/dev/permissions.md`。
- 只有一份 `prisma/schema.prisma`,切库改 provider;保持 SQLite/MySQL 通用,不加 Prisma enum 或 `@db.*`。模型变动同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑。 - 只有一份 `prisma/schema.prisma`,切库改 provider;保持 SQLite/MySQL 通用,不加 Prisma enum 或 `@db.*`。模型变动同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑。
- `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。 - `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。
- 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。 - 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。
@@ -44,7 +44,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。 - `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。
- 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。 - 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。
- 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。 - 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。
- 全校时间表写入用 `timetable:manage`;班级日课表写入还须班主任/管理员断言。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。 - 全校时间表写入仅管理员;班级时间表按存储 classId 校验 `schedule:write` 与班主任关系,引用班级也校验。日课表/名单在 TEACHING/HEAD 范围要求班主任,显式年级/部/全校分配可在范围内维护。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。
- 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。 - 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。
- 全校周数上限存 `term.maxWeeks`,默认 30、范围 1–60;服务端 `getMaxTermWeeks()`,shared 做归一化与离线兜底。班级周数不超全校上限。 - 全校周数上限存 `term.maxWeeks`,默认 30、范围 1–60;服务端 `getMaxTermWeeks()`,shared 做归一化与离线兜底。班级周数不超全校上限。
- 关闭个人成绩查询后,班级端仍可看服务端集体聚合;个人明细、原始全班成绩和排名拒绝读取。稀疏样本及补集抑制,不下发原始成绩后在客户端聚合。 - 关闭个人成绩查询后,班级端仍可看服务端集体聚合;个人明细、原始全班成绩和排名拒绝读取。稀疏样本及补集抑制,不下发原始成绩后在客户端聚合。
@@ -57,9 +57,9 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
- preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer;跨桥接传纯数据,不传 Vue Proxy。契约写在 `src/types/desktop.d.ts`。 - preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer;跨桥接传纯数据,不传 Vue Proxy。契约写在 `src/types/desktop.d.ts`。
- 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。 - 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。
- 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `deviceKey` 保持不变,渲染层不能改写它。 - 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `deviceKey` 保持不变,渲染层不能改写它。
- 设备维护只走 `/api/clients/*`,管理页 `ClientsView.vue`。心跳约 30 秒,90 秒窗口判在线;未登录不报,401/403 退避,切配置重置计时并隔离迟到响应。 - 设备维护只走 `/api/clients/*`,管理页 `ClientsView.vue`;心跳、进度、回执仅接受本班 CLASS_DEVICE,deviceKey 不可跨班迁移。心跳约 30 秒,90 秒窗口判在线;未登录不报,401/403 退避,切配置重置计时并隔离迟到响应。
- 远控须 `remote:control`,只允许 shutdown/restart/logoff/lock,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。 - 远控须 `remote:control`,只允许 shutdown/restart/logoff/lock,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。
- 更新写入口用 `client:update`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。 - 安装包登记/删除为管理员 `client:update`;班级推送用 `client:update-push`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。
- 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。 - 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。
- `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。 - `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。
- 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。 - 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。
@@ -71,7 +71,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界
## 5. 实时、界面与资源 ## 5. 实时、界面与资源
- 事件名与房间名取 shared 常量。业务用 `emitToClass/emitToSession`,不直接依赖 Socket.IO 实例。 - 事件名与房间名取 shared 常量。班级房间为 `class:{id}:{channel}`,教师按每域读键及范围订阅,班级端仅本班全部通道;握手回查数据库身份。业务用 `emitToClass/emitToSession`,由总线选择事件域。
- 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。 - 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。
- 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。 - 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。
- Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。 - Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。
+4 -1
View File
@@ -17,6 +17,8 @@ description: 名单、班级会话、任课关系与年度升级。
## 学生名单 ## 学生名单
Web 统一从“学生管理”查看和维护名单;旧 `/roster` 地址转到同一页面,不再提供重复菜单。班级详情中的名单与课程按当前班级的生效范围展示维护入口,任课关系本身不授予跨班名单、课程或班级设置管理权。
Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。 Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。
学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。 学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。
具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。 具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。
@@ -35,8 +37,9 @@ Student 存学号、姓名、班级、性别、家长电话和状态,没有密
## 权限、学年与归档 ## 权限、学年与归档
角色给默认权限模板,实际入口由可编辑权限组决定;岗位组还能配置年级或部范围。 管理员固定拥有全部能力;教师按岗位模板及每项分配的范围获得能力,未显式分配时使用教师默认模板与固定任教范围。
范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。 范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。
“学期周次”只维护教学周与日期区间;年度轨道、执行计划与补跑统一在管理员专用的“学年升级”页维护。
学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。 学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。
学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。 学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。
默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录, 默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录,
+29 -17
View File
@@ -12,18 +12,18 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
## 身份与班级 ## 身份与班级
| 方法与路径(省略 /api) | 行为及边界 | | 方法与路径(省略 /api) | 行为及边界 |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 | | POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 |
| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 | | GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 |
| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 与目标范围,创建/删除需全校范围 | | GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 且仅管理员 |
| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 | | PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据 class:account + 班主任;班主任分配仅管理员 |
| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 | | PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 |
| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 | | GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 |
| GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 | | GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 |
| /students、/teachers | 名单与账号 CRUD;读/写使用不同权限键,具体角色限制读取对应模块 | | /students、/teachers | 名单与账号 CRUD;读/写使用不同权限键,具体角色限制读取对应模块 |
| POST /teachers/:id/reset-password | 指定新密码或默认重置;学生无对应接口 | | POST /teachers/:id/reset-password | 指定新密码或默认重置;学生无对应接口 |
| /permissions/me、/catalog、/groups、/users | 当前有效权限、目录、组与账号分组 | | /permissions/me、/catalog、/groups、/users | 当前有效权限、目录、组与账号分组 |
## 教学与文件 ## 教学与文件
@@ -58,11 +58,11 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 | | POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 | | GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
| POST /clients/commands | remote:control + 对应范围,白名单动作 | | POST /clients/commands | remote:control + 对应范围,白名单动作 |
| POST /clients/updates | client:update,安装策略 immediate/after-school/idle | | POST /clients/updates | client:update-push,安装策略 immediate/after-school/idle |
| GET /update/check | 员工需 update:check;强制刷新另检查 update:force | | GET /update/check | 员工需 update:check;强制刷新另检查 update:force |
| GET /update/client/releases | 缓存版本读取,按路由允许的读取权限 | | GET /update/client/releases | 缓存版本读取,按路由允许的读取权限 |
| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update | | POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口仅管理员 client:update |
| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 | | GET /update/client/download/:version | 本班 CLASS_DEVICE 或 client:read + 有效班级范围 |
| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write | | /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write |
| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划与执行,promotion:manage + 全校范围,执行要求确认与计划哈希 | | GET/PUT /term/promotion;POST /term/promotion/run | 升级计划与执行,promotion:manage + 全校范围,执行要求确认与计划哈希 |
| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档需 archive:manage 全校范围;撤回转出需 roster:manage;不提供手动创建归档 | | /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档需 archive:manage 全校范围;撤回转出需 roster:manage;不提供手动创建归档 |
@@ -83,8 +83,8 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
| GET /permissions/scopes | permission:manage;返回可选年级、部建议和计划年级 | | GET /permissions/scopes | permission:manage;返回可选年级、部建议和计划年级 |
| GET /permissions/groups/:id/members | permission:manage;包含显式分组及角色默认回落的生效成员 | | GET /permissions/groups/:id/members | permission:manage;包含显式分组及角色默认回落的生效成员 |
| GET /permissions/audit?page=&pageSize= | permission:manage;分页操作人、目标、前后 JSON,pageSize 最大 100 | | GET /permissions/audit?page=&pageSize= | permission:manage;分页操作人、目标、前后 JSON,pageSize 最大 100 |
| POST/PATCH/DELETE /permissions/groups[/:id] | permission:manage;范围校验与防提权;默认组仅 ADMIN 可改,内置组不可删 | | POST/PATCH/DELETE /permissions/groups[/:id] | 仅 ADMIN;仅可分配键,范围校验,教师默认模板固定 TEACHING,内置组不可删 |
| PUT /permissions/users/:id/groups | groupIds 整体替换;默认组角色须匹配,空数组恢复角色默认模板;禁止越键越范围 | | PUT /permissions/users/:id/groups | assignments 整体替换,每项 groupId/scope/scopeValue;兼容 groupIds 按建议范围分配;空数组恢复教师默认模板;ADMIN 不可分配 |
| GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 | | GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 |
| POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 | | POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 |
| PATCH /students/:id | roster:manage;仅资料字段;classId/status 明确返回 400 | | PATCH /students/:id | roster:manage;仅资料字段;classId/status 明确返回 400 |
@@ -105,3 +105,15 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
生命周期历史以 kind 为事实源,mode 为派生兼容字段;新响应另含 batchId、academicYear、fromGrade/toGrade、fromSchool/toSchool。 生命周期历史以 kind 为事实源,mode 为派生兼容字段;新响应另含 batchId、academicYear、fromGrade/toGrade、fromSchool/toSchool。
归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。 归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。
所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 roster:read 且在对应范围内的员工才能获得学生明细。 所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 roster:read 且在对应范围内的员工才能获得学生明细。
## 分配级范围与班级设置
`GET /permissions/catalog` 增加 meta(kind/risk/relation/allowedScopes)。模板 DTO 的 defaultScope/defaultScopeValue/defaultScopeLabel 及兼容 scope 字段都是建议范围;kind、inactiveKeys、warnings 展示模板类别和停用键。成员 DTO 的 assignments 是实际分配,groupIds 为派生值;ADMIN fixed=true。
`GET /permissions/users/:id/effective` 仅 ADMIN,返回 permissions、entries(每键 scopes/classes/classCount)、conflicts、headOnlyKeys。可选 query assignments 为 JSON 数组,预览尚未保存的整体分配;预览与实际授权共用求值器。
`PATCH /classes/:id/settings` 使用 class:settings + 本班班主任(ADMIN 例外)保存 termWeeks/termStartDate。管理员 `PATCH /classes/:id` 只接收 enrollmentYear/classIndex。`PATCH /classes/:id/class-account` 使用 class:account + 本班班主任。全局周次仅管理员,带 classId 的周次写走 class:settings + 班主任。时间表写入按存储 classId 分流,班级时间表需要 schedule:write + 班主任,全校时间表仅管理员。
成绩 overview/collective/exams/ranking 显式检查 grade:read,班级端由服务断言限本班并执行查询开关。设备 heartbeat/update-progress/commands/:id/ack 仅接受绑定本班的 CLASS_DEVICE,不接受教师个人设备。
`GET /permissions/me` 同时返回 classScopes、manageScopes、allScopeKeys,供 Web 按钮与班级选择器显示实际能力范围;manageScopes 对名单、日课表、导入和考试配置叠加班主任或显式组织范围规则。界面范围提示不替代服务端断言。
+10 -2
View File
@@ -27,9 +27,9 @@ description: 现行模型职责、迁移与快照边界。
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 | | `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
| `Grade` | studentId、课程、考试、分数和等级 | | `Grade` | studentId、课程、考试、分数和等级 |
| `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 | | `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 |
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue | | `PermissionGroup` | 岗位模板与建议 scope/scopeValue |
| `PermissionGroupItem` | 组内权限键 | | `PermissionGroupItem` | 组内权限键 |
| `UserPermissionGroup` | 账号与组的多对多分配 | | `UserPermissionGroup` | 账号与模板的分配及实际 scope/scopeValue |
| `AppSetting` | 周次、升级、AI 和系统设置 | | `AppSetting` | 周次、升级、AI 和系统设置 |
| `ClientDevice` | deviceKey、心跳、更新目标与状态 | | `ClientDevice` | deviceKey、心跳、更新目标与状态 |
| `ClientCommand` | 白名单远控、有效期与执行回执 | | `ClientCommand` | 白名单远控、有效期与执行回执 |
@@ -85,3 +85,11 @@ User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好
SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind; SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind;
没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。 没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。
升级计划存 AppSetting.term.promotionPlan,时间存 term.promotionAt,无需新增学校或学生账号模型。 升级计划存 AppSetting.term.promotionPlan,时间存 term.promotionAt,无需新增学校或学生账号模型。
## 授权分配迁移
`20261031000000_authz_assignments` 为 UserPermissionGroup 增加 scope(默认 TEACHING)与 scopeValue。先按旧模板范围回填分配,再将模板 SELF_CLASS/空值和 SELF_CLASS/HEAD 规范为 TEACHING/HEAD。教师默认模板建议范围固定 TEACHING;旧 ADMIN 默认模板与旧管理员专用键保留在库中,由运行时忽略。
同一成员可将同一模板分配到多个范围,唯一约束为 userId/groupId/scope/scopeValue。PermissionGroup 的范围列仅作建议,不参与已有分配求值。
快照表集合和版本 1 保持兼容。恢复旧模板时映射 SELF_CLASS;旧 UserPermissionGroup 没有范围列时按快照内对应模板推导,缺组回落 TEACHING。新快照按实际分配列恢复。真实库升级前先备份快照,隔离回归不替代真实库升级演练。
+4 -2
View File
@@ -18,7 +18,7 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入
## 权限、成绩与年度升级 ## 权限、成绩与年度升级
默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。 能力、分配范围、业务关系统一由 lib/authz 的 principal/scope/relations/assert 维护;access 只提供带显式键的窄封装。ADMIN 固定全集,TEACHER 过滤管理员专用键。
全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。 全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。
个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。 个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。
年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。 年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。
@@ -44,9 +44,11 @@ JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见
学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。 学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。
资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。 资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。
权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。 权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。
范围解析在 shared,默认组或分组均按当前功能键过滤,不能借另一功能的范围扩大当前操作。 范围解析在 shared,实际范围存分配记录,模板范围仅作新分配建议。各键独立合并,无键为空集;默认教师范围固定 TEACHING。
权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。 权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。
年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算; 年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算;
先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。 先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。
周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。 周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。
实时班级事件按 core/homework/notification/grade/courseware 分房间,事件总线按 shared 映射选择通道,教师仅订阅对应读键范围。HTTP 与 Socket 握手共用数据库身份加载;敏感权限与账号变更使旧订阅失效。事务路径传递同一数据库连接给授权断言,保留原/目标资源的校验。
+52 -65
View File
@@ -1,89 +1,76 @@
--- ---
title: 角色与权限 title: 角色与权限
description: 功能权限、岗位范围与班级科目边界。 description: 能力目录、岗位模板、分配范围与业务关系的授权契约。
--- ---
# 角色与权限 # 角色与权限
授权分为功能权限、数据范围和业务关系三层,任何一层拒绝都不能操作。 授权依次检查能力、该能力的数据范围、数据行上的业务关系。服务端统一入口为 `lib/authz/`,路由预检不能替代服务断言。共享目录 `PERMISSION_META` 提供分类、风险、关系提示、合法范围、标签和默认角色;完整键目录读取源码,不在文档维护固定数量。
shared 的 `permissions.ts` 定义键、标签、默认模板与班级关系规则;服务端 `lib/permissions.ts`、
`lib/permission-scope.ts`、`lib/access.ts` 分别计算生效键、查询范围和具体业务断言。
## 身份与权限组 ## 身份与模板
账号角色为 ADMIN、TEACHER;班级登录形成 CLASS_DEVICE 会话。学生是 Student 名单,不是账号。 | 主体 | 生效能力 |
HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.role。 | -------------------- | -------------------------------------------------- |
| ADMIN | 固定拥有全部目录键及全校范围,忽略权限组 |
| TEACHER | 显式分配模板的可分配能力并集;管理员专用键永久无效 |
| 未显式分配的 TEACHER | 数据库教师默认模板与固定 TEACHING 范围 |
| CLASS_DEVICE | 空集;接口明确允许班级端并校验本班关系 |
| 情况 | 有效功能权限 | 学生是 Student 名单记录,没有账号。HEAD/SUBJECT 是班主任和科任关系,级长/部长是岗位模板,不增加 User.role。权限管理只允许 ADMIN;旧 ADMIN 默认模板保留存储但隐藏、忽略。
| ------------ | ----------------------------------------------------- |
| 已分配权限组 | 所有分组键的并集,可以比角色默认更少 |
| 没有分组 | 数据库对应角色的可编辑默认组 |
| ADMIN | 同上,始终额外保留 permission:manage 与 system:manage |
| CLASS_DEVICE | 空集;通过明确允许班级会话的接口与业务断言操作 |
每请求读取生效权限,编辑后无需等待令牌过期。内置组可改不可删,默认组不是写死的全权限快捷方式。 `PermissionGroup` 保存能力和新分配的建议范围,`UserPermissionGroup` 保存实际 `scope/scopeValue`。模板改建议范围不会改变已有分配;模板改能力会影响其成员。同一模板可分配不同范围,多条分配按键取并集。空分配恢复默认模板,界面不会将未分组成员自动转换成显式默认组。
路由使用 requirePermission(AND)、requireAnyPermission(OR),员工与班级端共享读取入口按 requireStaffPermission 等规则检查。
具体 API 的额外角色限制以模块路由和服务断言为准,不能把“默认只分配给管理员”写成所有接口硬编码 ADMIN。
## 数据范围与科目 ## 能力目录与范围
| 岗位/身份 | 范围 | 能力分为管理员专用、班级级、组织只读。管理员专用能力包括组织与账号维护、权限/系统/数据库、全局周次、全校时间表、升级归档、安装包管理等;具体清单以 `PERMISSION_META.kind` 为准。`teacher:read` 和 `update:check` 为组织只读,忽略班级范围。其余班级级能力按分配范围求值。
| ----------- | --------------------------------------------- |
| 管理员 | 全部班级;仍须功能键 |
| 班主任/教师 | 自己担任班主任或任课的班级,按分组 scope 收敛 |
| 级长 | 配置年级 |
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
| 班级端 | 登录绑定班级 |
空年级/部配置不放行。范围按当前功能键独立合并,不能借其他只读组扩大名单、班级或设备写权限。 | 范围 | 语义 |
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。 | -------- | -------------------------- |
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。 | TEACHING | 本人担任班主任或任课的班级 |
| HEAD | 仅本人担任班主任的班级 |
| GRADE | 指定一个年级 |
| DIVISION | 指定多个年级 |
| ALL | 全校 |
| 业务 | 主要功能键与额外边界 | `parsePermissionScope` 统一校验范围。GRADE/DIVISION 必须选择已有年级或升级计划中的年级,范围值按逗号分隔、去重排序;其余范围值必须为空串。旧 SELF_CLASS 仅在迁移和旧快照恢复中映射为 TEACHING/HEAD,不是运行时枚举。
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| 名单读取/管理 | roster:read / roster:manage;管理需名单键与目标班范围;关联班级仍须班主任关系,全校名单范围可跨班维护 |
| 教师列表/维护 | teacher:read / teacher:manage;导入教师另检查 import:teachers 与 teacher:manage |
| 日课表写入 | schedule:write + 管理员/本班班主任 |
| 全校时间表写入 | timetable:manage;默认给 ADMIN |
| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 |
| 通知/叫人 | notification:publish / call:create,再检查班级范围 |
| 课件 | courseware:read / courseware:write,上传和推送自己的文件还受本人授课班级约束 |
| 客户端维护/远控 | client:read、client:manage、remote:control,写操作按对应键范围 |
| 版本与安装包写入 | client:update;旧 client:update-push 不再作为写入口要求 |
| 学年升级 | promotion:manage;计划、预览与执行独立授权,全校范围,默认给 ADMIN |
| 数据库/系统/权限 | database:manage / system:manage / permission:manage |
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。 `loadAuthPrincipal` 在请求主体上缓存只读能力和分配快照,没有可变的“当前权限键”。`classIdsFor(user, key)` 和 `classWhere(user, key)` 强制传键,无键或无授权返回空集合;不会合并其他能力的范围。`classWhere` 过滤 Class 表主键,下游子表使用明确的 classId 条件。
## 前端门控与成绩隐私 ## 关系与写入
Web 菜单属性来自路由,`meta.roles` 粗筛、`meta.permission` 细筛;先 ensurePermissions 再按生效集合判断。 | 操作 | 功能与关系 |
服务端仍检查功能、范围与业务关系,直接构造请求不能绕过。 | --------------------------------------------- | -------------------------------------------------------------------------------- |
岗位范围可以扩大读范围,不能自动扩大科目写权限,也不能替代课件的本人任课关系。 | 建班/删班/届别班号、班主任/任课分配、教师账号 | 管理员专用,记录审计;最后一个管理员不可降级或删除 |
班级成绩查询开关仅员工按业务权限修改;关闭后班级端仍看集体聚合,个人明细、原始全班成绩和排名返回拒绝。 | 班级账号 | class:account + 本班班主任,管理员例外;班级码只给班主任或管理员 |
稀疏分段与补集抑制,服务端不向客户端下发个人原始成绩再聚合。 | 班级学期设置/班级周次 | class:settings + 本班班主任,管理员例外 |
| 名单、日课表与课程写入 | 对应写键;TEACHING/HEAD 要求班主任,显式年级/部/全校范围允许范围内维护 |
| 学生/课表导入 | 导入键与相应写键同时满足;先校验全部班级再写,学生及任课导入使用事务 |
| 班级时间表写入 | schedule:write + 本班班主任;按存储 classId 校验,所有引用班级也要校验 |
| 全校时间表、全局周次 | 管理员;班级设置使用独立接口 |
| 作业/成绩写入 | assertContentWrite;有科目要求该科任教,无科目要求班主任;原科目与目标科目都检查 |
| 作业提交/未交名单 | homework:write + 同样的科目关系;班级端明确允许本班作业 |
| 通知/叫人 | 发布能力和范围,再检查班主任/任课关系;班级端限本班 |
| 通知删除 | notification:delete + 发布者/班主任/管理员 |
| 课件 | 本人文件、对应功能范围与本人授课班级共同约束,不赋予班主任同事文件访问权 |
## 设备维护 ALL 也不能替代班主任、任课、发布者关系,只有 ADMIN 无条件绕过关系。班级端可代录作业,不能写成绩。跨班调班检查源班与目标班。事务中的断言使用同一事务连接,不能因事务路径省略授权。
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。 成绩列表、考试、排名和集体分析都要求 grade:read 与对应班级范围。学生明细另要求 student:grade-query。关闭班级自助查询后,本班设备仍可获得集体聚合,个人明细、原始成绩、考试列表和排名拒绝。实时载荷同样受隐私开关保护,服务端执行稀疏样本和补集抑制。
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。
## 范围配置与权限治理 ## 实时与设备
范围由 shared 的 `parsePermissionScope` 统一解析。关联班级只接受空值(班主任与任课班)或 HEAD(仅班主任班); 班级房间为 `class:{id}:{channel}`,事件映射见 `SOCKET_EVENT_CHANNELS`。班级端加入本班全部通道;教师按各域读键及范围加入,core 由 class:read/schedule:read 决定。显式订阅再次校验通道,握手与 HTTP 共用数据库身份加载;权限/账号变更断开旧订阅。
GRADE 必选一个年级,DIVISION 必选多个年级组成的集合;全角逗号、顿号和空白统一分隔、去重排序。
ALL 存空范围值。年级选项来自数据库已有年级和升级计划,空范围或未知年级返回 400;旧库未配置的岗位组不放行。
`GET /permissions/scopes` 提供选择器选项,组 DTO 的 scopeLabel 供页面展示。
默认组只能分配给同角色;非 ADMIN 的权限管理员只能管理关联班级范围的自定义组, 心跳、进度和命令回执只接受 CLASS_DEVICE,deviceKey 必须属于会话班级。远控只排队允许的系统指令,自动化始终假执行。安装包登记/删除使用管理员 client:update;班级推送使用 client:update-push。下载要求本班班级端或有范围的 client:read。版本与文件名在请求校验和路径构造两处拒绝不安全路径段。
组原有键和新键均不得超过自己当前权限。角色默认组只有 ADMIN 可改;删除组可能触发默认回落,仅 ADMIN 可执行。
分配空数组恢复数据库角色默认组,分配任何组则仅取所选组并集;需要保留默认时显式同时选择同角色默认组。
权限页保存前展示成员与权限差异,支持复制组、查看生效成员和分页审计。 ## 权限页与审计
PermissionAudit 在组或分配事务中保存操作人、目标、前后 JSON;`GET /permissions/audit` 受 permission:manage 控制。
成员权限由一次预取组和账号后计算,不逐账号重复加载。
新增迁移为旧库已有 ADMIN 默认组补 promotion:manage;新库由默认模板创建。 权限页默认展示成员与范围,每条分配独立选择范围,保存前预览新增/移除能力、每键合并范围、班级列表、混合范围提示、班主任专属提示和风险标记。模板页仅提供可分配能力,旧管理员键显示为停用。组织只读能力标注“不限班级”。路由用 `meta.permission` 检查读取能力;维护页面额外用 `meta.menuActions` 声明可执行动作,动作须在至少一个可读班级生效才显示侧栏、搜索及页签,有部分功能可操作就保留入口。`permissions/me.actionScopes` 结合范围和班主任、任课、发布者关系提供动作班级;班级管理动作另用 `manageScopes`。纯查看页面按读取能力展示,服务端始终做最终判定。
自定义组和已分配其他组的管理员不会自动获得该键,需要在权限页显式授予。
班级端权限仍为空,本班名单、课表、通知、叫人和作业代录走显式员工守卫例外;班级端发布内容归属本班班主任。 学期周次维护入口要求全校周次写能力或至少一个可维护的本班设置范围。非全校维护者进入后默认选择可编辑班级,仍可切换查看授权范围内的其他班级。
目录、模板、成员、分配预览和审计的公开契约见[API](api.md)。模板/分配和账号敏感操作写入 PermissionAudit;审计不保存密码明文或哈希。迁移与快照兼容见[数据模型](data-model.md)。
## 验证
`pnpm check:authz` 扫描旧授权符号和缺少显式键的查询,并接入 lint。`pnpm verify:authz` 使用临时 SQLite 验证接管班级、管理员提权、跨键借范围、科目改挂、跨班时间表/周次/导入、成绩隐私、通道订阅、设备冒用和旧快照等攻击场景。其他专项回归保留生命周期、课表、课件、设备与升级覆盖。验证不触及真实班级、不访问外网、不执行系统指令。
+14 -11
View File
@@ -5,22 +5,21 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。
# 实时事件(Socket.IO) # 实时事件(Socket.IO)
连接使用 `io(url, { auth: { token } })`,服务端在握手校验 JWT。 连接使用 `io(url, { auth: { token } })`,服务端握手校验 JWT 并与 HTTP 共用数据库身份加载。
事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。 事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。
## 房间与定向推送 ## 房间与定向推送
| 房间 | 作用 | | 房间 | 作用 |
| --------------- | -------------------------------------------------- | | ------------------------- | -------------------------------------------------- |
| class:{classId} | 某班级的业务广播 | | class:{classId}:{channel} | 某班级对应读域的业务广播 |
| session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id | | session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id |
| role:{role} | 角色房间名,定义在共享常量 | | role:{role} | 角色房间名,定义在共享常量 |
| teacher:{id} | 教师房间名,定义在共享常量 | | teacher:{id} | 教师房间名,定义在共享常量 |
| students | 班级端连接的广播房间,代表全班,不是个人学生账号 | | students | 班级端连接的广播房间,代表全班,不是个人学生账号 |
| teachers | 教师和管理员连接的广播房间 | | teachers | 教师和管理员连接的广播房间 |
`realtime/socket.ts` 当前显式 join 可访问的 class 房间与 students/teachers 广播房间; `realtime/socket.ts` 按域加入授权班级房间,并加入 session/role 与 students/teachers 房间;connected 载荷列出实际已加入房间。
同时在 connected 载荷列出 session/role 名。不能把共享常量或该列表当成所有房间均已成功 join 的证明,实际订阅以 join 调用为准。
业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。 业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。
叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。 叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。
@@ -47,3 +46,7 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。
同步修改共享事件名与载荷、服务端发送点、Web `stores/realtime.ts`、客户端 `stores/realtime.ts` 及相关缓存/岛逻辑。 同步修改共享事件名与载荷、服务端发送点、Web `stores/realtime.ts`、客户端 `stores/realtime.ts` 及相关缓存/岛逻辑。
不要手写事件名;只改服务端会导致前端静默不刷新。涉及成绩时另检查隐私载荷和班级范围。 不要手写事件名;只改服务端会导致前端静默不刷新。涉及成绩时另检查隐私载荷和班级范围。
## 读域订阅
现行班级房间为 `class:{id}:{channel}`。channel 为 core/homework/notification/grade/courseware,事件映射来自 shared 的 SOCKET_EVENT_CHANNELS。旧 `class:{id}` 仅保留兼容名称,生产不订阅。班级端加入本班全部域;教师按对应读键和分配范围加入。`class:join` 接受旧 classId 字符串或 `{classId, channels}`,后者逐通道校验,有任一无权则拒绝整次请求。握手回查账号与角色,权限变更断开旧订阅。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

+6 -4
View File
@@ -17,7 +17,7 @@
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build", "build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"check:icons": "node scripts/check-icons.mjs", "check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .", "lint": "node scripts/check-authz.mjs && eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"", "format": "prettier --write \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
"format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"", "format:check": "prettier --check \"**/*.{ts,js,mjs,cjs,vue,json,md,yaml,yml,css,scss,html}\"",
@@ -34,8 +34,8 @@
"verify:lifecycle": "node scripts/verify-lifecycle.mjs", "verify:lifecycle": "node scripts/verify-lifecycle.mjs",
"verify:client-agent": "node scripts/verify-client-agent.mjs", "verify:client-agent": "node scripts/verify-client-agent.mjs",
"verify:review-client": "node scripts/verify-review-client.mjs", "verify:review-client": "node scripts/verify-review-client.mjs",
"verify:review-api": "node scripts/verify-review-api.mjs", "verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api",
"verify:clients-api": "node scripts/verify-clients-api.mjs", "verify:clients-api": "node scripts/verify-e2e-isolated.mjs --clients-api",
"verify:client-migrations": "node scripts/verify-client-migrations.mjs", "verify:client-migrations": "node scripts/verify-client-migrations.mjs",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke", "verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs", "verify:web": "node scripts/ui-smoke/run.mjs",
@@ -54,7 +54,9 @@
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux", "dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:all": "pnpm dist:server && pnpm dist:win", "dist:all": "pnpm dist:server && pnpm dist:win",
"verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual", "verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online" "verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online",
"verify:authz": "node scripts/verify-authz.mjs",
"check:authz": "node scripts/check-authz.mjs"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
@@ -60,9 +60,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
connecting.value = false; connecting.value = false;
dispatch('__connected__', null); dispatch('__connected__', null);
}); });
instance.on('disconnect', () => { instance.on('disconnect', (reason: string) => {
connected.value = false; connected.value = false;
dispatch('__disconnected__', null); dispatch('__disconnected__', null);
if (reason === 'io server disconnect' && socket === instance) instance.connect();
}); });
instance.on('connect_error', (error: Error) => { instance.on('connect_error', (error: Error) => {
connected.value = false; connected.value = false;
@@ -0,0 +1,7 @@
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'TEACHING';
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scopeValue" TEXT NOT NULL DEFAULT '';
UPDATE "UserPermissionGroup" SET "scope" = COALESCE((SELECT CASE WHEN g."scope" = 'SELF_CLASS' THEN CASE WHEN g."scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END ELSE g."scope" END FROM "PermissionGroup" g WHERE g."id" = "groupId"), 'TEACHING'), "scopeValue" = COALESCE((SELECT CASE WHEN g."scope" IN ('GRADE', 'DIVISION') THEN g."scopeValue" ELSE '' END FROM "PermissionGroup" g WHERE g."id" = "groupId"), '');
UPDATE "PermissionGroup" SET "scope" = CASE WHEN "scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END, "scopeValue" = '' WHERE "scope" = 'SELF_CLASS';
UPDATE "PermissionGroup" SET "scope" = 'TEACHING', "scopeValue" = '' WHERE "defaultRole" = 'TEACHER';
DROP INDEX "UserPermissionGroup_userId_groupId_key";
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_scope_scopeValue_key" ON "UserPermissionGroup"("userId", "groupId", "scope", "scopeValue");
+6 -2
View File
@@ -451,7 +451,8 @@ model Grade {
} }
model PermissionGroup { model PermissionGroup {
scope String @default("SELF_CLASS") /// 新分配的建议范围,不参与已有分配求值。
scope String @default("TEACHING")
scopeValue String @default("") scopeValue String @default("")
/// 角色默认组;自定义组为空,改名不影响对应角色。 /// 角色默认组;自定义组为空,改名不影响对应角色。
defaultRole String? @unique defaultRole String? @unique
@@ -483,6 +484,9 @@ model PermissionGroupItem {
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组) /// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
model UserPermissionGroup { model UserPermissionGroup {
/// 此条分配的生效范围。
scope String @default("TEACHING")
scopeValue String @default("")
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
groupId String groupId String
@@ -491,7 +495,7 @@ model UserPermissionGroup {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId]) @@unique([userId, groupId, scope, scopeValue])
@@index([userId]) @@index([userId])
@@index([groupId]) @@index([groupId])
} }
+516
View File
@@ -0,0 +1,516 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { io as connect, type Socket } from 'socket.io-client';
import {
PERMISSION_KEYS,
PERMISSION_META,
SOCKET_CHANNELS,
SOCKET_ROOMS,
SOCKET_EVENTS,
parsePermissionScope,
} from '@classhelper/shared';
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { classIdsFor } from '../src/lib/authz/index.js';
import { initRealtime } from '../src/realtime/socket.js';
import { emitToClass } from '../src/realtime/bus.js';
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
if (!process.env.DATABASE_URL?.includes('authz-verification'))
throw new Error('只能在授权验收临时数据库运行');
let count = 0;
const check = (name: string) => console.log(`通过 ${++count}:${name}`);
async function account(name: string, role: TokenPayload['role'] = 'TEACHER'): Promise<TokenPayload> {
const row = await prisma.user.create({
data: { username: name, name, role, passwordHash: '隔离夹具,不使用真实账号' },
});
return { sub: row.id, username: name, name, role, classId: null };
}
const admin = await account('authz-admin', 'ADMIN'),
aTeacher = await account('authz-A'),
bTeacher = await account('authz-B'),
unrelated = await account('authz-C'),
lead = await account('authz-grade'),
division = await account('authz-division');
await ensureDefaultGroups();
const cls = async (name: string, grade: string, teacherId: string, index: number) =>
prisma.class.create({
data: {
name,
grade,
teacherId,
classIndex: index,
enrollmentYear: 2026,
code: `AUTHZ${index}`,
passwordHash: '隔离',
},
});
const A = await cls('授权A', '高一', aTeacher.sub, 1),
B = await cls('授权B', '高一', bTeacher.sub, 2),
_C = await cls('授权C', '高二', bTeacher.sub, 3);
const course = async (classId: string, name: string, teacherId: string) =>
prisma.course.create({ data: { classId, name, teacherId } });
const mathB = await course(B.id, '数学', aTeacher.sub),
chineseB = await course(B.id, '语文', bTeacher.sub),
mathA = await course(A.id, '数学', aTeacher.sub);
const studentA = await prisma.student.create({ data: { studentNo: 'AUTHZA', name: '临时A', classId: A.id } }),
studentB = await prisma.student.create({ data: { studentNo: 'AUTHZB', name: '临时B', classId: B.id } });
const device = (c: typeof A): TokenPayload => ({
sub: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classId: c.id,
classSession: true,
classCode: c.code,
});
const aDevice = device(A),
bDevice = device(B);
const server = createServer(createApp()),
realtime = initRealtime(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`,
base = origin + '/api';
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
const res = await fetch(base + url, {
method,
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await res.json();
assert.equal(res.status, expected, `${method} ${url} ${JSON.stringify(json)}`);
return json.data;
}
async function group(name: string, keys: string[], scope = 'TEACHING', scopeValue = '') {
return prisma.permissionGroup.create({
data: { name, scope, scopeValue, items: { create: keys.map((key) => ({ key })) } },
});
}
async function assign(actor: TokenPayload, rows: { groupId: string; scope: string; scopeValue?: string }[]) {
await prisma.userPermissionGroup.deleteMany({ where: { userId: actor.sub } });
if (rows.length)
await prisma.userPermissionGroup.createMany({
data: rows.map((r) => ({ userId: actor.sub, ...r, scopeValue: r.scopeValue ?? '' })),
});
}
const sockets: Socket[] = [];
async function socket(actor: TokenPayload) {
const s = connect(origin, {
auth: { token: signToken(actor) },
transports: ['websocket'],
reconnection: false,
});
sockets.push(s);
const info = await new Promise<{ rooms: string[] }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('实时连接超时')), 5000);
s.once('connected', (data) => {
clearTimeout(timer);
resolve(data);
});
s.once('connect_error', reject);
});
return { s, rooms: info.rooms };
}
try {
// 旧残留管理员键全部装备给科任,管理员能力仍永久无效。
const old = await group('旧残留全能力', [...PERMISSION_KEYS], 'ALL');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const mine = await request('/permissions/me', aTeacher);
assert(
!mine.permissions.some((key: keyof typeof PERMISSION_META) => PERMISSION_META[key].kind === 'admin'),
);
assert.equal((await request('/permissions/me', admin)).permissions.length, PERMISSION_KEYS.length);
check('管理员全集;旧组残留管理员键对教师无效');
for (const [url, method, body] of [
[`/classes/${B.id}/class-account`, 'PATCH', { password: 'authz-test' }],
[`/classes/${B.id}/settings`, 'PATCH', { termWeeks: 12 }],
[`/classes/${B.id}/head-teacher`, 'PATCH', { teacherId: aTeacher.sub }],
[
`/classes/${B.id}/subject-teachers`,
'PUT',
{ assignments: [{ subjectName: '语文', teacherId: aTeacher.sub }] },
],
['/classes', 'POST', { grade: '高一', enrollmentYear: 2029, classIndex: 99 }],
['/teachers', 'POST', { username: 'intruder', name: '攻击', role: 'ADMIN' }],
[`/teachers/${admin.sub}`, 'PATCH', { role: 'TEACHER' }],
[`/teachers/${admin.sub}/reset-password`, 'POST', {}],
[`/teachers/${admin.sub}`, 'DELETE', undefined],
['/permissions/groups', 'POST', { name: '越权', keys: [] }],
['/system/settings', 'PUT', {}],
['/database/status', 'GET', undefined],
['/term', 'PUT', { weeks: [] }],
['/term/promotion', 'GET', undefined],
[
'/imports/table/commit',
'POST',
{ kind: 'classTeachers', fileName: 'authz.csv', contentBase64: '', mapping: {}, mode: 'append' },
],
] as const)
await request(url, aTeacher, method, body, 403);
assert.equal((await request(`/classes/${B.id}`, aTeacher)).code, undefined);
check('科任不能接管班级账号、学期设置、人员关系或组织写入口,班级码不泄漏');
await request(`/classes/${A.id}/class-account`, aTeacher, 'PATCH', { code: 'AUTHZA1' });
await request(`/classes/${A.id}/settings`, aTeacher, 'PATCH', { termWeeks: 12 });
await request(`/classes/${B.id}/class-account`, admin, 'PATCH', { code: 'AUTHZB1' });
check('本班班主任可改账号与学期设置;管理员可改其他班');
await request(`/teachers/${admin.sub}`, admin, 'PATCH', { role: 'TEACHER' }, 409);
await request(`/teachers/${admin.sub}`, admin, 'DELETE', undefined, 409);
check('最后一个管理员不可降级或删除');
const helper = await request(
'/teachers',
admin,
'POST',
{ username: 'authz-new', name: '新增教师', password: 'authz-pass' },
201,
);
await request(`/teachers/${helper.id}/reset-password`, admin, 'POST', { newPassword: 'authz-pass-2' });
await request(`/teachers/${helper.id}`, admin, 'DELETE');
check('管理员账号写入口正常且产生审计');
const readA = await group(
'A的关联读取',
[
'class:read',
'homework:read',
'notification:read',
'grade:read',
'dashboard:read',
'schedule:read',
'roster:read',
],
'HEAD',
);
const otherAll = await group('无关全校键', ['client:read'], 'ALL');
await assign(aTeacher, [
{ groupId: readA.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
for (const url of [
`/grades/ranking?classId=${B.id}&examName=测试`,
`/grades/exams?classId=${B.id}`,
`/grades/collective?classId=${B.id}`,
`/homeworks?classId=${B.id}`,
`/notifications?classId=${B.id}`,
])
await request(url, aTeacher, 'GET', undefined, 403);
assert.deepEqual(
(await request('/classes', aTeacher)).map((c: { id: string }) => c.id),
[A.id],
);
const dashboard = await request('/dashboard/summary', aTeacher);
assert.equal(dashboard.classCount, 1);
assert.equal((await classIdsFor({ ...aTeacher }, undefined as never)).length, 0);
check('按键独立计算,设备全校读不能扩大内容读取;无键范围为空');
await assign(unrelated, [{ groupId: otherAll.id, scope: 'ALL' }]);
for (const url of ['/homeworks', '/notifications', '/dashboard/summary', `/grades/exams?classId=${B.id}`])
await request(url, unrelated, 'GET', undefined, 403);
const onlyDashboard = await group('仅看板', ['dashboard:read'], 'ALL');
await assign(unrelated, [{ groupId: onlyDashboard.id, scope: 'ALL' }]);
const hidden = await request('/dashboard/summary', unrelated);
assert.equal(hidden.gradeCount, 0);
assert.equal(hidden.studentCount, 0);
assert.equal(hidden.sections.grades, false);
check('宽范围无关能力不授予读权限,看板各段独立受控');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const hw = await prisma.homework.create({
data: {
classId: B.id,
courseId: chineseB.id,
title: '同事语文',
content: '隔离测试',
createdBy: bTeacher.sub,
},
});
const grade = await prisma.grade.create({
data: { classId: B.id, courseId: chineseB.id, studentId: studentB.id, examName: '授权考试', score: 80 },
});
await request(`/homeworks/${hw.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(`/grades/${grade.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(
`/homeworks/${hw.id}/status`,
aTeacher,
'PATCH',
{ completed: true, studentId: studentB.id },
403,
);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'GET', undefined, 403);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'PATCH', { notSubmittedStudentIds: [] }, 403);
await request(`/grades/exams`, aTeacher, 'PUT', { classId: B.id, name: '授权考试', countInTotal: true });
// 年级/全校授权可配置考试口径;仅任教范围的科任不能。
await assign(aTeacher, [{ groupId: old.id, scope: 'TEACHING' }]);
await request(
`/grades/exams`,
aTeacher,
'PUT',
{ classId: B.id, name: '授权考试', countInTotal: false },
403,
);
await request(`/classes/${B.id}/student-grade-query`, aTeacher, 'PATCH', { enabled: false }, 403);
check('原/目标科目双校验;未交名单与完成状态需要科目关系;科任不能改考试配置');
const ownHw = await request(
'/homeworks',
aTeacher,
'POST',
{ classId: B.id, courseId: mathB.id, title: '本人数学', content: '隔离测试' },
201,
);
await request(`/homeworks/${ownHw.id}/status`, bDevice, 'PATCH', { completed: true });
check('科任可发布本科作业,班级端可维护本班作业');
const layoutBody = {
classId: A.id,
name: '本班时间表',
points: [{ name: '第一节', startTime: '08:00', endTime: '08:40', timeType: 'LESSON' }],
};
const layout = await request('/time-layouts', aTeacher, 'POST', layoutBody, 201);
const foreign = await request(
'/time-layouts',
admin,
'POST',
{ ...layoutBody, classId: B.id, name: '别班时间表' },
201,
);
for (const method of ['PUT', 'DELETE'])
await request(
`/time-layouts/${foreign.id}`,
aTeacher,
method,
method === 'PUT' ? { ...layoutBody, classId: A.id } : undefined,
403,
);
await request(`/time-layouts/${foreign.id}/clone`, aTeacher, 'POST', { name: '偷复制' }, 403);
await request('/time-layouts', aTeacher, 'POST', { ...layoutBody, classId: null }, 403);
await request('/term', aTeacher, 'PUT', { classId: B.id, weeks: [] }, 403);
await request('/term', aTeacher, 'PUT', { classId: A.id, weeks: [] });
await request(`/time-layouts/${layout.id}`, aTeacher, 'DELETE');
check('时间表按存储班级校验;本班可写、跨班与全局拒绝;班级周次要求班主任');
const csv = `学号,姓名,班级\nAUTHZSAFE,首行,${A.name}\nAUTHZBAD,末行,${B.name}`;
await request(
'/imports/table/commit',
aTeacher,
'POST',
{
kind: 'students',
fileName: 'authz.csv',
contentBase64: Buffer.from('\ufeff' + csv).toString('base64'),
mapping: { studentNo: '学号', name: '姓名', className: '班级' },
mode: 'append',
},
403,
);
assert.equal(await prisma.student.count({ where: { studentNo: 'AUTHZSAFE' } }), 0);
check('跨班学生导入预检全部范围,403 不留下首行数据');
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { scope: 'ALL' }, 400);
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { keys: ['teacher:manage'] }, 400);
await request(
'/permissions/groups',
admin,
'POST',
{ name: '非法年级', scope: 'GRADE', scopeValue: '不存在', keys: ['class:read'] },
400,
);
await request('/permissions/groups', admin, 'POST', { name: '非法能力', keys: ['permission:manage'] }, 400);
const legacyTemplate = await group('保留旧停用键', ['class:read', 'class:manage']);
const updatedLegacyTemplate = await request(`/permissions/groups/${legacyTemplate.id}`, admin, 'PATCH', {
keys: ['class:read', 'grade:read'],
});
assert(updatedLegacyTemplate.inactiveKeys.includes('class:manage'));
assert.equal(
await prisma.permissionGroupItem.count({ where: { groupId: legacyTemplate.id, key: 'class:manage' } }),
1,
);
await assign(unrelated, [{ groupId: legacyTemplate.id, scope: 'ALL' }]);
assert(!(await request('/permissions/me', unrelated)).permissions.includes('class:manage'));
check('默认模板固定 TEACHING;不可保存管理员能力或未知年级');
const scoped = await request(
'/permissions/groups',
admin,
'POST',
{ name: '分配级范围', scope: 'HEAD', keys: ['class:read', 'grade:read', 'class:account'] },
201,
);
await request(`/permissions/users/${lead.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高一' }],
});
await request(`/permissions/users/${division.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'DIVISION', scopeValue: '高二,高一' }],
});
assert.equal((await request('/classes', lead)).length, 2);
assert.equal((await request('/classes', division)).length, 3);
const effective = await request(`/permissions/users/${lead.sub}/effective`, admin);
assert.equal(effective.entries.find((e: { key: string }) => e.key === 'class:account').classCount, 0);
const leadPermissions = await request('/permissions/me', lead);
assert.deepEqual(leadPermissions.classScopes['grade:read'].sort(), [A.id, B.id].sort());
await request(`/grades/ranking?classId=${B.id}&examName=授权考试`, lead);
await request(`/permissions/groups/${scoped.id}`, admin, 'PATCH', { scope: 'ALL' });
assert.equal((await request('/classes', lead)).length, 2);
check('模板建议范围变更不影响已有分配;级长/部长范围与班主任关系独立');
const query = encodeURIComponent(
JSON.stringify([
{ groupId: scoped.id, scope: 'HEAD', scopeValue: '' },
{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高二' },
]),
);
const merged = await request(`/permissions/users/${aTeacher.sub}/effective?assignments=${query}`, admin);
assert.equal(merged.entries.find((e: { key: string }) => e.key === 'class:read').classCount, 2);
assert(merged.conflicts.length > 0);
await request(`/permissions/users/${admin.sub}/groups`, admin, 'PUT', { assignments: [] }, 400);
check('同模板多范围正确合并并预览冲突,管理员不可分配');
const beforeDefault = await request('/permissions/me', bTeacher);
await request(`/permissions/users/${bTeacher.sub}/groups`, admin, 'PUT', { assignments: [] });
assert.equal((await request('/permissions/me', bTeacher)).fromGroups, false);
assert(beforeDefault.permissions.length > 0);
check('空分配恢复默认,无隐式自动预选转换');
await request(`/classes/${B.id}/student-grade-query`, admin, 'PATCH', { enabled: false });
for (const url of [
`/grades/ranking?classId=${B.id}&examName=授权考试`,
`/grades/exams?classId=${B.id}`,
`/grades/student/${studentB.studentNo}`,
'/grades/my',
])
await request(url, bDevice, 'GET', undefined, 403);
assert.equal((await request(`/grades/collective?classId=${B.id}`, bDevice)).mode, 'collective');
await request(`/grades/collective?classId=${B.id}`, aDevice, 'GET', undefined, 403);
check('班级端关闭查询后拒绝个人/排名/原始列表,保留本班集体聚合');
const hb = {
deviceKey: 'authz-device-b',
name: '临时设备',
clientVersion: '1.0.0',
platform: 'win32',
agentProtocol: 2,
};
const d = await request('/clients/heartbeat', bDevice, 'POST', hb);
await request('/clients/heartbeat', aTeacher, 'POST', hb, 403);
await request('/clients/heartbeat', aDevice, 'POST', hb, 403);
const command = await prisma.clientCommand.create({
data: { deviceId: d.deviceId, kind: 'lock', createdBy: admin.sub, payload: '隔离假执行' },
});
for (const actor of [aTeacher, aDevice])
await request(
`/clients/commands/${command.id}/ack`,
actor,
'POST',
{ deviceKey: hb.deviceKey, ok: true },
403,
);
await request(`/clients/commands/${command.id}/ack`, bDevice, 'POST', {
deviceKey: hb.deviceKey,
ok: true,
result: '未执行任何系统操作',
});
await request(
'/clients/update-progress',
aTeacher,
'POST',
{
deviceKey: hb.deviceKey,
progress: {
version: '1.1.0',
stage: 'downloading',
downloadedBytes: 0,
totalBytes: 1,
percent: 0,
error: '',
},
},
403,
);
check('心跳/进度/回执只接受本班班级端,教师知道 deviceKey 仍不能冒用');
await request('/update/client/releases', admin, 'POST', { version: '..' }, 400);
await request('/update/client/download/1.0.0', unrelated, 'GET', undefined, 403);
check('更新路径拒绝 ..,下载要求班级端或设备读能力及范围');
const readOnly = await group('只读作业实时', ['homework:read'], 'HEAD');
await assign(aTeacher, [{ groupId: readOnly.id, scope: 'TEACHING' }]);
const previousSubjectSocket = await socket(aTeacher);
assert(previousSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
const disconnected = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('任课导入后旧订阅未断开')), 5000);
previousSubjectSocket.s.once('disconnect', () => {
clearTimeout(timer);
resolve();
});
});
await request('/imports/table/commit', admin, 'POST', {
kind: 'classTeachers',
fileName: 'relations.csv',
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
contentBase64: Buffer.from(
`\ufeff班级,科目,工号,姓名,角色\n${B.name},数学,${bTeacher.username},临时教师,科任`,
).toString('base64'),
mode: 'upsert',
});
await disconnected;
const updatedSubjectSocket = await socket(aTeacher);
assert(!updatedSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
check('任课导入变更后旧房间立即失效,重连按现行关系订阅');
await assign(aTeacher, [
{ groupId: readOnly.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
const { s, rooms } = await socket(aTeacher),
deviceSocket = await socket(aDevice);
assert(rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'grade')));
assert(SOCKET_CHANNELS.every((ch) => deviceSocket.rooms.includes(SOCKET_ROOMS.classChannel(A.id, ch))));
const join = await new Promise<{ ok: boolean }>((resolve) =>
s.emit('class:join', { classId: B.id, channels: ['homework'] }, resolve),
);
assert.equal(join.ok, false);
const seen: string[] = [];
s.on(SOCKET_EVENTS.gradeUpdated, () => seen.push('grade'));
s.on(SOCKET_EVENTS.homeworkNew, () => seen.push('homework'));
emitToClass(A.id, SOCKET_EVENTS.gradeUpdated, { classId: A.id });
emitToClass(B.id, SOCKET_EVENTS.homeworkNew, { id: 'foreign' });
emitToClass(A.id, SOCKET_EVENTS.homeworkNew, { id: 'own' });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(seen, ['homework']);
check('实时按读域与范围订阅,跨班 join 和跨域事件均拒绝');
const stale = signToken(admin);
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'TEACHER' } });
const staleSocket = connect(origin, {
auth: { token: stale },
transports: ['websocket'],
reconnection: false,
});
sockets.push(staleSocket);
const staleInfo = await new Promise<{ role: string }>((resolve) => staleSocket.once('connected', resolve));
assert.equal(staleInfo.role, 'TEACHER');
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'ADMIN' } });
check('Socket 与 HTTP 共用数据库身份加载,旧管理员 JWT 不保留角色');
const snapshot = await exportSnapshot(prisma, 'sqlite');
const legacy = structuredClone(snapshot);
for (const g of legacy.data.PermissionGroup)
if (g.id === readOnly.id) {
g.scope = 'SELF_CLASS';
g.scopeValue = 'HEAD';
}
for (const a of legacy.data.UserPermissionGroup) {
delete a.scope;
delete a.scopeValue;
}
await restoreSnapshot(prisma, 'sqlite', legacy);
assert.equal(
(
await prisma.userPermissionGroup.findFirstOrThrow({
where: { userId: aTeacher.sub, groupId: readOnly.id },
})
).scope,
'HEAD',
);
assert.equal(
(await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } })).scope,
'TEACHING',
);
assert.throws(() => parsePermissionScope('SELF_CLASS' as never, ''));
check('旧快照按模板回填分配、默认模板归一化、运行时拒绝旧范围枚举');
check(`授权攻击矩阵完成,夹具 ${studentA.id} / ${mathA.id} 均位于临时库`);
} finally {
for (const s of sockets) s.disconnect();
await new Promise<void>((resolve) => realtime.close(() => resolve()));
server.closeAllConnections();
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}
+16 -11
View File
@@ -255,7 +255,9 @@ try {
emitted emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged) .filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) => .every((e) =>
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)), e.rooms.every((room) =>
[classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}:courseware`),
),
), ),
); );
await new Promise<void>((resolve) => server.close(() => resolve())); await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -357,24 +359,27 @@ try {
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } }); state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check('取消目标后忽略旧任务进度', state.updateProgress === ''); check('取消目标后忽略旧任务进度', state.updateProgress === '');
const teacherDeviceKey = 'courseware-teacher-device'; const teacherDeviceKey = 'courseware-teacher-device';
await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }); check(
let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } }); '教师会话不能注册设备心跳',
check('教师电脑设备不绑定班级', teacherDevice.classId === null); (await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }))
await prisma.clientDevice.update({ .status === 403,
where: { id: teacherDevice.id }, );
data: { updateTargetVersion: '2.0.0' }, const teacherDevice = await prisma.clientDevice.create({
data: { deviceKey: teacherDeviceKey, updateTargetVersion: '2.0.0' },
}); });
check( check(
'教师电脑可上报自己的更新进度', '旧未绑定设备也不能由教师冒报进度',
( (
await request('/clients/update-progress', tokens[0], { await request('/clients/update-progress', tokens[0], {
deviceKey: teacherDeviceKey, deviceKey: teacherDeviceKey,
progress: { ...updateProgress, version: '2.0.0' }, progress: { ...updateProgress, version: '2.0.0' },
}) })
).status === 200, ).status === 403,
);
check(
'拒绝请求不改变旧设备进度',
(await prisma.clientDevice.findUniqueOrThrow({ where: { id: teacherDevice.id } })).updateProgress === '',
); );
teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading');
const snapshot = await exportSnapshot(prisma, 'sqlite'); const snapshot = await exportSnapshot(prisma, 'sqlite');
check( check(
'备份包含课件、可见范围、推送和回执', '备份包含课件、可见范围、推送和回执',
+5 -15
View File
@@ -1563,28 +1563,18 @@ async function main() {
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`, `含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
); );
// 管理员可以分配权限组,但始终保留两个管理入口
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, { const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT', method: 'PUT',
token: adminToken, token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] }, body: { assignments: [] },
}); });
const fixedAdmin = await api('/permissions/me', { token: adminToken });
record( record(
'管理员可以分配权限组(PUT /users/:id/groups → 200)', '管理员固定全集并拒绝分配模板',
Boolean(adminMember) && assignAdmin.status === 400 && fixedAdmin.payload?.data?.permissions.includes('teacher:manage'),
assignAdmin.status === 200 && `status=${assignAdmin.status}`,
Array.isArray(assignAdmin.payload?.data?.permissions) &&
assignAdmin.payload.data.permissions.includes('permission:manage') &&
assignAdmin.payload.data.permissions.includes('system:manage'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
); );
await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败) // 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken }); const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null; const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
+31 -42
View File
@@ -6,7 +6,7 @@ import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@cl
import { prisma } from '../src/lib/db.js'; import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js'; import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js'; import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups, loadEffectivePermissions } from '../src/lib/permissions.js'; import { ensureDefaultGroups, loadAuthPrincipal } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js'; import { registerEmitter } from '../src/realtime/bus.js';
import * as students from '../src/modules/students/students.service.js'; import * as students from '../src/modules/students/students.service.js';
import * as classes from '../src/modules/classes/classes.service.js'; import * as classes from '../src/modules/classes/classes.service.js';
@@ -53,7 +53,7 @@ const a = await cls('TESTA', '高一', 2025, 1),
c = await cls('TESTC', '高二', 2024, 1, admin.sub); c = await cls('TESTC', '高二', 2024, 1, admin.sub);
const events: { classId: string; event: string }[] = []; const events: { classId: string; event: string }[] = [];
registerEmitter((rooms, event) => { registerEmitter((rooms, event) => {
for (const room of rooms) events.push({ classId: room.replace('class:', ''), event }); for (const room of rooms) events.push({ classId: room.split(':')[1], event });
}); });
const server = createServer(createApp()); const server = createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -91,24 +91,22 @@ try {
assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true), assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true),
); );
const delegate = await account('临时教务授权', 'TEACHER'); const delegate = await account('临时教务授权', 'TEACHER');
const governance = await permissions.createGroup(admin, { await denied(
name: '教务授权', permissions.createGroup(admin, {
scope: 'ALL', name: '不可下放组织能力',
keys: ['class:read', 'class:manage', 'archive:manage', 'promotion:manage'], keys: ['class:manage', 'archive:manage', 'promotion:manage'],
}); }),
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [governance.id] }); 400,
const delegatedClass = await request(
'/classes',
delegate,
'POST',
{ grade: '高一', enrollmentYear: 2028, classIndex: 99 },
201,
); );
await request(`/classes/${delegatedClass.id}`, delegate, 'PATCH', { termWeeks: 10 }); for (const url of ['/classes', '/archives', '/term/promotion'])
await request(`/classes/${delegatedClass.id}`, delegate, 'DELETE'); await request(
await request('/archives', delegate); url,
await request('/term/promotion', delegate); delegate,
check('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {}); url === '/classes' ? 'POST' : 'GET',
url === '/classes' ? {} : undefined,
403,
);
check('组织管理能力不能下放给教师,路由永久拒绝', () => {});
const allRoster = await permissions.createGroup(admin, { const allRoster = await permissions.createGroup(admin, {
name: '全校名单', name: '全校名单',
scope: 'ALL', scope: 'ALL',
@@ -132,50 +130,41 @@ try {
scope: 'ALL', scope: 'ALL',
keys: ['class:read'], keys: ['class:read'],
}); });
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:manage'] }); const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:settings'] });
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] }); await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] });
const foreignDetail = await request(`/classes/${a.id}`, delegate); const foreignDetail = await request(`/classes/${a.id}`, delegate);
assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键'); assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键');
await request(`/classes/${a.id}`, delegate, 'PATCH', { termWeeks: 11 }, 403); await request(`/classes/${a.id}/settings`, delegate, 'PATCH', { termWeeks: 11 }, 403);
check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {}); check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {});
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } }); const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] }); await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
assert((await loadEffectivePermissions(teacher)).has('roster:manage')); assert((await loadAuthPrincipal(teacher)).permissions.has('roster:manage'));
const additional = await permissions.createGroup(admin, { const additional = await permissions.createGroup(admin, {
name: '追加升级权限', name: '追加设备读权限',
scope: 'ALL', scope: 'ALL',
keys: ['promotion:manage'], keys: ['client:read'],
}); });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] }); await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
check('无组回落、显式默认组与多组并集', () => {}); assert((await loadAuthPrincipal(teacher)).permissions.has('client:read'));
assert((await loadEffectivePermissions(teacher)).has('promotion:manage')); assert(!(await loadAuthPrincipal(teacher)).permissions.has('promotion:manage'));
await denied( await denied(
permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }), permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }),
403, 400,
); );
const assistant = await permissions.createGroup(admin, {
name: '权限协助',
keys: ['permission:manage', 'roster:read'],
});
await permissions.assignUserGroups(admin, manager.sub, { groupIds: [assistant.id] });
await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403); await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403);
await denied(
permissions.createGroup(manager, { name: '越范围', scope: 'ALL', keys: ['roster:read'] }),
403,
);
await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403); await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403);
await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403); await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403);
check('跨角色默认组、越键、越范围和默认组编辑均拒绝', () => {}); check('分配并集、管理员默认组忽略、非管理员权限管理均拒绝', () => {});
await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400); await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400);
await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400); await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400);
check('范围统一归一化并拒绝空值和未知年级', () => check('范围统一归一化并拒绝空值和未知年级', () =>
assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'), assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'),
); );
const memberList = await permissions.listMembers(); const memberList = await permissions.listMembers(admin);
check('成员含真实权限与范围,组成员包含默认回落账号', () => check('成员含真实权限与范围,组成员包含默认回落账号', () =>
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('promotion:manage')), assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('client:read')),
); );
const audits = await permissions.listAudits(); const audits = await permissions.listAudits(admin);
check('审计记录操作人与变更前后快照', () => check('审计记录操作人与变更前后快照', () =>
assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)), assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)),
); );
@@ -197,7 +186,7 @@ try {
} }
} }
try { try {
await permissions.listMembers(); await permissions.listMembers(admin);
const small = queryCount; const small = queryCount;
queryCount = 0; queryCount = 0;
await prisma.user.createMany({ await prisma.user.createMany({
@@ -208,7 +197,7 @@ try {
passwordHash: '临时', passwordHash: '临时',
})), })),
}); });
await permissions.listMembers(); await permissions.listMembers(admin);
check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small)); check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small));
} finally { } finally {
restoreMethods.forEach((restore) => restore()); restoreMethods.forEach((restore) => restore());
+11 -2
View File
@@ -114,8 +114,17 @@ for (const [userId, id, scopeValue] of [
[head.id, 'builtin-head', 'HEAD'], [head.id, 'builtin-head', 'HEAD'],
[teacher.id, 'builtin-teacher', ''], [teacher.id, 'builtin-teacher', ''],
] as const) { ] as const) {
await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } }); const scope =
await prisma.userPermissionGroup.create({ data: { userId, groupId: id } }); id === 'builtin-grade'
? 'GRADE'
: id === 'builtin-division'
? 'DIVISION'
: id === 'builtin-head'
? 'HEAD'
: 'TEACHING';
await prisma.userPermissionGroup.create({
data: { userId, groupId: id, scope, scopeValue: ['GRADE', 'DIVISION'].includes(scope) ? scopeValue : '' },
});
} }
const server = createServer(createApp()); const server = createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+74 -324
View File
@@ -1,379 +1,129 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared'; import type { Prisma } from '../generated/prisma/client.js';
import { import type { PermissionKey, ClassRole } from '@classhelper/shared';
canManageGrades, import { resolveClassRole } from '@classhelper/shared';
canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { teacherScopeWhere } from './permission-scope.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import { ApiError } from './http.js'; import { ApiError } from './http.js';
import { assertPermission, currentRequestPermission } from './permissions.js';
import { hasAllScope } from './permission-scope.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
import {
/** assertAdmin,
* 权限(RBAC)规则。 assertPermission,
* assertClassRead,
* | 角色 | 范围 | 能做什么 | assertManage,
* | ---- | ---- | -------- | classIdsFor,
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 | classWhere,
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** | classDeviceClassId,
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 | isHead,
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 | hasOrganizationalGrant,
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 | } from './authz/index.js';
* export { classDeviceClassId } from './authz/relations.js';
* 三条铁律(都是这次重构的落点): export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN';
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表; export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER';
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有; export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER';
* 3. **所有权限都在后端判**,前端只做展示控制。 export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE';
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
export function isAdmin(user: TokenPayload): boolean {
return user.role === 'ADMIN';
}
export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
export async function resolveContentCreator(user: TokenPayload, classId: string) { export async function resolveContentCreator(user: TokenPayload, classId: string) {
if (!isClassDevice(user)) return user.sub; if (!isClassDevice(user)) return user.sub;
const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } }); const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任,再在班级端发布内容'); if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任');
return record.teacherId; return record.teacherId;
} }
export function requireClassDeviceClassId(user: TokenPayload): string {
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */ const id = classDeviceClassId(user);
export function classDeviceClassId(user: TokenPayload): string | null { if (!id) throw ApiError.forbidden('当前会话未绑定班级');
return isClassDevice(user) ? (user.classId ?? user.sub) : null; return id;
} }
export const getAccessibleClassIds = classIdsFor;
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(
user: TokenPayload,
permission?: ContractPermissionKey,
): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: await teacherScopeWhere(user, permission),
select: { id: true },
});
return classes.map((item) => item.id);
}
const classId = classDeviceClassId(user);
return classId ? [classId] : [];
}
export async function canAccessClass( export async function canAccessClass(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
permission?: ContractPermissionKey, key: PermissionKey,
): Promise<boolean> { ): Promise<boolean> {
if (isAdmin(user)) return true; if (isClassDevice(user)) return classDeviceClassId(user) === classId;
if (isTeacher(user)) { return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } }));
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
});
return count > 0;
}
return classDeviceClassId(user) === classId;
} }
/** 兼容窄封装,键必须由调用点显式提供。 */
/** 读权限断言 */
export async function assertClassAccess( export async function assertClassAccess(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
permission?: ContractPermissionKey, key: PermissionKey,
): Promise<void> { ): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); await assertClassRead(user, key, classId, { allowClassDevice: true });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
} }
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> { export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN'; if (isAdmin(user)) return 'ADMIN';
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE'; if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
return resolveClassRole({ return resolveClassRole({
role: user.role, role: user.role,
isHeadTeacher: record.teacherId === user.sub, isHeadTeacher: await isHead(user, classId),
teachesSubjects: record.courses.length > 0, teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })),
}); });
} }
export async function assertCanManageClasses(user: TokenPayload, classId?: string) {
/** 班级本身(增删改)与人员分配:仅管理员 */ assertAdmin(user);
export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise<void> { if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId)
await assertPermission(user, 'class:manage'); throw ApiError.conflict('该班级已归档');
if (classId) await assertClassInScope(user, classId, 'class:manage');
else if (!(await hasAllScope(user, 'class:manage'))) throw ApiError.forbidden('创建或删除班级需要全校范围');
if (
classId &&
(await prisma.class.findUnique({ where: { id: classId }, select: { archivedYearId: true } }))
?.archivedYearId
)
throw ApiError.conflict('该班级已归档,请先撤销归档');
} }
export const assertCanAssignTeachers = assertCanManageClasses;
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) { export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) {
assertAdmin(user);
await assertPermission(user, key); await assertPermission(user, key);
if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围');
} }
export async function assertClassInScope(user: TokenPayload, classId: string, key: PermissionKey) {
/** 班主任 / 各科科任老师的分配:仅管理员 */ await assertClassRead(user, key, classId);
export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise<void> {
await assertCanManageClasses(user, classId);
} }
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) {
export async function assertClassInScope(user: TokenPayload, classId: string, key: ContractPermissionKey) {
await assertPermission(user, key);
if (isAdmin(user)) return;
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] },
});
if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) {
for (const id of new Set(ids)) await assertClassInScope(user, id, key); for (const id of new Set(ids)) await assertClassInScope(user, id, key);
} }
export async function assertCanManageRoster(
/** user: TokenPayload,
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。 classId?: string,
* db: Prisma.TransactionClient = prisma,
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员 ) {
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'roster:manage');
if (!classId) { if (!classId) {
if (!(await hasAllScope(user, 'roster:manage'))) await assertPermission(user, 'roster:manage');
throw ApiError.forbidden('未分班学生操作需要全校名单范围'); if (!(await hasOrganizationalGrant(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生需要全校名单范围');
return; return;
} }
await assertClassInScope(user, classId, 'roster:manage'); await assertManage(user, 'roster:manage', classId, db);
if (await hasAllScope(user, 'roster:manage')) return;
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
}
} }
export async function assertCanManageSchedule(
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
);
}
}
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
export async function assertCanImportSchedule(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
writeKey: ContractPermissionKey = 'schedule:write', db: Prisma.TransactionClient = prisma,
): Promise<void> { ) {
await assertClassInScope(user, classId, 'import:schedule'); await assertManage(user, 'schedule:write', classId, db);
await assertClassInScope(user, classId, writeKey); }
export async function assertCanImportSchedule(user: TokenPayload, classId: string) {
await assertManage(user, 'import:schedule', classId);
await assertCanManageSchedule(user, classId); await assertCanManageSchedule(user, classId);
} }
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
}
}
/**
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
*
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (canManageGrades(classRole)) return;
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入自己担任班主任的班级的成绩',
);
}
/**
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
*
* | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean } = {},
): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
}
let isSubjectTeacherOfCourse = false;
let courseName = '';
if (courseId) {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { classId: true, teacherId: true, name: true },
});
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
}
const allowed = canManageSubjectContent({
classRole,
hasSubject: Boolean(courseId),
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
}
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
export function requireClassDeviceClassId(user: TokenPayload): string {
const classId = classDeviceClassId(user);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
return classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] }; export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope( export async function resolveClassScope(
user: TokenPayload, user: TokenPayload,
requestedClassId?: string, requestedClassId: string | undefined,
permission?: ContractPermissionKey, key: PermissionKey,
): Promise<ClassScope> { ): Promise<ClassScope> {
if (requestedClassId) { if (requestedClassId) {
await assertClassAccess(user, requestedClassId, permission); await assertClassAccess(user, requestedClassId, key);
return { mode: 'in', classIds: [requestedClassId] }; return { mode: 'in', classIds: [requestedClassId] };
} }
if (isAdmin(user)) return { mode: 'all' }; if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] };
const scopePermission = permission ?? currentRequestPermission(user); await assertPermission(user, key);
if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission))) if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' };
return { mode: 'all' }; // 其余范围显式枚举班级,避免空集合被下游当成全量。
return { mode: 'in', classIds: await classIdsFor(user, key) };
const classIds = await getAccessibleClassIds(user, permission);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds };
} }
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } { export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } }; return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
} }
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } { export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } }; return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
} }
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined { export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isClassDevice(user)) return requireClassDeviceClassId(user); if (isClassDevice(user)) {
const id = requireClassDeviceClassId(user);
if (requestedClassId && requestedClassId !== id) throw ApiError.forbidden('不能访问其他班级');
return id;
}
return requestedClassId; return requestedClassId;
} }
+104
View File
@@ -0,0 +1,104 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { PERMISSION_LABELS, type PermissionKey } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal } from './principal.js';
import { assertClassInScope, hasOrganizationalGrant } from './scope.js';
import { classDeviceClassId, isHead, isTeaching } from './relations.js';
export function assertAdmin(user: TokenPayload): void {
if (user.role !== 'ADMIN' || user.classSession) throw ApiError.forbidden('此操作仅限管理员');
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await loadAuthPrincipal(user)).permissions.has(key))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const auth = await loadAuthPrincipal(user);
if (!keys.some((key) => auth.permissions.has(key))) throw ApiError.forbidden('缺少此操作所需权限');
}
export function assertClassDevice(user: TokenPayload, classId: string): void {
if (classDeviceClassId(user) !== classId) throw ApiError.forbidden('仅限绑定本班的班级端会话');
}
export async function assertPermissionOrClassDevice(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
export async function assertClassRead(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
if (user.role === 'CLASS_DEVICE' && options.allowClassDevice) {
assertClassDevice(user, classId);
return;
}
await assertPermission(user, key);
await assertClassInScope(user, key, classId, options.db);
}
export async function assertHeadOnly(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (user.role !== 'ADMIN' && !(await isHead(user, classId, db)))
throw ApiError.forbidden('此操作仅限本班班主任或管理员');
}
export async function assertManage(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (
user.role !== 'ADMIN' &&
!(await isHead(user, classId, db)) &&
!(await hasOrganizationalGrant(user, key, classId, db))
)
throw ApiError.forbidden('此操作需要班主任关系或该功能的年级、部、全校范围');
}
export async function assertContentWrite(
user: TokenPayload,
key: PermissionKey,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (courseId) {
const course = await (options.db ?? prisma).course.findUnique({ where: { id: courseId } });
if (!course || course.classId !== classId) throw ApiError.badRequest('课程不属于目标班级');
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
course.teacherId === user.sub
)
return;
} else if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isHead(user, classId, options.db))
)
return;
throw ApiError.forbidden(`只能${action}本人任教科目或班主任班未指定科目的内容`);
}
export async function assertPublish(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isTeaching(user, classId, options.db))
)
return;
throw ApiError.forbidden('只有班主任或本班任课老师可以发布');
}
+4
View File
@@ -0,0 +1,4 @@
export * from './principal.js';
export * from './scope.js';
export * from './relations.js';
export * from './assert.js';
@@ -0,0 +1,99 @@
import {
PERMISSION_KEYS,
PERMISSION_META,
parsePermissionScope,
type PermissionKey,
type PermissionScope,
} from '@classhelper/shared';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
import { ensureDefaultGroups } from '../permissions.js';
export interface Grant {
scope: PermissionScope;
scopeValue: string;
groupId: string;
}
export interface AuthPrincipal {
permissions: ReadonlySet<PermissionKey>;
grants: ReadonlyMap<PermissionKey, readonly Grant[]>;
fromGroups: boolean;
}
export interface AssignmentRow {
groupId: string;
scope: string;
scopeValue: string;
group: { defaultRole: string | null; items: { key: string }[] };
}
let requests = new WeakMap<TokenPayload, Promise<AuthPrincipal>>();
export function invalidateAuthPrincipals() {
requests = new WeakMap();
}
/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */
export function loadAuthPrincipal(user: TokenPayload): Promise<AuthPrincipal> {
let principal = requests.get(user);
if (!principal) {
principal = load(user);
requests.set(user, principal);
}
return principal;
}
async function load(user: TokenPayload): Promise<AuthPrincipal> {
if (user.role !== 'TEACHER') return principalForAssignments(user, []);
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
return principalForAssignments(user, assignments);
}
/** 分配预览与运行时共用求值,不写数据库。 */
export async function principalForAssignments(
user: TokenPayload,
assignments: AssignmentRow[],
defaultGroup?: AssignmentRow['group'] & { id: string },
): Promise<AuthPrincipal> {
const permissions = new Set<PermissionKey>();
const grants = new Map<PermissionKey, Grant[]>();
if (user.role === 'CLASS_DEVICE' || user.classSession) return { permissions, grants, fromGroups: false };
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) {
permissions.add(key);
grants.set(key, [{ scope: 'ALL', scopeValue: '', groupId: '' }]);
}
return { permissions, grants, fromGroups: false };
}
const fromGroups = assignments.length > 0;
if (!fromGroups) {
const group =
defaultGroup ??
(await prisma.permissionGroup.findUnique({
where: { defaultRole: 'TEACHER' },
include: { items: true },
}));
assignments = group ? [{ groupId: group.id, scope: 'TEACHING', scopeValue: '', group }] : [];
}
for (const assignment of assignments) {
if (assignment.group.defaultRole === 'ADMIN') continue;
for (const { key: raw } of assignment.group.items) {
if (!(PERMISSION_KEYS as readonly string[]).includes(raw)) continue;
const key = raw as PermissionKey;
if (PERMISSION_META[key].kind === 'admin') continue;
permissions.add(key);
try {
const parsed = parsePermissionScope(assignment.scope as PermissionScope, assignment.scopeValue);
grants.set(key, [
...(grants.get(key) ?? []),
{ scope: parsed.scope, scopeValue: parsed.scopeValue, groupId: assignment.groupId },
]);
} catch {
/* 非法旧分配不授予班级。 */
}
}
}
return { permissions, grants, fromGroups };
}
@@ -0,0 +1,24 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
export function classDeviceClassId(user: TokenPayload): string | null {
return user.role === 'CLASS_DEVICE' && user.classSession ? (user.classId ?? user.sub) : null;
}
export async function isHead(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } }));
}
export async function isTeaching(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return (
(await isHead(user, classId, db)) ||
!!(await db.course.count({ where: { classId, teacherId: user.sub } }))
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PERMISSION_META, parsePermissionScope, type PermissionKey } from '@classhelper/shared';
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal, type AuthPrincipal } from './principal.js';
/** 返回 Class 表过滤条件;无键、无授权、组织只读键均不产生班级集合。 */
export async function classWhere(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
): Promise<Prisma.ClassWhereInput> {
if (!key) return { id: { in: [] } };
const auth = principal ?? (await loadAuthPrincipal(user));
if (!auth.permissions.has(key)) return { id: { in: [] } };
if (user.role === 'ADMIN') return {};
if (PERMISSION_META[key].kind !== 'class') return { id: { in: [] } };
const alternatives: Prisma.ClassWhereInput[] = [];
for (const grant of auth.grants.get(key) ?? []) {
if (grant.scope === 'ALL') return PERMISSION_META[key].relation === 'head' ? { teacherId: user.sub } : {};
if (grant.scope === 'HEAD') alternatives.push({ teacherId: user.sub });
else if (grant.scope === 'TEACHING')
alternatives.push({ OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] });
else alternatives.push({ grade: { in: parsePermissionScope(grant.scope, grant.scopeValue).grades } });
}
// 班主任专属键仍须满足分配范围与关系的交集。
if (PERMISSION_META[key].relation === 'head') {
return { AND: [{ OR: alternatives }, { teacherId: user.sub }] };
}
return { OR: alternatives };
}
export async function classIdsFor(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
db: Prisma.TransactionClient = prisma,
): Promise<string[]> {
const rows = await db.class.findMany({
where: await classWhere(user, key, principal),
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 名单、日课表、导入及考试配置的班级管理范围,供界面准确显示操作入口。 */
export async function manageClassIdsFor(
user: TokenPayload,
key: PermissionKey,
principal: AuthPrincipal,
): Promise<string[]> {
if (user.role === 'ADMIN') return classIdsFor(user, key, principal);
const grants = (principal.grants.get(key) ?? []).filter((g) =>
['GRADE', 'DIVISION', 'ALL'].includes(g.scope),
);
const broad = await classWhere(user, key, { ...principal, grants: new Map([[key, grants]]) });
const rows = await prisma.class.findMany({
where: { AND: [await classWhere(user, key, principal), { OR: [{ teacherId: user.sub }, broad] }] },
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 每项输入都携带权限键;关系一次读取,输出仍逐键与各自授权范围求交。 */
export async function actionScopesFor(
user: TokenPayload,
scopes: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
management: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
): Promise<Partial<Record<PermissionKey, readonly string[]>>> {
const result = { ...Object.fromEntries(scopes), ...Object.fromEntries(management) };
const related = scopes.filter(([key]) =>
['subject', 'publish', 'creator'].includes(PERMISSION_META[key].relation),
);
if (user.role === 'ADMIN' || !related.length) return result;
const rows = await prisma.class.findMany({
where: { id: { in: [...new Set(related.flatMap(([, ids]) => [...ids]))] } },
select: {
id: true,
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true }, take: 1 },
notifications: { where: { createdBy: user.sub }, select: { id: true }, take: 1 },
},
});
const relations = new Map(rows.map((row) => [row.id, row]));
for (const [key, ids] of related) {
const allowed = ids.filter((id) => {
const row = relations.get(id);
return (
row &&
(row.teacherId === user.sub ||
(PERMISSION_META[key].relation === 'creator'
? row.notifications.length > 0
: row.courses.length > 0))
);
});
// 成绩页还包含考试口径/查询开关,显式组织范围可维护这些配置。
result[key] =
key === 'grade:write'
? [...new Set([...allowed, ...(management.find(([k]) => k === key)?.[1] ?? [])])]
: allowed;
}
return result;
}
export async function assertClassInScope(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
if (!(await db.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })))
throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
/** 管理动作只有显式年级/部/全校分配可越过班主任关系。 */
export async function hasOrganizationalGrant(
user: TokenPayload,
key: PermissionKey,
classId?: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
if (user.role === 'ADMIN') return true;
const auth = await loadAuthPrincipal(user);
const grants = (auth.grants.get(key) ?? []).filter((g) => ['GRADE', 'DIVISION', 'ALL'].includes(g.scope));
if (!classId) return grants.some((g) => g.scope === 'ALL');
return (await classIdsFor(user, key, { ...auth, grants: new Map([[key, grants]]) }, db)).includes(classId);
}
+23
View File
@@ -0,0 +1,23 @@
import type { UserRole } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
export async function loadSessionUser(payload: TokenPayload): Promise<TokenPayload> {
if (payload.classSession && payload.role === 'CLASS_DEVICE') {
const cls = await prisma.class.findUnique({ where: { id: payload.classId ?? payload.sub } });
if (!cls || cls.archivedYearId) throw ApiError.unauthorized('班级不存在或已归档');
return {
sub: cls.id,
username: cls.code,
name: cls.name,
role: 'CLASS_DEVICE',
classId: cls.id,
classSession: true,
classCode: cls.code,
};
}
if (payload.role === 'CLASS_DEVICE') throw ApiError.unauthorized('无效班级会话');
const row = await prisma.user.findUnique({ where: { id: payload.sub } });
if (!row || !['ADMIN', 'TEACHER'].includes(row.role)) throw ApiError.unauthorized('账号不存在');
return { sub: row.id, username: row.username, name: row.name, role: row.role as UserRole, classId: null };
}
@@ -1,52 +0,0 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import type { Prisma } from '../generated/prisma/client.js';
import { parsePermissionScope, type PermissionScope, type PermissionKey } from '@classhelper/shared';
import { currentRequestPermission } from './permissions.js';
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
export async function teacherScopeWhere(
user: TokenPayload,
permission?: PermissionKey,
): Promise<Prisma.ClassWhereInput> {
permission ??= currentRequestPermission(user);
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
const groups = assignments.length
? assignments.map(({ group }) => group)
: await prisma.permissionGroup.findMany({ where: { defaultRole: user.role }, include: { items: true } });
const alternatives: Prisma.ClassWhereInput[] = [];
for (const group of groups) {
if (permission && !group.items.some((item) => item.key === permission)) continue;
try {
const parsed = parsePermissionScope(group.scope as PermissionScope, group.scopeValue);
if (parsed.scope === 'ALL') return {};
if (parsed.scope === 'SELF_CLASS')
alternatives.push(parsed.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
else alternatives.push({ grade: { in: parsed.grades } });
} catch {
/* 旧岗位组未配置或范围非法时不授予任何班级。 */
}
}
return { OR: alternatives };
}
export async function hasAllScope(user: TokenPayload, permission: PermissionKey) {
if (user.role === 'ADMIN') return true;
const links = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { groupId: true },
});
return (
(await prisma.permissionGroup.count({
where: {
...(links.length ? { id: { in: links.map((link) => link.groupId) } } : { defaultRole: user.role }),
scope: 'ALL',
items: { some: { key: permission } },
},
})) > 0
);
}
+11 -90
View File
@@ -1,94 +1,11 @@
import { import {
BUILTIN_TEACHER_GROUPS, BUILTIN_TEACHER_GROUPS,
PERMISSION_KEYS,
PERMISSION_LABELS, PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS, ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey, type PermissionKey,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { prisma } from './db.js'; import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
import { ApiError } from './http.js';
// 仅复用当前请求对象上的权限,绝不跨请求缓存。
const requestPermissions = new WeakMap<TokenPayload, ReadonlySet<PermissionKey>>();
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
requestPermission.set(user, key);
}
export function currentRequestPermission(user: TokenPayload) {
return requestPermission.get(user);
}
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
requestPermissions.set(user, permissions);
}
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await hasEffectivePermission(user, key)))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
bindRequestPermission(user, key);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(user, matched);
}
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
const group = await prisma.permissionGroup.findUnique({
where: { defaultRole: user.role },
include: { items: true },
});
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */ /** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
let defaultGroupsReady: Promise<void> | null = null; let defaultGroupsReady: Promise<void> | null = null;
@@ -114,10 +31,10 @@ async function createDefaultGroups(): Promise<void> {
}, },
}); });
} }
for (const role of ['ADMIN', 'TEACHER'] as const) { for (const role of ['TEACHER'] as const) {
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } })) if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
continue; continue;
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`; const baseName = '教师默认权限';
let name = baseName; let name = baseName;
let suffix = 1; let suffix = 1;
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。 // 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
@@ -132,18 +49,22 @@ async function createDefaultGroups(): Promise<void> {
defaultRole: role, defaultRole: role,
name, name,
builtin: true, builtin: true,
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS', scope: 'TEACHING',
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。', description: '未显式分配时使用此模板与固定任教班级范围。',
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) }, items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
}, },
}); });
} }
} }
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 把权限键翻译成中文提示(403 文案与权限页共用) */ /** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string { export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key]; return PERMISSION_LABELS[key];
} }
export { loadAuthPrincipal } from './authz/principal.js';
export { assertPermission, assertAnyPermission, assertPermissionOrClassDevice } from './authz/assert.js';
import { loadAuthPrincipal } from './authz/principal.js';
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (await loadAuthPrincipal(user)).permissions.has(key);
}
+23
View File
@@ -1,3 +1,6 @@
import { refreshClassSubscriptions } from '../realtime/bus.js';
import { invalidateAuthPrincipals } from './authz/principal.js';
import { migratePermissionScope } from '@classhelper/shared';
import type { PrismaClient } from '../generated/prisma/client.js'; import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js'; import type { DatabaseProvider } from './db.js';
@@ -183,6 +186,24 @@ export async function restoreSnapshot(
} }
for (const table of SNAPSHOT_TABLES) { for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data[table] ?? []).map((row) => { const rows = (snapshot.data[table] ?? []).map((row) => {
if (table === 'PermissionGroup') {
const mapped = migratePermissionScope(
String(row.scope ?? 'TEACHING'),
String(row.scopeValue ?? ''),
);
return {
...row,
...mapped,
...(row.defaultRole === 'TEACHER' ? { scope: 'TEACHING', scopeValue: '' } : {}),
};
}
if (table === 'UserPermissionGroup' && row.scope === undefined) {
const group = snapshot.data.PermissionGroup.find((g) => g.id === row.groupId);
return {
...row,
...migratePermissionScope(String(group?.scope ?? 'TEACHING'), String(group?.scopeValue ?? '')),
};
}
if (table === 'StudentClassTransfer') { if (table === 'StudentClassTransfer') {
const { mode, ...rest } = row; const { mode, ...rest } = row;
return { return {
@@ -209,6 +230,8 @@ export async function restoreSnapshot(
} finally { } finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined); await setForeignKeys(prisma, provider, true).catch(() => undefined);
} }
invalidateAuthPrincipals();
refreshClassSubscriptions();
return { counts }; return { counts };
} }
+12 -7
View File
@@ -1,3 +1,4 @@
import type { Prisma } from '../generated/prisma/client.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared'; import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
import { prisma } from './db.js'; import { prisma } from './db.js';
@@ -31,17 +32,20 @@ export async function changeStudentLifecycle(
status?: 'active' | 'inactive'; status?: 'active' | 'inactive';
}; };
}, },
transaction?: Prisma.TransactionClient,
) { ) {
const batchId = randomUUID(); const batchId = randomUUID();
const ids = [...new Set(input.studentIds)]; const ids = [...new Set(input.studentIds)];
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : []; const existing = ids.length
? await (transaction ?? prisma).student.findMany({ where: { id: { in: ids } } })
: [];
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试'); if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
for (const cls of new Set(existing.map((student) => student.classId))) for (const cls of new Set(existing.map((student) => student.classId)))
await assertCanManageRoster(user, cls ?? undefined); await assertCanManageRoster(user, cls ?? undefined, transaction);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId); if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction);
else if (!existing.length) await assertCanManageRoster(user); else if (!existing.length) await assertCanManageRoster(user, undefined, transaction);
const changedClasses: (string | null)[] = []; const changedClasses: (string | null)[] = [];
const result = await prisma.$transaction(async (tx) => { const work = async (tx: Prisma.TransactionClient) => {
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null; const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在'); if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入'); if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
@@ -160,7 +164,8 @@ export async function changeStudentLifecycle(
transfers: records.map(toStudentTransferDto), transfers: records.map(toStudentTransferDto),
studentId: students[0].id, studentId: students[0].id,
}; };
}); };
notifyStudentClasses(changedClasses); const result = transaction ? await work(transaction) : await prisma.$transaction(work);
if (!transaction) notifyStudentClasses(changedClasses);
return result; return result;
} }
+8 -49
View File
@@ -1,13 +1,9 @@
import type { Request, RequestHandler } from 'express'; import type { Request, RequestHandler } from 'express';
import type { PermissionKey, UserRole } from '@classhelper/shared'; import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared'; import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js'; import { ApiError } from '../lib/http.js';
import { import { loadAuthPrincipal } from '../lib/authz/index.js';
bindRequestPermission, import { loadSessionUser } from '../lib/authz/session.js';
bindRequestPermissions,
loadEffectivePermissions,
} from '../lib/permissions.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/** /**
@@ -25,44 +21,8 @@ export function authenticate(): RequestHandler {
const payload = verifyToken(token); const payload = verifyToken(token);
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表 const user = await loadSessionUser(payload);
if (payload.classSession) { req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions };
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
bindRequestPermissions(authUser, req.auth.permissions!);
next(); next();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -89,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
/** /**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。 * 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
* *
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。 * 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。 * 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/ */
export function requirePermission(...keys: PermissionKey[]): RequestHandler { export function requirePermission(...keys: PermissionKey[]): RequestHandler {
@@ -105,13 +65,12 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`)); next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return; return;
} }
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
next(); next();
}; };
} }
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */ /** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler { export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler {
const guard = requirePermission(...keys); const guard = requirePermission(...keys);
return (req, res, next) => { return (req, res, next) => {
if (req.auth?.user.role === 'CLASS_DEVICE') return next(); if (req.auth?.user.role === 'CLASS_DEVICE') return next();
@@ -139,8 +98,6 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
); );
return; return;
} }
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(req.auth.user, matched);
next(); next();
}; };
} }
@@ -161,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
export function hasAuthPermission(req: Request, key: PermissionKey): boolean { export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key); return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
} }
export const requireAdmin = (): RequestHandler => requireRole('ADMIN');
+14 -2
View File
@@ -1,5 +1,6 @@
import type { Request, RequestHandler } from 'express'; import type { Request, RequestHandler } from 'express';
import type { ZodType } from 'zod'; import { ZodError, type ZodType } from 'zod';
import { ApiError } from '../lib/http.js';
interface ValidationTargets<B, Q, P> { interface ValidationTargets<B, Q, P> {
body?: ZodType<B>; body?: ZodType<B>;
@@ -14,6 +15,7 @@ interface ValidationTargets<B, Q, P> {
*/ */
export function validate<B = unknown, Q = unknown, P = unknown>( export function validate<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>, targets: ValidationTargets<B, Q, P>,
errorStatus: 400 | 422 = 422,
): RequestHandler { ): RequestHandler {
return (req, _res, next) => { return (req, _res, next) => {
try { try {
@@ -22,11 +24,21 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown; if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
next(); next();
} catch (error) { } catch (error) {
next(error); next(
errorStatus === 400 && error instanceof ZodError
? ApiError.badRequest('请求参数不合法', error.issues)
: error,
);
} }
}; };
} }
export function validateBadRequest<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
): RequestHandler {
return validate(targets, 400);
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */ /** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */ /** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T { export function validatedBody<T>(req: Request): T {
+26 -7
View File
@@ -1,3 +1,5 @@
import type { PermissionKey } from '@classhelper/shared';
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import { import {
dayKeyLocal, dayKeyLocal,
@@ -84,6 +86,7 @@ export async function updateAiSettings(
user: TokenPayload, user: TokenPayload,
input: UpdateAiSettingsInput, input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> { ): Promise<AiSettingsDto> {
assertAdmin(user);
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false'); if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider); if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim()); if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
@@ -105,17 +108,33 @@ interface AssistantContext {
} }
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> { async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId, 'ai:use');
const classIds = await getAccessibleClassIds(user); await assertPermission(user, 'ai:use');
const candidates = new Set(
(
await Promise.all(
['notification:publish', 'homework:write', 'call:create'].map((key) =>
classIdsFor(user, key as PermissionKey),
),
)
).flat(),
);
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
const classes = await prisma.class.findMany({ const classes = await prisma.class.findMany({
where: { id: { in: classIds } }, where: {
id: { in: classIds },
...(user.role !== 'ADMIN'
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
: {}),
},
select: { id: true, name: true }, select: { id: true, name: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
take: 80, take: 80,
}); });
const courses = classId const courses =
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } }) classId && classIds.includes(classId)
: []; ? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses }; return { classes, courses };
} }
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
} }
if (typeof params.classId === 'string') { if (typeof params.classId === 'string') {
await assertClassAccess(user, params.classId); await assertClassAccess(user, params.classId, 'ai:use');
if (typeof params.courseId === 'string') { if (typeof params.courseId === 'string') {
const course = await prisma.course.findFirst({ const course = await prisma.course.findFirst({
where: { id: params.courseId, classId: params.classId }, where: { id: params.courseId, classId: params.classId },
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { restoreStudentSchema } from '../students/students.schemas.js'; import { restoreStudentSchema } from '../students/students.schemas.js';
import { listTransferred, restoreTransferred } from './transferred.service.js'; import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express'; import { Router } from 'express';
@@ -15,7 +16,7 @@ import {
import * as archiveService from './archives.service.js'; import * as archiveService from './archives.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate(), requireAdmin());
router.get('/transferred', requirePermission('archive:manage'), async (req, res) => router.get('/transferred', requirePermission('archive:manage'), async (req, res) =>
sendOk(res, await listTransferred(getAuthUser(req))), sendOk(res, await listTransferred(getAuthUser(req))),
); );
@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated } from '../../lib/http.js'; import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js'; import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -15,7 +15,7 @@ router.use(authenticate());
*/ */
router.post( router.post(
'/', '/',
requireStaffPermission('call:create'), requirePermissionOrClassDevice('call:create'),
validate({ body: createCallSchema }), validate({ body: createCallSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { assertStaffPermission } from '../../lib/permissions.js'; import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js'; import { resolveContentCreator } from '../../lib/access.js';
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js';
import { isActiveStudent } from '../../lib/students.js'; import { isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
@@ -29,8 +29,8 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。 * 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
*/ */
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> { export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertStaffPermission(user, 'call:create'); await assertPermissionOrClassDevice(user, 'call:create');
await assertCanPublishNotification(user, input.classId); await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true });
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { id: input.studentId }, where: { id: input.studentId },
@@ -9,8 +9,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
@@ -21,7 +22,7 @@ const layouts = Router();
layouts.use(authenticate()); layouts.use(authenticate());
layouts.get( layouts.get(
'/', '/',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }), validate({ query: schemas.scopeQuery }),
async (req, res) => async (req, res) =>
sendOk( sendOk(
@@ -31,26 +32,27 @@ layouts.get(
); );
layouts.post( layouts.post(
'/', '/',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ body: schemas.layoutSchema }), validate({ body: schemas.layoutSchema }),
async (req, res) => sendCreated(res, await service.saveLayout(req.body)), async (req, res) => sendCreated(res, await service.saveLayout(getAuthUser(req), req.body)),
); );
layouts.put( layouts.put(
'/:id', '/:id',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.layoutSchema }), validate({ params: idParamSchema, body: schemas.layoutSchema }),
async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))), async (req, res) =>
sendOk(res, await service.saveLayout(getAuthUser(req), req.body, String(req.params.id))),
); );
layouts.post( layouts.post(
'/:id/clone', '/:id/clone',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.cloneSchema }), validate({ params: idParamSchema, body: schemas.cloneSchema }),
async (req, res) => { async (req, res) => {
const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id); const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id);
if (!current) throw ApiError.notFound('时间表不存在'); if (!current) throw ApiError.notFound('时间表不存在');
sendCreated( sendCreated(
res, res,
await service.saveLayout({ await service.saveLayout(getAuthUser(req), {
...current, ...current,
name: req.body.name || `${current.name} 副本`, name: req.body.name || `${current.name} 副本`,
points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -60,7 +62,7 @@ layouts.post(
); );
layouts.post( layouts.post(
'/:id/import', '/:id/import',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.importLayoutSchema }), validate({ params: idParamSchema, body: schemas.importLayoutSchema }),
async (req, res) => { async (req, res) => {
const all = await service.listLayouts(getAuthUser(req)); const all = await service.listLayouts(getAuthUser(req));
@@ -70,6 +72,7 @@ layouts.post(
sendOk( sendOk(
res, res,
await service.saveLayout( await service.saveLayout(
getAuthUser(req),
{ {
...target, ...target,
points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -81,21 +84,21 @@ layouts.post(
); );
layouts.post( layouts.post(
'/:id/points/:pointId/apply-default', '/:id/points/:pointId/apply-default',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }), validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
async (req, res) => async (req, res) =>
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))), sendOk(
res,
await service.applyDefaultCourse(getAuthUser(req), String(req.params.id), String(req.params.pointId)),
),
); );
layouts.delete( layouts.delete(
'/:id', '/:id',
requirePermission('timetable:manage'), requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const id = String(req.params.id); const id = String(req.params.id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } })) await service.deleteLayout(getAuthUser(req), id);
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await service.notifyScheduleChanged();
sendOk(res, { id }); sendOk(res, { id });
}, },
); );
@@ -104,7 +107,7 @@ const plans = Router();
plans.use(authenticate()); plans.use(authenticate());
plans.get( plans.get(
'/profile', '/profile',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }), validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)), sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
@@ -127,7 +130,7 @@ plans.post(
); );
plans.get( plans.get(
'/', '/',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }), validate({ query: schemas.scopeQuery }),
async (req, res) => { async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req); const q = validatedQuery<{ classId?: string; week?: number }>(req);
@@ -175,7 +178,7 @@ plans.post(
); );
plans.get( plans.get(
'/:id/export', '/:id/export',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
@@ -207,22 +210,22 @@ const groups = Router();
groups.use(authenticate()); groups.use(authenticate());
groups.get( groups.get(
'/selection', '/selection',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }), validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => { async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId); await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }); const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {}); sendOk(res, value ? JSON.parse(value.value) : {});
}, },
); );
groups.get( groups.get(
'/', '/',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }), validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => { async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId); await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } })); sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
}, },
); );
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { SOCKET_EVENTS } from '@classhelper/shared'; import { SOCKET_EVENTS } from '@classhelper/shared';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js'; import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js';
@@ -24,20 +25,24 @@ export async function notifyScheduleChanged(classId?: string | null) {
for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' }); for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' });
} }
export async function listLayouts(user: TokenPayload, classId?: string) { export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId); await assertPermissionOrClassDevice(user, 'schedule:read');
const ids = classId ? [classId] : await getAccessibleClassIds(user); if (classId) await assertClassAccess(user, classId, 'schedule:read');
const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read');
const rows = await prisma.timeLayout.findMany({ const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] }, where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } }, include: { points: { orderBy: { position: 'asc' } } },
}); });
return Promise.all(rows.map((row) => decorateLayout(row))); return Promise.all(rows.map((row) => decorateLayout(row)));
} }
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) { export async function saveLayout(user: TokenPayload, input: z.infer<typeof layoutSchema>, id?: string) {
const existing = id const existing = id
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } }) ? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
: null; : null;
if (id && !existing) throw ApiError.notFound('时间表不存在'); if (id && !existing) throw ApiError.notFound('时间表不存在');
const layoutClassId = existing ? existing.classId : (input.classId ?? null); const layoutClassId = existing ? existing.classId : (input.classId ?? null);
await assertLayoutWrite(user, layoutClassId, id);
if (existing && input.classId !== undefined && input.classId !== existing.classId)
throw ApiError.forbidden('不能移动时间表到其他班级');
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。 // 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
const points = input.points.map((point) => ({ const points = input.points.map((point) => ({
...point, ...point,
@@ -83,12 +88,13 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。 * 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
* 只处理班级时间表:全校时间表的默认课程没有归属班级。 * 只处理班级时间表:全校时间表的默认课程没有归属班级。
*/ */
export async function applyDefaultCourse(layoutId: string, pointId: string) { export async function applyDefaultCourse(user: TokenPayload, layoutId: string, pointId: string) {
const layout = await prisma.timeLayout.findUnique({ const layout = await prisma.timeLayout.findUnique({
where: { id: layoutId }, where: { id: layoutId },
include: { points: true }, include: { points: true },
}); });
if (!layout) throw ApiError.notFound('时间表不存在'); if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, layout.id);
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程'); if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
const point = layout.points.find((p) => p.id === pointId); const point = layout.points.find((p) => p.id === pointId);
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点'); if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
@@ -121,10 +127,11 @@ export async function applyDefaultCourse(layoutId: string, pointId: string) {
return { plans: plans.length }; return { plans: plans.length };
} }
export async function listPlans(user: TokenPayload, classId?: string, week?: number) { export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId); await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
return prisma.classPlan.findMany({ return prisma.classPlan.findMany({
where: { where: {
classId: { in: classId ? [classId] : await getAccessibleClassIds(user) }, classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') },
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}), ...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
}, },
include: planInclude, include: planInclude,
@@ -134,7 +141,8 @@ export async function listPlans(user: TokenPayload, classId?: string, week?: num
export async function requirePlan(user: TokenPayload, id: string, write = true) { export async function requirePlan(user: TokenPayload, id: string, write = true) {
const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude }); const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude });
if (!p) throw ApiError.notFound('课表不存在'); if (!p) throw ApiError.notFound('课表不存在');
await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId); if (write) await assertCanManageSchedule(user, p.classId);
else await assertClassAccess(user, p.classId, 'schedule:read');
return p; return p;
} }
export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) { export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) {
@@ -285,3 +293,24 @@ export async function createOverlay(
await notifyScheduleChanged(source.classId); await notifyScheduleChanged(source.classId);
return result; return result;
} }
export async function assertLayoutWrite(user: TokenPayload, classId: string | null, layoutId?: string) {
if (classId) await assertHeadOnly(user, 'schedule:write', classId);
else assertAdmin(user);
if (layoutId) {
const refs = await prisma.classPlan.findMany({
where: { timeLayoutId: layoutId },
select: { classId: true },
});
for (const id of new Set(refs.map((p) => p.classId))) await assertHeadOnly(user, 'schedule:write', id);
}
}
export async function deleteLayout(user: TokenPayload, id: string) {
const layout = await prisma.timeLayout.findUnique({ where: { id } });
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await notifyScheduleChanged(layout.classId);
}
@@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */ /** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
export async function exportProfile(user: TokenPayload, classId: string) { export async function exportProfile(user: TokenPayload, classId: string) {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'schedule:read');
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([ const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
prisma.class.findUniqueOrThrow({ where: { id: classId } }), prisma.class.findUniqueOrThrow({ where: { id: classId } }),
prisma.timeLayout.findMany({ prisma.timeLayout.findMany({
@@ -4,8 +4,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
requireAdmin,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
@@ -19,6 +20,8 @@ import {
promoteClassesSchema, promoteClassesSchema,
updateClassAccountSchema, updateClassAccountSchema,
updateClassSchema, updateClassSchema,
updateClassSettingsSchema,
type UpdateClassSettingsInput,
updateStudentGradeQuerySchema, updateStudentGradeQuerySchema,
type AddStudentInput, type AddStudentInput,
type AssignSubjectTeachersInput, type AssignSubjectTeachersInput,
@@ -42,7 +45,7 @@ router.use(authenticate());
*/ */
router.get( router.get(
'/', '/',
requireStaffPermission('class:read'), requirePermissionOrClassDevice('class:read'),
validate({ query: listClassesQuerySchema }), validate({ query: listClassesQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -59,6 +62,7 @@ router.get(
*/ */
router.post( router.post(
'/promote', '/promote',
requireAdmin(),
requirePermission('promotion:manage'), requirePermission('promotion:manage'),
validate({ body: promoteClassesSchema }), validate({ body: promoteClassesSchema }),
async (req, res) => { async (req, res) => {
@@ -71,7 +75,7 @@ router.post(
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */ /** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
router.get( router.get(
'/:id', '/:id',
requireStaffPermission('class:read'), requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -83,6 +87,7 @@ router.get(
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */ /** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post( router.post(
'/', '/',
requireAdmin(),
requirePermission('class:manage'), requirePermission('class:manage'),
validate({ body: createClassSchema }), validate({ body: createClassSchema }),
async (req, res) => { async (req, res) => {
@@ -94,6 +99,7 @@ router.post(
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */ /** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
router.patch( router.patch(
'/:id', '/:id',
requireAdmin(),
requirePermission('class:manage'), requirePermission('class:manage'),
(req, _res, next) => { (req, _res, next) => {
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改')); if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
@@ -107,9 +113,25 @@ router.patch(
}, },
); );
router.patch(
'/:id/settings',
requirePermission('class:settings'),
validate({ params: idParamSchema, body: updateClassSettingsSchema }),
async (req, res) => {
sendOk(
res,
await classService.updateClassSettings(
getAuthUser(req),
String(req.params.id),
validatedBody<UpdateClassSettingsInput>(req),
),
);
},
);
/** DELETE /api/classes/:id - 删除班级 */ /** DELETE /api/classes/:id - 删除班级 */
router.delete( router.delete(
'/:id', '/:id',
requireAdmin(),
requirePermission('class:manage'), requirePermission('class:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
@@ -126,7 +148,7 @@ router.delete(
*/ */
router.patch( router.patch(
'/:id/class-account', '/:id/class-account',
requirePermission('class:manage'), requirePermission('class:account'),
validate({ params: idParamSchema, body: updateClassAccountSchema }), validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -145,6 +167,7 @@ router.patch(
*/ */
router.patch( router.patch(
'/:id/head-teacher', '/:id/head-teacher',
requireAdmin(),
requirePermission('class:manage'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }), validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => { async (req, res) => {
@@ -164,6 +187,7 @@ router.patch(
*/ */
router.put( router.put(
'/:id/subject-teachers', '/:id/subject-teachers',
requireAdmin(),
requirePermission('class:manage'), requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }), validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => { async (req, res) => {
@@ -183,7 +207,7 @@ router.put(
*/ */
router.get( router.get(
'/:id/student-grade-query', '/:id/student-grade-query',
requireStaffPermission('class:read'), requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -210,7 +234,7 @@ router.patch(
/** GET /api/classes/:id/students - 班级学生名单 */ /** GET /api/classes/:id/students - 班级学生名单 */
router.get( router.get(
'/:id/students', '/:id/students',
requireStaffPermission('roster:read'), requirePermissionOrClassDevice('roster:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -41,12 +41,15 @@ export const createClassSchema = z.object({
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。 * `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
*/ */
export const updateClassSchema = z.strictObject({ export const updateClassSchema = z.strictObject({
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
});
export const updateClassSettingsSchema = z.strictObject({
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */ /** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
termStartDate: z termStartDate: z
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')]) .union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
.nullish(), .nullish(),
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
termWeeks: z.coerce.number().int().min(1).max(60).optional(), termWeeks: z.coerce.number().int().min(1).max(60).optional(),
}); });
@@ -125,3 +128,5 @@ export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>; export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>; export type AddStudentInput = z.infer<typeof addStudentSchema>;
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>; export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
export type UpdateClassSettingsInput = z.infer<typeof updateClassSettingsSchema>;
@@ -1,3 +1,6 @@
import { disconnectUserSessions, refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertHeadOnly, assertManage } from '../../lib/authz/index.js';
import { auditData } from '../permissions/permissions.service.js';
import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared'; import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared'; import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
@@ -23,12 +26,17 @@ import type {
AssignSubjectTeachersInput, AssignSubjectTeachersInput,
CreateClassInput, CreateClassInput,
UpdateClassInput, UpdateClassInput,
UpdateClassSettingsInput,
UpdateStudentGradeQueryInput, UpdateStudentGradeQueryInput,
} from './classes.schemas.js'; } from './classes.schemas.js';
import { loadPromotionPlan } from '../term/promotion.service.js'; import { loadPromotionPlan } from '../term/promotion.service.js';
import { assertClassInScope, canAccessClass } from '../../lib/access.js'; import { assertClassInScope, canAccessClass } from '../../lib/access.js';
import { changeStudentLifecycle } from '../../lib/student-lifecycle.js'; import { changeStudentLifecycle } from '../../lib/student-lifecycle.js';
import { assertStaffPermission, assertPermission, hasEffectivePermission } from '../../lib/permissions.js'; import {
assertPermissionOrClassDevice,
assertPermission,
hasEffectivePermission,
} from '../../lib/permissions.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import { getMaxTermWeeks } from '../../lib/term.js'; import { getMaxTermWeeks } from '../../lib/term.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
@@ -79,7 +87,7 @@ function toClassDtoWithAccount(
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。 * 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
*/ */
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> { export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
await assertStaffPermission(user, 'class:read'); await assertPermissionOrClassDevice(user, 'class:read');
const scope = await resolveClassScope(user, undefined, 'class:read'); const scope = await resolveClassScope(user, undefined, 'class:read');
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表) // Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = { const where = {
@@ -102,8 +110,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */ /** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> { export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
await assertStaffPermission(user, 'class:read'); await assertPermissionOrClassDevice(user, 'class:read');
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({ const item = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
@@ -147,7 +155,7 @@ export async function listSubjectTeachers(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> { ): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({ const item = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
include: { include: {
@@ -206,6 +214,13 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
}, },
}); });
await prisma.permissionAudit.create({
data: auditData(user, 'class-create', 'class', created.id, null, {
name: created.name,
teacherId: created.teacherId,
}),
});
refreshClassSubscriptions();
return toClassDtoWithAccount(user, created); return toClassDtoWithAccount(user, created);
} }
@@ -222,6 +237,10 @@ export async function updateHeadTeacher(
teacherId: string, teacherId: string,
): Promise<ClassDto> { ): Promise<ClassDto> {
await assertCanAssignTeachers(user, classId); await assertCanAssignTeachers(user, classId);
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { teacherId: true },
});
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师'); const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
const updated = await prisma.class.update({ const updated = await prisma.class.update({
@@ -233,6 +252,10 @@ export async function updateHeadTeacher(
}, },
}); });
await prisma.permissionAudit.create({
data: auditData(user, 'class-head', 'class', classId, before, { teacherId: targetId }),
});
disconnectUserSessions([before.teacherId, targetId].filter((id): id is string => !!id));
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' }); emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
return toClassDtoWithAccount(user, updated); return toClassDtoWithAccount(user, updated);
} }
@@ -256,6 +279,10 @@ export async function assignSubjectTeachers(
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!targetClass) throw ApiError.notFound('班级不存在'); if (!targetClass) throw ApiError.notFound('班级不存在');
const before = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true, teacherId: true },
});
for (const assignment of input.assignments) { for (const assignment of input.assignments) {
const teacherId = assignment.teacherId const teacherId = assignment.teacherId
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`) ? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
@@ -290,6 +317,17 @@ export async function assignSubjectTeachers(
where: { id: classId }, where: { id: classId },
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } }, include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
}); });
await prisma.permissionAudit.create({
data: auditData(
user,
'class-subjects',
'class',
classId,
before,
refreshed.courses.map((c) => ({ id: c.id, name: c.name, teacherId: c.teacherId })),
),
});
disconnectUserSessions([...new Set([...before, ...refreshed.courses].map((c) => c.teacherId))]);
return refreshed.courses.map((course) => ({ return refreshed.courses.map((course) => ({
courseId: course.id, courseId: course.id,
subjectName: course.name, subjectName: course.name,
@@ -304,7 +342,8 @@ export async function updateClassAccount(
classId: string, classId: string,
input: { code?: string; password?: string }, input: { code?: string; password?: string },
): Promise<ClassDto> { ): Promise<ClassDto> {
await assertCanManageClasses(user, classId); await assertHeadOnly(user, 'class:account', classId);
const before = await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { code: true } });
if ( if (
'termWeeks' in input && 'termWeeks' in input &&
typeof input.termWeeks === 'number' && typeof input.termWeeks === 'number' &&
@@ -322,6 +361,12 @@ export async function updateClassAccount(
}, },
}); });
if (!updated) throw ApiError.notFound('班级不存在'); if (!updated) throw ApiError.notFound('班级不存在');
await prisma.permissionAudit.create({
data: auditData(user, 'class-account', 'class', classId, before, {
code: updated.code,
passwordChanged: input.password !== undefined,
}),
});
return toClassDtoWithAccount(user, updated); return toClassDtoWithAccount(user, updated);
} }
@@ -340,19 +385,12 @@ export async function updateClass(
if (!current) throw ApiError.notFound('班级不存在'); if (!current) throw ApiError.notFound('班级不存在');
if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口'); if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口');
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear; const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
const classIndex = input.classIndex ?? current.classIndex; const classIndex = input.classIndex ?? current.classIndex;
const updated = await prisma.class.update({ const updated = await prisma.class.update({
where: { id: classId }, where: { id: classId },
data: { data: {
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
...(input.termStartDate !== undefined
? { termStartDate: input.termStartDate ? input.termStartDate : null }
: {}),
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}), ...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
...(input.classIndex !== undefined ? { classIndex } : {}), ...(input.classIndex !== undefined ? { classIndex } : {}),
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」 // 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
@@ -364,10 +402,45 @@ export async function updateClass(
}, },
}); });
await prisma.permissionAudit.create({
data: auditData(user, 'class-update', 'class', classId, current, {
enrollmentYear: updated.enrollmentYear,
classIndex: updated.classIndex,
name: updated.name,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated); return toClassDto(updated);
} }
export async function updateClassSettings(
user: TokenPayload,
classId: string,
input: UpdateClassSettingsInput,
): Promise<ClassDto> {
await assertHeadOnly(user, 'class:settings', classId);
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { termWeeks: true, termStartDate: true },
});
const updated = await prisma.class.update({
where: { id: classId },
data: {
termWeeks: input.termWeeks,
...(input.termStartDate !== undefined ? { termStartDate: input.termStartDate || null } : {}),
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-settings', 'class', classId, before, {
termWeeks: updated.termWeeks,
termStartDate: updated.termStartDate,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
/** /**
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。 * 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
* *
@@ -405,6 +478,7 @@ export async function promoteClasses(
} }
}); });
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
refreshClassSubscriptions();
return { promoted: ids.length }; return { promoted: ids.length };
} }
@@ -414,13 +488,21 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
if (await prisma.student.count({ where: { classId } })) if (await prisma.student.count({ where: { classId } }))
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属'); throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' }); emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { id: true, name: true, grade: true },
});
await prisma.class.delete({ where: { id: classId } }); await prisma.class.delete({ where: { id: classId } });
refreshClassSubscriptions();
await prisma.permissionAudit.create({
data: auditData(user, 'class-delete', 'class', classId, before, null),
});
} }
/** 班级学生名单 */ /** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> { export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read'); await assertPermissionOrClassDevice(user, 'roster:read');
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'roster:read');
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
where: { classId, ...activeStudentWhere() }, where: { classId, ...activeStudentWhere() },
include: { class: { select: { name: true, grade: true } } }, include: { class: { select: { name: true, grade: true } } },
@@ -472,7 +554,7 @@ export async function getStudentGradeQuery(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
): Promise<{ studentGradeQueryEnabled: boolean }> { ): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'class:read');
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
select: { studentGradeQueryEnabled: true }, select: { studentGradeQueryEnabled: true },
@@ -493,7 +575,7 @@ export async function updateStudentGradeQuery(
classId: string, classId: string,
input: UpdateStudentGradeQueryInput, input: UpdateStudentGradeQueryInput,
): Promise<{ studentGradeQueryEnabled: boolean }> { ): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId); await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) { if (!isStaff(user)) {
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读'); throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
} }
@@ -2,7 +2,7 @@ import { Router } from 'express';
import type { ClientUpdateProgressDto } from '@classhelper/shared'; import type { ClientUpdateProgressDto } from '@classhelper/shared';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requireRole, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -22,14 +22,19 @@ import * as clientService from './clients.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate());
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => { router.post(
const input = validatedBody<{ '/update-progress',
deviceKey: string; requireRole('CLASS_DEVICE'),
progress: ClientUpdateProgressDto; validate({ body: clientProgressSchema }),
}>(req); async (req, res) => {
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress); const input = validatedBody<{
sendOk(res, null); deviceKey: string;
}); progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
},
);
/** /**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次 * POST /api/clients/heartbeat - 客户端每 30 秒上报一次
@@ -37,22 +42,28 @@ router.post('/update-progress', validate({ body: clientProgressSchema }), async
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。 * **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。 * 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
*/ */
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => { router.post(
const user = getAuthUser(req); '/heartbeat',
const input = validatedBody<ClientHeartbeatInput>(req); requireRole('CLASS_DEVICE'),
const result = await clientService.heartbeat(user, input, req.ip ?? ''); validate({ body: clientHeartbeatSchema }),
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。 async (req, res) => {
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result); const user = getAuthUser(req);
}); const input = validatedBody<ClientHeartbeatInput>(req);
const result = await clientService.heartbeat(user, input, req.ip ?? '');
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
},
);
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */ /** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
router.post( router.post(
'/commands/:id/ack', '/commands/:id/ack',
requireRole('CLASS_DEVICE'),
validate({ params: idParamSchema, body: clientCommandAckSchema }), validate({ params: idParamSchema, body: clientCommandAckSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<ClientCommandAckInput>(req); const input = validatedBody<ClientCommandAckInput>(req);
sendOk(res, await clientService.ackCommand(id, input), '回执已记录'); sendOk(res, await clientService.ackCommand(getAuthUser(req), id, input), '回执已记录');
}, },
); );
@@ -79,7 +90,7 @@ router.post(
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */ /** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get( router.get(
'/devices', '/devices',
requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'), requirePermission('client:read'),
validate({ query: listClientDevicesQuerySchema }), validate({ query: listClientDevicesQuerySchema }),
async (req, res) => { async (req, res) => {
const query = validatedQuery<ListClientDevicesQuery>(req); const query = validatedQuery<ListClientDevicesQuery>(req);
@@ -94,7 +105,7 @@ router.get(
/** POST /api/clients/updates - 统一下发客户端更新 */ /** POST /api/clients/updates - 统一下发客户端更新 */
router.post( router.post(
'/updates', '/updates',
requirePermission('client:update'), requirePermission('client:update-push'),
validate({ body: pushClientUpdateSchema }), validate({ body: pushClientUpdateSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { assertClassDevice } from '../../lib/authz/index.js';
import { import {
CLIENT_COMMAND_EXPIRE_MS, CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS, CLIENT_COMMAND_LABELS,
@@ -136,6 +137,11 @@ export async function heartbeat(
input: ClientHeartbeatInput, input: ClientHeartbeatInput,
ip: string, ip: string,
): Promise<ClientHeartbeatResponse> { ): Promise<ClientHeartbeatResponse> {
const boundClassId = resolveClassId(user);
assertClassDevice(user, boundClassId ?? '');
const previousDevice = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (previousDevice && previousDevice.classId !== boundClassId)
throw ApiError.forbidden('设备不属于当前班级');
const now = new Date(); const now = new Date();
const heartbeatAt = { const heartbeatAt = {
classId: resolveClassId(user), classId: resolveClassId(user),
@@ -149,11 +155,17 @@ export async function heartbeat(
lastIp: ip, lastIp: ip,
}; };
const device = (await prisma.clientDevice.upsert({ const device = (await prisma.clientDevice
where: { deviceKey: input.deviceKey }, .upsert({
create: { deviceKey: input.deviceKey, ...heartbeatAt }, where: { deviceKey: input.deviceKey, classId: boundClassId },
update: heartbeatAt, create: { deviceKey: input.deviceKey, ...heartbeatAt },
})) as unknown as DeviceRow; update: heartbeatAt,
})
.catch(async (error: unknown) => {
const current = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (current && current.classId !== boundClassId) throw ApiError.forbidden('设备不属于当前班级');
throw error;
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim(); const targetVersion = device.updateTargetVersion.trim();
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) { if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
@@ -204,7 +216,7 @@ export async function reportUpdateProgress(
progress: ClientUpdateProgressDto, progress: ClientUpdateProgressDto,
) { ) {
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } }); const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。 assertClassDevice(user, device?.classId ?? '');
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级'); if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
// 已取消或更换目标的迟到进度不得覆盖当前任务。 // 已取消或更换目标的迟到进度不得覆盖当前任务。
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return; if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
@@ -240,14 +252,16 @@ function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */ /** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand( export async function ackCommand(
user: TokenPayload,
commandId: string, commandId: string,
input: ClientCommandAckRequest, input: ClientCommandAckRequest,
): Promise<ClientCommandDto> { ): Promise<ClientCommandDto> {
const command = (await prisma.clientCommand.findUnique({ const command = (await prisma.clientCommand.findUnique({
where: { id: commandId }, where: { id: commandId },
include: { device: { select: { deviceKey: true, name: true } } }, include: { device: { select: { deviceKey: true, name: true, classId: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null; })) as (CommandRow & { device: { deviceKey: string; name: string; classId: string | null } }) | null;
if (!command) throw ApiError.notFound('命令不存在'); if (!command) throw ApiError.notFound('命令不存在');
assertClassDevice(user, command.device.classId ?? '');
if (command.device.deviceKey !== input.deviceKey) { if (command.device.deviceKey !== input.deviceKey) {
throw ApiError.forbidden('这条命令不是发给这台电脑的'); throw ApiError.forbidden('这条命令不是发给这台电脑的');
@@ -271,7 +285,7 @@ export async function listDevices(
user: TokenPayload, user: TokenPayload,
options: { classId?: string } = {}, options: { classId?: string } = {},
): Promise<ClientDeviceDto[]> { ): Promise<ClientDeviceDto[]> {
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'client:read');
const rows = (await prisma.clientDevice.findMany({ const rows = (await prisma.clientDevice.findMany({
where: classScopeWhere(scope), where: classScopeWhere(scope),
include: { class: { select: { id: true, name: true } } }, include: { class: { select: { id: true, name: true } } },
@@ -394,7 +408,7 @@ export async function createCommands(
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */ /** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> { async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update')); const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update-push'));
const deviceIds = [...new Set(input.deviceIds ?? [])]; const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) { if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({ const rows = (await prisma.clientDevice.findMany({
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -24,7 +24,7 @@ router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */ /** GET /api/courses?classId= - 课程列表 */
router.get( router.get(
'/', '/',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: listCoursesQuerySchema }), validate({ query: listCoursesQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import type { CourseDto } from '@classhelper/shared'; import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js'; import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
@@ -9,7 +10,7 @@ import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js'
const teacherSelect = { select: { id: true, name: true, username: true } } as const; const teacherSelect = { select: { id: true, name: true, username: true } } as const;
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> { export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
const scope = await resolveClassScope(user, classId); const scope = await resolveClassScope(user, classId, 'schedule:read');
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: classScopeWhere(scope), where: classScopeWhere(scope),
include: { teacher: teacherSelect }, include: { teacher: teacherSelect },
@@ -26,7 +27,7 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput)
* *
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师, * 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 —— * 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设 * 后面那道 `` 的科目门随即形同虚设
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师 * (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
* 能布置其他科作业"的入口。 * 能布置其他科作业"的入口。
* *
@@ -82,4 +83,5 @@ export async function deleteCourse(user: TokenPayload, courseId: string): Promis
if (!course) throw ApiError.notFound('课程不存在'); if (!course) throw ApiError.notFound('课程不存在');
await assertCanManageSchedule(user, course.classId); await assertCanManageSchedule(user, course.classId);
await prisma.course.delete({ where: { id: courseId } }); await prisma.course.delete({ where: { id: courseId } });
refreshClassSubscriptions();
} }
@@ -6,7 +6,7 @@ import {
getAuthUser, getAuthUser,
requireRole, requireRole,
requirePermission, requirePermission,
requireStaffPermission, requirePermissionOrClassDevice,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js'; import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
@@ -22,7 +22,7 @@ import * as service from './courseware.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate());
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')]; const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
router.get('/', requireStaffPermission('courseware:read'), async (req, res) => router.get('/', requirePermissionOrClassDevice('courseware:read'), async (req, res) =>
sendOk(res, await service.listCourseware(getAuthUser(req))), sendOk(res, await service.listCourseware(getAuthUser(req))),
); );
router.get('/classes', ...staff, async (req, res) => router.get('/classes', ...staff, async (req, res) =>
@@ -83,10 +83,14 @@ router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), asy
); );
router.get( router.get(
'/:id/download', '/:id/download',
requireStaffPermission('courseware:read'), requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res, next) => { async (req, res, next) => {
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id); const row = await service.readable(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
'courseware:read',
);
res.setHeader('Cache-Control', 'private, no-store'); res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => { res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
@@ -127,7 +131,7 @@ router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req,
}); });
router.get( router.get(
'/:id', '/:id',
requireStaffPermission('courseware:read'), requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => async (req, res) =>
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)), sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
@@ -1,3 +1,9 @@
import {
assertContentWrite,
assertPermission,
assertPermissionOrClassDevice,
classWhere,
} from '../../lib/authz/index.js';
import fs from 'node:fs'; import fs from 'node:fs';
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -17,7 +23,7 @@ import {
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js'; import { requireClassDeviceClassId } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import type { Prisma } from '../../generated/prisma/client.js'; import type { Prisma } from '../../generated/prisma/client.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
@@ -47,13 +53,20 @@ function dto(row: RecordWithRelations): CoursewareDto {
}; };
} }
export async function teachingClasses(user: TokenPayload) { export async function teachingClasses(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
return prisma.class.findMany({ return prisma.class.findMany({
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } }, where: {
AND: [
{ archivedYearId: null, courses: { some: { teacherId: user.sub } } },
await classWhere(user, 'courseware:write'),
],
},
select: { id: true, name: true }, select: { id: true, name: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
} }
async function validateClasses(user: TokenPayload, ids: string[]) { async function validateClasses(user: TokenPayload, ids: string[]) {
await assertPermission(user, 'courseware:write');
const classIds = [...new Set(ids)]; const classIds = [...new Set(ids)];
const allowed = new Set((await teachingClasses(user)).map((item) => item.id)); const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级'); if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
@@ -62,11 +75,12 @@ async function validateClasses(user: TokenPayload, ids: string[]) {
where: { classId, teacherId: user.sub }, where: { classId, teacherId: user.sub },
select: { id: true }, select: { id: true },
}); });
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件'); await assertContentWrite(user, 'courseware:write', classId, course?.id, '发布课件');
} }
return classIds; return classIds;
} }
export async function readable(user: TokenPayload, id: string) { export async function readable(user: TokenPayload, id: string, key: 'courseware:read' | 'courseware:write') {
await assertPermissionOrClassDevice(user, key);
const row = await prisma.courseware.findUnique({ where: { id }, include }); const row = await prisma.courseware.findUnique({ where: { id }, include });
if (!row) throw ApiError.notFound('课件不存在'); if (!row) throw ApiError.notFound('课件不存在');
if (user.role === 'CLASS_DEVICE') { if (user.role === 'CLASS_DEVICE') {
@@ -77,17 +91,19 @@ export async function readable(user: TokenPayload, id: string) {
return row; return row;
} }
async function owned(user: TokenPayload, id: string) { async function owned(user: TokenPayload, id: string) {
const row = await readable(user, id); await assertPermission(user, 'courseware:write');
const row = await readable(user, id, 'courseware:write');
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件'); if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
return row; return row;
} }
export async function getCourseware(user: TokenPayload, id: string) { export async function getCourseware(user: TokenPayload, id: string) {
const item = dto(await readable(user, id)); const item = dto(await readable(user, id, 'courseware:read'));
if (user.role === 'CLASS_DEVICE') if (user.role === 'CLASS_DEVICE')
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user)); item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
return item; return item;
} }
export async function listCourseware(user: TokenPayload) { export async function listCourseware(user: TokenPayload) {
await assertPermissionOrClassDevice(user, 'courseware:read');
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null; const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
const rows = await prisma.courseware.findMany({ const rows = await prisma.courseware.findMany({
where: classId where: classId
@@ -177,6 +193,7 @@ async function assertFolder(user: TokenPayload, folderPath: string) {
throw ApiError.badRequest('请选择自己创建的文件夹'); throw ApiError.badRequest('请选择自己创建的文件夹');
} }
export async function listFolders(user: TokenPayload) { export async function listFolders(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
const rows = await prisma.coursewareFolder.findMany({ const rows = await prisma.coursewareFolder.findMany({
where: { ownerId: user.sub }, where: { ownerId: user.sub },
orderBy: { path: 'asc' }, orderBy: { path: 'asc' },
@@ -184,6 +201,7 @@ export async function listFolders(user: TokenPayload) {
return rows.map((row) => ({ id: row.id, path: row.path })); return rows.map((row) => ({ id: row.id, path: row.path }));
} }
export async function createFolder(user: TokenPayload, folderPath: string) { export async function createFolder(user: TokenPayload, folderPath: string) {
await assertPermission(user, 'courseware:write');
const row = await prisma.coursewareFolder.upsert({ const row = await prisma.coursewareFolder.upsert({
where: { ownerId_path: { ownerId: user.sub, path: folderPath } }, where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
create: { ownerId: user.sub, path: folderPath }, create: { ownerId: user.sub, path: folderPath },
@@ -259,7 +277,7 @@ export async function acknowledgeTransfer(user: TokenPayload, pushId: string, de
const device = await deviceFor(user, deviceKey); const device = await deviceFor(user, deviceKey);
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } }); const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
if (!push) throw ApiError.notFound('传输任务不存在'); if (!push) throw ApiError.notFound('传输任务不存在');
await readable(user, push.coursewareId); await readable(user, push.coursewareId, 'courseware:read');
await prisma.coursewareReceipt.upsert({ await prisma.coursewareReceipt.upsert({
where: { pushId_deviceId: { pushId, deviceId: device.id } }, where: { pushId_deviceId: { pushId, deviceId: device.id } },
create: { pushId, deviceId: device.id }, create: { pushId, deviceId: device.id },
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendOk } from '../../lib/http.js'; import { sendOk } from '../../lib/http.js';
import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js'; import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import * as dashboardService from './dashboard.service.js'; import * as dashboardService from './dashboard.service.js';
@@ -9,7 +9,7 @@ const router = Router();
router.use(authenticate()); router.use(authenticate());
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */ /** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res) => { router.get('/summary', requirePermissionOrClassDevice('dashboard:read'), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功'); sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
}); });
@@ -21,7 +21,7 @@ router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。 * 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。 * 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
*/ */
router.get('/term', requireStaffPermission('term:read'), async (req, res) => { router.get('/term', requirePermissionOrClassDevice('term:read'), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined; const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
const maxWeek = await dashboardService.resolveTermWeeks(user, classId); const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
@@ -1,9 +1,9 @@
import type { PermissionKey } from '@classhelper/shared';
import { classIdsFor, loadAuthPrincipal } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared'; import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import { import {
assertClassAccess, assertClassAccess,
classScopeIdFilter,
classScopeWhere,
isClassDevice, isClassDevice,
resolveClassScope, resolveClassScope,
requireClassDeviceClassId, requireClassDeviceClassId,
@@ -25,7 +25,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* - 都不传:全局默认(getMaxTermWeeks 兜底)。 * - 都不传:全局默认(getMaxTermWeeks 兜底)。
*/ */
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> { export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId, 'term:read');
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined); const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
if (!targetClassId) return getMaxTermWeeks(); if (!targetClassId) return getMaxTermWeeks();
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
@@ -42,10 +42,38 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> { export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const device = isClassDevice(user); const device = isClassDevice(user);
// 班级端强制限定在自己班级,避免越权统计 // 班级端强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined); const scope = await resolveClassScope(
user,
device ? requireClassDeviceClassId(user) : undefined,
'dashboard:read',
);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤 // 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope); const allowed = scope.mode === 'in' ? scope.classIds : await classIdsFor(user, 'dashboard:read');
const classWhere = classScopeIdFilter(scope); const auth = await loadAuthPrincipal(user);
async function segment(key: PermissionKey) {
const ids = device ? allowed : (await classIdsFor(user, key)).filter((id) => allowed.includes(id));
return { classId: { in: ids } };
}
const [classes, students, courses, schedules, homeworks, notifications, grades] = await Promise.all(
[
'class:read',
'roster:read',
'schedule:read',
'schedule:read',
'homework:read',
'notification:read',
'grade:read',
].map((key) => segment(key as PermissionKey)),
);
const sections = {
classes: device || auth.permissions.has('class:read'),
students: device || auth.permissions.has('roster:read'),
courses: device || auth.permissions.has('schedule:read'),
schedules: device || auth.permissions.has('schedule:read'),
homeworks: device || auth.permissions.has('homework:read'),
notifications: device || auth.permissions.has('notification:read'),
grades: device || auth.permissions.has('grade:read'),
};
// ClassHelper 班级端以全班学生为范围 // ClassHelper 班级端以全班学生为范围
const personalIds = await resolvePersonalIds(user); const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds); const personalWhere = personalIdWhere(personalIds);
@@ -64,27 +92,27 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
recentHomeworks, recentHomeworks,
] = await Promise.all([ ] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人" // 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }), prisma.class.count({ where: { id: classes.classId } }),
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }), prisma.student.count({ where: { ...students, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where }), prisma.course.count({ where: courses }),
prisma.classPlan.count({ where }), prisma.classPlan.count({ where: schedules }),
prisma.homework.count({ where }), prisma.homework.count({ where: homeworks }),
prisma.notification.count({ where }), prisma.notification.count({ where: notifications }),
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }), prisma.grade.count({ where: { ...grades, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }), prisma.notification.count({ where: { ...notifications, reads: { none: personalWhere } } }),
device device
? prisma.homework.count({ ? prisma.homework.count({
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } }, where: { ...homeworks, statuses: { none: { ...personalWhere, completed: true } } },
}) })
: prisma.homework.count({ where }), : prisma.homework.count({ where: homeworks }),
prisma.notification.findMany({ prisma.notification.findMany({
where, where: notifications,
include: { creator: creatorSelect, reads: { where: personalWhere } }, include: { creator: creatorSelect, reads: { where: personalWhere } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
}), }),
prisma.homework.findMany({ prisma.homework.findMany({
where, where: homeworks,
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
@@ -96,6 +124,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
]); ]);
return { return {
sections,
classCount, classCount,
studentCount, studentCount,
courseCount, courseCount,
@@ -1,8 +1,9 @@
import { requireAdmin } from '../../middleware/auth.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -30,46 +31,52 @@ import * as databaseService from './database.service.js';
*/ */
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate(), requireAdmin());
router.use(requirePermission('database:manage')); router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */ /** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => { router.get('/status', async (req, res) => {
sendOk(res, await databaseService.getDatabaseStatus()); sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).getDatabaseStatus());
}); });
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */ /** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => { router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req)); const result = await databaseService
.forAdministrator(getAuthUser(req))
.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败'); sendOk(res, result, result.ok ? '连接成功' : '连接失败');
}); });
/** GET /api/database/backups - 备份列表 */ /** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => { router.get('/backups', (req, res) => {
sendOk(res, databaseService.listBackups()); sendOk(res, databaseService.forAdministrator(getAuthUser(req)).listBackups());
}); });
/** POST /api/database/backups - 立即创建一份手动备份 */ /** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => { router.post('/backups', async (req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建'); sendCreated(
res,
await databaseService.forAdministrator(getAuthUser(req)).createBackup('manual'),
'备份已创建',
);
}); });
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */ /** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => { router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req); const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复'); sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).restoreBackup(name), '已从备份恢复');
}); });
/** DELETE /api/database/backups/:name - 删除备份 */ /** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => { router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req); const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name); databaseService.forAdministrator(getAuthUser(req)).deleteBackup(name);
sendOk(res, { name }, '备份已删除'); sendOk(res, { name }, '备份已删除');
}); });
/** GET /api/database/export - 下载当前库的 JSON 快照 */ /** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => { router.get('/export', async (req, res) => {
const json = await databaseService.exportSnapshotJson(); const json = await databaseService.forAdministrator(getAuthUser(req)).exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`; const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
@@ -77,8 +84,8 @@ router.get('/export', async (_req, res) => {
}); });
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */ /** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => { router.get('/sqlite-file', (req, res) => {
const filePath = databaseService.sqliteFilePathForDownload(); const filePath = databaseService.forAdministrator(getAuthUser(req)).sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`); res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath)); res.status(200).send(fs.readFileSync(filePath));
@@ -87,33 +94,41 @@ router.get('/sqlite-file', (_req, res) => {
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */ /** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => { router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req); const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)'); sendOk(
res,
await databaseService.forAdministrator(getAuthUser(req)).importSnapshotBase64(data),
'快照已导入(原数据已被覆盖)',
);
}); });
/** GET /api/database/backup-schedule - 定时备份配置 */ /** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => { router.get('/backup-schedule', (req, res) => {
sendOk(res, databaseService.getBackupSchedule()); sendOk(res, databaseService.forAdministrator(getAuthUser(req)).getBackupSchedule());
}); });
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */ /** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => { router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk( sendOk(
res, res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)), databaseService
.forAdministrator(getAuthUser(req))
.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存', '定时备份配置已保存',
); );
}); });
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */ /** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => { router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req)); const job = databaseService
.forAdministrator(getAuthUser(req))
.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动'); sendCreated(res, { jobId: job.id }, '切换任务已启动');
}); });
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */ /** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => { router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id); const job = databaseService.forAdministrator(getAuthUser(req)).getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在'); sendOk(res, job, job ? 'ok' : '任务不存在');
}); });
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -795,3 +797,23 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise
job.finishedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString();
} }
} }
/** 请求入口必须携带 actor;后台调度只调用模块内部维护函数。 */
export function forAdministrator(actor: TokenPayload) {
assertAdmin(actor);
return {
getDatabaseStatus,
testConnection,
listBackups,
createBackup,
restoreBackup,
deleteBackup,
exportSnapshotJson,
importSnapshotBase64,
sqliteFilePathForDownload,
getBackupSchedule,
saveSchedulePatch,
startSwitch,
getSwitchJob,
};
}
@@ -1,11 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requirePermission, requirePermission,
requireStaffPermission, requirePermissionOrClassDevice,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
@@ -31,9 +31,10 @@ import * as gradeService from './grades.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate());
router.get('/overview', async (req, res) => { router.get('/overview', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const classId = user.classId ?? user.sub; const classId = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : String(req.query.classId ?? '');
if (!classId) throw ApiError.badRequest('请指定班级');
const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } }); const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } });
sendOk( sendOk(
res, res,
@@ -42,14 +43,19 @@ router.get('/overview', async (req, res) => {
: await gradeService.collectiveAnalysis(user, classId), : await gradeService.collectiveAnalysis(user, classId),
); );
}); });
router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => { router.get(
const user = getAuthUser(req); '/collective',
const { classId } = validatedQuery<{ classId: string }>(req); requirePermissionOrClassDevice('grade:read'),
sendOk(res, await gradeService.collectiveAnalysis(user, classId)); validate({ query: examListQuerySchema }),
}); async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
},
);
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */ /** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
router.get('/my', async (req, res) => { router.get('/my', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功'); sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
}); });
@@ -65,7 +71,7 @@ router.get('/my', async (req, res) => {
*/ */
router.get( router.get(
'/student/:studentNo', '/student/:studentNo',
requireStaffPermission('student:grade-query'), requirePermissionOrClassDevice('student:grade-query'),
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }), validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -93,11 +99,16 @@ router.get(
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集, * **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。 * 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
*/ */
router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => { router.get(
const user = getAuthUser(req); '/exams',
const { classId } = validatedQuery<{ classId: string }>(req); requirePermissionOrClassDevice('grade:read'),
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功'); validate({ query: examListQuerySchema }),
}); async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
},
);
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */ /** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
router.put( router.put(
@@ -121,11 +132,16 @@ router.put(
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用 * 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。 * `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
*/ */
router.get('/ranking', validate({ query: examRankingQuerySchema }), async (req, res) => { router.get(
const user = getAuthUser(req); '/ranking',
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req); requirePermissionOrClassDevice('grade:read'),
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功'); validate({ query: examRankingQuerySchema }),
}); async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
},
);
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */ /** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get( router.get(
@@ -1,3 +1,5 @@
import { assertManage, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { assertContentWrite } from '../../lib/authz/index.js';
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared'; import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
@@ -14,7 +16,6 @@ import {
type StudentGradeDetailDto, type StudentGradeDetailDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { import {
assertCanManageSubjectContent,
assertClassAccess, assertClassAccess,
classScopeWhere, classScopeWhere,
isClassDevice, isClassDevice,
@@ -57,16 +58,10 @@ const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const; const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> { async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'grade:read');
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } }); const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
if ( if (isClassDevice(user) && !cls.studentGradeQueryEnabled)
!canQueryStudentGrades({
classRole: await resolveUserClassRole(user, classId),
gradeQueryEnabled: cls.studentGradeQueryEnabled,
})
) {
throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩'); throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩');
}
} }
/** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */ /** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */
@@ -74,7 +69,7 @@ export async function collectiveAnalysis(
user: TokenPayload, user: TokenPayload,
classId: string, classId: string,
): Promise<ContractCollectiveGradeAnalysisDto> { ): Promise<ContractCollectiveGradeAnalysisDto> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'grade:read');
const rows = await prisma.grade.findMany({ const rows = await prisma.grade.findMany({
where: { classId, student: activeStudentWhere() }, where: { classId, student: activeStudentWhere() },
select: { select: {
@@ -139,12 +134,17 @@ export interface ListGradeOptions {
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。 * 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
*/ */
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> { export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
await assertPermissionOrClassDevice(user, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const studentIds = await resolvePersonalIds(user); const studentIds = await resolvePersonalIds(user);
if (studentIds.length === 0) return []; if (studentIds.length === 0) return [];
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { studentId: { in: studentIds }, student: activeStudentWhere() }, where: {
classId: user.classId ?? user.sub,
studentId: { in: studentIds },
student: activeStudentWhere(),
},
include, include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 1000, take: 1000,
@@ -154,7 +154,7 @@ export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
/** 教师 / 管理员查看成绩(按可访问班级过滤) */ /** 教师 / 管理员查看成绩(按可访问班级过滤) */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> { export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { where: {
@@ -220,11 +220,11 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
/** /**
* 录入一条成绩。 * 录入一条成绩。
* *
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩, * 权限走 ``:**科任老师只能录自己任教科目**的成绩,
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。 * 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
*/ */
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> { export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩'); await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
await assertStudentInClass(input.studentId, input.classId); await assertStudentInClass(input.studentId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId); if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -249,7 +249,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
/** 批量录入:同一个考试一次提交整班成绩 */ /** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> { export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩'); await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
if (input.courseId) await assertCourseInClass(input.courseId, input.classId); if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date(); const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
@@ -292,8 +292,11 @@ export async function updateGrade(
if (!current) throw ApiError.notFound('成绩记录不存在'); if (!current) throw ApiError.notFound('成绩记录不存在');
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限 // 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '修改成绩', {
allowClassDevice: false,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null); const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩'); await assertContentWrite(user, 'grade:write', current.classId, nextCourseId, '修改成绩');
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId); if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
const totalScore = input.totalScore ?? current.totalScore; const totalScore = input.totalScore ?? current.totalScore;
@@ -331,7 +334,7 @@ export async function updateGrade(
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> { export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } }); const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } });
if (!current) throw ApiError.notFound('成绩记录不存在'); if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩'); await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } }); await prisma.grade.delete({ where: { id: gradeId } });
await emitGradeUpdated(current.classId, { await emitGradeUpdated(current.classId, {
@@ -369,7 +372,7 @@ export async function updateGradeLevels(
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样) // 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
for (const item of input.items) { for (const item of input.items) {
const record = byId.get(item.id)!; const record = byId.get(item.id)!;
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级'); await assertContentWrite(user, 'grade:write', record.classId, record.courseId, '修改成绩等级');
} }
await prisma.$transaction( await prisma.$transaction(
@@ -387,7 +390,7 @@ export async function updateGradeLevels(
// 整批重算 // 整批重算
const classId = input.classId!; const classId = input.classId!;
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级'); await assertContentWrite(user, 'grade:write', classId, input.courseId ?? null, '调整成绩等级');
const levelType = input.levelType!; const levelType = input.levelType!;
if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级'); if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级');
const records = await prisma.grade.findMany({ const records = await prisma.grade.findMany({
@@ -433,7 +436,7 @@ export async function getStudentGradeDetail(
requestedClassId?: string, requestedClassId?: string,
): Promise<StudentGradeDetailDto> { ): Promise<StudentGradeDetailDto> {
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const scope = await resolveClassScope(user, requestedClassId); const scope = await resolveClassScope(user, requestedClassId, 'student:grade-query');
const student = await prisma.student.findFirst({ const student = await prisma.student.findFirst({
where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) }, where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
@@ -458,7 +461,7 @@ export async function getStudentGradeDetail(
} }
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { studentId: student.id }, where: { studentId: student.id, ...classScopeWhere(scope) },
include, include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500, take: 500,
@@ -488,7 +491,7 @@ export async function getStudentGradeDetailById(
}); });
if (!student || ('status' in student && !isActiveStudent(student as { status: string }))) if (!student || ('status' in student && !isActiveStudent(student as { status: string })))
throw ApiError.notFound('学生不存在'); throw ApiError.notFound('学生不存在');
await assertClassAccess(user, student.classId ?? ''); await assertClassAccess(user, student.classId ?? '', 'student:grade-query');
return getStudentGradeDetail(user, student.studentNo); return getStudentGradeDetail(user, student.studentNo);
} }
@@ -579,7 +582,8 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。 * 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
*/ */
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> { export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
await assertClassAccess(user, classId); if (isClassDevice(user)) await assertDetailQuery(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const [groups, exams] = await Promise.all([ const [groups, exams] = await Promise.all([
prisma.grade.groupBy({ prisma.grade.groupBy({
by: ['examName'], by: ['examName'],
@@ -619,7 +623,7 @@ export async function setExamCountInTotal(
name: string, name: string,
countInTotal: boolean, countInTotal: boolean,
): Promise<ExamDto> { ): Promise<ExamDto> {
await assertClassAccess(user, classId); await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径'); if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
const row = await prisma.exam.upsert({ const row = await prisma.exam.upsert({
@@ -666,7 +670,7 @@ export async function getExamRanking(
classId: string, classId: string,
examName: string, examName: string,
): Promise<ExamRankingDto> { ): Promise<ExamRankingDto> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'grade:read');
const classRecord = await prisma.class.findUnique({ const classRecord = await prisma.class.findUnique({
where: { id: classId }, where: { id: classId },
select: { name: true, studentGradeQueryEnabled: true }, select: { name: true, studentGradeQueryEnabled: true },
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -32,7 +32,7 @@ router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */ /** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get( router.get(
'/', '/',
requireStaffPermission('homework:read'), requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworksQuerySchema }), validate({ query: listHomeworksQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -54,7 +54,7 @@ router.get(
*/ */
router.get( router.get(
'/days', '/days',
requireStaffPermission('homework:read'), requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworkDaysQuerySchema }), validate({ query: listHomeworkDaysQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -65,7 +65,7 @@ router.get(
router.get( router.get(
'/:id', '/:id',
requireStaffPermission('homework:read'), requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -82,7 +82,7 @@ router.get(
*/ */
router.post( router.post(
'/', '/',
requireStaffPermission('homework:write'), requirePermissionOrClassDevice('homework:write'),
validate({ body: createHomeworkSchema }), validate({ body: createHomeworkSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.post(
* *
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`; * 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。 * 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 —— * 每个班的科目归属依然由 createHomework 里的 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。 * 批量不是绕过科目门的后门(§5 第 5 条)。
*/ */
router.post( router.post(
@@ -145,7 +145,7 @@ router.delete(
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */ /** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch( router.patch(
'/:id/status', '/:id/status',
requireStaffPermission('homework:write'), requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }), validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -162,7 +162,7 @@ router.patch(
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */ /** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
router.get( router.get(
'/:id/submissions', '/:id/submissions',
requireStaffPermission('homework:read'), requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -175,7 +175,7 @@ router.get(
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */ /** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
router.patch( router.patch(
'/:id/submissions', '/:id/submissions',
requireStaffPermission('homework:write'), requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }), validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,4 +1,5 @@
import { assertStaffPermission } from '../../lib/permissions.js'; import { assertContentWrite } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
dayKeyLocal, dayKeyLocal,
@@ -12,13 +13,7 @@ import {
type HomeworkSubmissionDto, type HomeworkSubmissionDto,
type HomeworkSubmissionsDto, type HomeworkSubmissionsDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { import { assertClassAccess, classScopeWhere, isClassDevice, resolveClassScope } from '../../lib/access.js';
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
resolveClassScope,
} from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
@@ -56,8 +51,8 @@ export async function listHomeworks(
user: TokenPayload, user: TokenPayload,
options: ListHomeworkOptions, options: ListHomeworkOptions,
): Promise<HomeworkDto[]> { ): Promise<HomeworkDto[]> {
await assertStaffPermission(user, 'homework:read'); await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'homework:read');
const device = isClassDevice(user); const device = isClassDevice(user);
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态" // ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
const personalIds = device ? await resolvePersonalIds(user) : []; const personalIds = device ? await resolvePersonalIds(user) : [];
@@ -97,8 +92,8 @@ export async function listHomeworkDays(
user: TokenPayload, user: TokenPayload,
options: { classId?: string; from?: string; to?: string; days?: number }, options: { classId?: string; from?: string; to?: string; days?: number },
): Promise<HomeworkDaysDto> { ): Promise<HomeworkDaysDto> {
await assertStaffPermission(user, 'homework:read'); await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'homework:read');
const today = dayKeyLocal(new Date()); const today = dayKeyLocal(new Date());
const to = options.to ?? today; const to = options.to ?? today;
const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1); const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1);
@@ -122,7 +117,7 @@ export async function listHomeworkDays(
} }
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> { export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:read'); await assertPermissionOrClassDevice(user, 'homework:read');
const device = isClassDevice(user); const device = isClassDevice(user);
const personalIds = device ? await resolvePersonalIds(user) : []; const personalIds = device ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({ const homework = await prisma.homework.findUnique({
@@ -136,7 +131,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
}, },
}); });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId); await assertClassAccess(user, homework.classId, 'homework:read');
return toHomeworkDto(homework, { return toHomeworkDto(homework, {
studentIds: device ? personalIds : [], studentIds: device ? personalIds : [],
@@ -149,15 +144,15 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
/** /**
* 发布作业并实时推送到班级房间。 * 发布作业并实时推送到班级房间。
* *
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`): * 权限走 ``(`allowClassDevice: true`):
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了); * - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员; * - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求, * - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。 * 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/ */
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> { export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', { await assertContentWrite(user, 'homework:write', input.classId, input.courseId ?? null, '布置作业', {
allowClassDevice: true, allowClassDevice: true,
}); });
if (input.courseId) await assertCourseInClass(input.courseId, input.classId); if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -212,7 +207,7 @@ export async function bulkCreateHomeworks(
user: TokenPayload, user: TokenPayload,
input: BulkCreateHomeworkRequest, input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> { ): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
const classIds = normalizeBulkClassIds(input.classIds); const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = []; const results: BulkPublishClassResult[] = [];
@@ -261,13 +256,16 @@ export async function updateHomework(
homeworkId: string, homeworkId: string,
input: UpdateHomeworkInput, input: UpdateHomeworkInput,
): Promise<HomeworkDto> { ): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({ where: { id: homeworkId } }); const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在'); if (!current) throw ApiError.notFound('作业不存在');
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判 // 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '修改作业', {
allowClassDevice: true,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null); const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', { await assertContentWrite(user, 'homework:write', current.classId, nextCourseId, '修改作业', {
allowClassDevice: true, allowClassDevice: true,
}); });
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId); if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
@@ -294,13 +292,13 @@ export async function updateHomework(
} }
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> { export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({ const current = await prisma.homework.findUnique({
where: { id: homeworkId }, where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect }, include: { course: courseSelect, creator: creatorSelect },
}); });
if (!current) throw ApiError.notFound('作业不存在'); if (!current) throw ApiError.notFound('作业不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', { await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '删除作业', {
allowClassDevice: true, allowClassDevice: true,
}); });
@@ -323,11 +321,13 @@ export async function updateHomeworkStatus(
homeworkId: string, homeworkId: string,
input: UpdateHomeworkStatusInput, input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> { ): Promise<HomeworkStatusDto> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } }); const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId); await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
if (isClassDevice(user)) { if (isClassDevice(user)) {
const studentIds = await resolvePersonalIds(user); const studentIds = await resolvePersonalIds(user);
@@ -417,13 +417,15 @@ export async function listHomeworkSubmissions(
user: TokenPayload, user: TokenPayload,
homeworkId: string, homeworkId: string,
): Promise<HomeworkSubmissionsDto> { ): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:read'); await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({ const homework = await prisma.homework.findUnique({
where: { id: homeworkId }, where: { id: homeworkId },
select: { id: true, classId: true }, select: { id: true, classId: true, courseId: true },
}); });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId); await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user); assertCanManageSubmissions(user);
return buildSubmissions(homework.classId, homeworkId); return buildSubmissions(homework.classId, homeworkId);
} }
@@ -437,13 +439,15 @@ export async function updateHomeworkSubmissions(
homeworkId: string, homeworkId: string,
input: UpdateHomeworkSubmissionsInput, input: UpdateHomeworkSubmissionsInput,
): Promise<HomeworkSubmissionsDto> { ): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:write'); await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({ const homework = await prisma.homework.findUnique({
where: { id: homeworkId }, where: { id: homeworkId },
select: { id: true, classId: true }, select: { id: true, classId: true, courseId: true },
}); });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId); await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user); assertCanManageSubmissions(user);
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
@@ -6,7 +6,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
requireAnyPermission, requireAnyPermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
@@ -115,7 +115,7 @@ router.post(
const input = validatedBody<TableCommitInput>(req); const input = validatedBody<TableCommitInput>(req);
await assertImportKindAllowed(input.kind, getAuthUser(req)); await assertImportKindAllowed(input.kind, getAuthUser(req));
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局") // 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) { if (input.kind === 'grades' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级'); throw ApiError.badRequest('请先选择要导入的班级');
} }
const result = await commitTable(getAuthUser(req), input); const result = await commitTable(getAuthUser(req), input);
@@ -147,7 +147,7 @@ router.post(
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */ /** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get( router.get(
'/time-layout', '/time-layout',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: timeLayoutQuerySchema }), validate({ query: timeLayoutQuerySchema }),
async (req, res) => { async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
@@ -161,7 +161,7 @@ router.get(
*/ */
router.post( router.post(
'/time-layout', '/time-layout',
requirePermission('timetable:manage', 'import:schedule'), requirePermission('schedule:write', 'import:schedule'),
validate({ body: timeLayoutImportSchema }), validate({ body: timeLayoutImportSchema }),
async (req, res) => { async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req)); const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
@@ -178,7 +178,7 @@ router.post(
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */ /** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete( router.delete(
'/time-layout/:id', '/time-layout/:id',
requirePermission('timetable:manage'), requirePermission('import:schedule', 'schedule:write'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
@@ -1,7 +1,8 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertAdmin, assertManage, assertContentWrite } from '../../lib/authz/index.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
import { assertPermission } from '../../lib/permissions.js'; import { assertPermission } from '../../lib/permissions.js';
import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js'; import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js';
import { addStudent } from '../classes/classes.service.js';
import { updateStudent } from '../students/students.service.js';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared'; import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
@@ -11,8 +12,6 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js'; import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport, canManageSubjectContent } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js'; import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/** /**
@@ -362,7 +361,6 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。 * 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。 * 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
*/ */
const classRole = await resolveUserClassRole(user, classId);
const errors: ImportRowError[] = []; const errors: ImportRowError[] = [];
const warnings: string[] = []; const warnings: string[] = [];
@@ -425,21 +423,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`); warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师) // 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
const canWriteCourse = canManageSubjectContent({ await assertContentWrite(user, 'grade:import', classId, course?.id ?? null, '导入成绩');
classRole,
hasSubject: Boolean(course),
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
allowClassDevice: false,
});
if (!canWriteCourse) {
errors.push({
row: rowNumber,
message: course
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
});
continue;
}
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算 // 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent'; const levelType = levelText ? 'custom' : 'percent';
@@ -521,6 +505,15 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
else classByName.set(key, item); else classByName.set(key, item);
} }
const operations: {
classId: string;
studentNo: string;
name: string;
gender: string;
guardianPhone: string;
existingId: string | null;
sourceClassId: string | null;
}[] = [];
const errors: ImportRowError[] = []; const errors: ImportRowError[] = [];
const warnings: string[] = []; const warnings: string[] = [];
let inserted = 0; let inserted = 0;
@@ -580,19 +573,68 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1; skipped += 1;
continue; continue;
} }
if (existing.classId !== classId) if (existing.classId !== classId) await assertCanManageRoster(user, existing.classId ?? undefined);
await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); operations.push({
await updateStudent(user, existing.id, { classId,
studentNo,
name, name,
...(gender ? { gender } : {}), gender,
...(guardianPhone ? { guardianPhone } : {}), guardianPhone,
existingId: existing.id,
sourceClassId: existing.classId,
}); });
updated += 1; updated += 1;
} else { } else {
await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); operations.push({
classId,
studentNo,
name,
gender,
guardianPhone,
existingId: null,
sourceClassId: null,
});
inserted += 1; inserted += 1;
} }
} }
await prisma.$transaction(
async (tx) => {
for (const op of operations) {
if (!op.existingId || op.sourceClassId !== op.classId)
await changeStudentLifecycle(
user,
{
studentIds: op.existingId ? [op.existingId] : [],
kind: 'class-change',
toClassId: op.classId,
note: '学生名单导入',
...(!op.existingId
? {
newStudent: {
studentNo: op.studentNo,
name: op.name,
gender: op.gender,
guardianPhone: op.guardianPhone,
},
}
: {}),
},
tx,
);
if (op.existingId)
await tx.student.update({
where: { id: op.existingId },
data: {
name: op.name,
...(op.gender ? { gender: op.gender } : {}),
...(op.guardianPhone ? { guardianPhone: op.guardianPhone } : {}),
},
});
}
},
{ timeout: 30000 },
);
notifyStudentClasses(operations.flatMap((op) => [op.sourceClassId, op.classId]));
const result: ImportResult = { const result: ImportResult = {
kind: 'students', kind: 'students',
@@ -618,73 +660,94 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
*/ */
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> { export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'teachers'); await assertImportAllowed(user, undefined, 'teachers');
const buffer = decodeBase64(input.contentBase64); assertAdmin(user);
const parsed = parseTable(buffer, input.fileName); return prisma.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: ImportRowError[] = []; const errors: ImportRowError[] = [];
const warnings: string[] = []; const warnings: string[] = [];
let inserted = 0; let inserted = 0;
let updated = 0; let updated = 0;
let skipped = 0; let skipped = 0;
for (const [index, row] of parsed.rows.entries()) { for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username'); const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name'); const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone'); const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password'); const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role'); const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) { if (!username || !name) {
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' }); errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue; continue;
} }
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) { if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` }); errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue; continue;
} }
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } }); const existing = await tx.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) { if (existing?.role === 'ADMIN') {
if (input.mode === 'append') { errors.push({ row: rowNumber, message: '管理员账号请通过教师账号管理修改' });
skipped += 1; continue;
continue; }
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await tx.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await tx.user.create({
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
},
});
inserted += 1;
}
} }
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({ const result: ImportResult = {
where: { id: existing.id }, kind: 'teachers',
data: { name, role: nextRole, ...(phone ? { phone } : {}) }, total: parsed.rows.length,
}); inserted,
updated += 1; updated,
} else { skipped,
const initialPassword = password || env.defaultTeacherPassword; failed: errors.length,
await prisma.user.create({ errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: { data: {
username, actorId: user.sub,
name, actorName: user.name,
phone, action: 'import-commitTeachers',
role: parseTeacherRole(roleCell) ?? 'TEACHER', targetType: 'import',
passwordHash: await hashPassword(initialPassword), targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
}, },
}); });
inserted += 1; return result;
} },
} { timeout: 30000 },
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
); );
return result;
} }
/* ------------------------------------------------------------------ 班级任课老师 */ /* ------------------------------------------------------------------ 班级任课老师 */
@@ -705,160 +768,184 @@ export async function commitClassTeachers(
input: TableCommitInput, input: TableCommitInput,
): Promise<ImportResult> { ): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers'); await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64); assertAdmin(user);
const parsed = parseTable(buffer, input.fileName); return prisma
.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配 const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const errors: ImportRowError[] = []; const errors: ImportRowError[] = [];
const warnings: string[] = []; const warnings: string[] = [];
let inserted = 0; let inserted = 0;
let updated = 0; let updated = 0;
let skipped = 0; let skipped = 0;
const classes = await prisma.class.findMany({ const classes = await tx.class.findMany({
where: { archivedYearId: null }, where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true }, select: { id: true, name: true, teacherId: true },
});
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
continue;
}
await assertImportAllowed(user, targetClass.id, 'classTeachers');
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await prisma.user.findUnique({
where: { username: teacherNo },
select: { id: true },
}); });
if (byNo) { const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
teacherId = byNo.id; for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
} }
} else if (teacherName) {
const byName = await prisma.user.findMany({ // 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } }, const createdTeachers = new Map<string, string>();
select: { id: true },
take: 2, for (const [index, row] of parsed.rows.entries()) {
}); const rowNumber = index + 2;
if (byName.length === 1) { const className = cellValue(row, parsed.columns, input.mapping, 'className');
teacherId = byName[0]!.id; const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
} else if (byName.length > 1) { const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
errors.push({ const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
row: rowNumber, const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
}); if (!className || !teacherNo) {
continue; errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({
row: rowNumber,
message: `找不到班级「${className}」(可能已毕业归档或名称重复)`,
});
continue;
}
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await tx.user.findUnique({
where: { username: teacherNo },
select: { id: true },
});
if (byNo) {
teacherId = byNo.id;
}
} else if (teacherName) {
const byName = await tx.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await tx.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await tx.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await tx.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
} }
}
}
if (!teacherId) { const result: ImportResult = {
if (!useTeacherNo) { kind: 'classTeachers',
errors.push({ total: parsed.rows.length,
row: rowNumber, inserted,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`, updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: {
actorId: user.sub,
actorName: user.name,
action: 'import-commitClassTeachers',
targetType: 'import',
targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
},
}); });
continue; return result;
} },
await assertPermission(user, 'teacher:manage'); { timeout: 30000 },
await assertPermission(user, 'import:teachers'); )
const created = await prisma.user.create({ .then((result) => {
data: { refreshClassSubscriptions();
username: teacherNo, return result;
name: teacherName || teacherNo, });
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await prisma.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
} }
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> { export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
@@ -890,19 +977,15 @@ async function assertImportAllowed(
} as const; } as const;
await assertPermission(user, key[kind]); await assertPermission(user, key[kind]);
if (classId) await assertClassAccess(user, classId, key[kind]); if (classId) await assertClassAccess(user, classId, key[kind]);
if (kind === 'students') return assertCanManageRoster(user, classId); if (kind === 'students') {
if (classId) await assertManage(user, 'import:students', classId);
return assertCanManageRoster(user, classId);
}
if (kind === 'teachers') return assertPermission(user, 'teacher:manage'); if (kind === 'teachers') return assertPermission(user, 'teacher:manage');
if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId); if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId);
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE'; if (kind === 'grades' && classId) return;
if (canImport(user.role, classRole, kind)) return; if (kind === 'scheduleTimes' && classId) return assertManage(user, 'import:schedule', classId);
if (kind === 'grades') { throw ApiError.forbidden('请指定目标班级');
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
: '只能导入自己担任班主任的班级的数据',
);
}
throw ApiError.forbidden('该导入仅限管理员使用');
} }
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */ /** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
@@ -324,7 +324,7 @@ export async function importTimeLayout(
user: TokenPayload, user: TokenPayload,
input: TimeLayoutImportInput, input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> { ): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanImportSchedule(user, input.classId, 'timetable:manage'); await assertCanImportSchedule(user, input.classId);
const parsed = parseClassIslandTimeLayout(input.payload); const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) { if (parsed.errors.length > 0) {
@@ -380,7 +380,7 @@ export async function importTimeLayout(
actionSet: '{}', actionSet: '{}',
}; };
}); });
const layout = await saveLayout({ classId: input.classId, name, points }, current?.id); const layout = await saveLayout(user, { classId: input.classId, name, points }, current?.id);
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`); logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
return { return {
layout: toDto(layout), layout: toDto(layout),
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -28,7 +28,7 @@ router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */ /** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get( router.get(
'/', '/',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ query: listNotificationsQuerySchema }), validate({ query: listNotificationsQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -40,7 +40,7 @@ router.get(
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */ /** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get( router.get(
'/days', '/days',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }), validate({ query: notificationArchiveQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -52,7 +52,7 @@ router.get(
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */ /** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get( router.get(
'/creators', '/creators',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }), validate({ query: notificationArchiveQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -64,7 +64,7 @@ router.get(
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */ /** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get( router.get(
'/unread-count', '/unread-count',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationScopeQuerySchema }), validate({ query: notificationScopeQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -76,7 +76,7 @@ router.get(
/** POST /api/notifications/read-all - 全部标为已读 */ /** POST /api/notifications/read-all - 全部标为已读 */
router.post( router.post(
'/read-all', '/read-all',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ body: notificationScopeQuerySchema }), validate({ body: notificationScopeQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -88,7 +88,7 @@ router.post(
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */ /** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post( router.post(
'/', '/',
requireStaffPermission('notification:publish'), requirePermissionOrClassDevice('notification:publish'),
validate({ body: createNotificationSchema }), validate({ body: createNotificationSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -100,7 +100,7 @@ router.post(
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */ /** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post( router.post(
'/bulk', '/bulk',
requireStaffPermission('notification:publish'), requirePermissionOrClassDevice('notification:publish'),
validate({ body: bulkCreateNotificationSchema }), validate({ body: bulkCreateNotificationSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -115,7 +115,7 @@ router.post(
/** POST /api/notifications/:id/read - 标记已读 */ /** POST /api/notifications/:id/read - 标记已读 */
router.post( router.post(
'/:id/read', '/:id/read',
requireStaffPermission('notification:read'), requirePermissionOrClassDevice('notification:read'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,4 +1,6 @@
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js'; import { assertClassRead, isHead } from '../../lib/authz/index.js';
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js'; import { resolveContentCreator } from '../../lib/access.js';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
@@ -12,12 +14,7 @@ import {
type NotificationDto, type NotificationDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { import { assertClassAccess, classScopeWhere, resolveClassScope } from '../../lib/access.js';
assertClassAccess,
assertCanPublishNotification,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
@@ -73,8 +70,8 @@ export async function listNotifications(
user: TokenPayload, user: TokenPayload,
options: ListNotificationOptions, options: ListNotificationOptions,
): Promise<NotificationDto[]> { ): Promise<NotificationDto[]> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'notification:read');
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER'; const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己 // 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
const personalIds = await resolvePersonalIds(user); const personalIds = await resolvePersonalIds(user);
@@ -112,8 +109,8 @@ export async function listNotificationDays(
user: TokenPayload, user: TokenPayload,
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string }, options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
): Promise<NotificationDaysDto> { ): Promise<NotificationDaysDto> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({ const grouped = await prisma.notification.groupBy({
by: ['assignDate'], by: ['assignDate'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) }, where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -133,8 +130,8 @@ export async function listNotificationCreators(
user: TokenPayload, user: TokenPayload,
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string }, options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
): Promise<NotificationCreatorsDto> { ): Promise<NotificationCreatorsDto> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({ const grouped = await prisma.notification.groupBy({
by: ['createdBy'], by: ['createdBy'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) }, where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -171,7 +168,7 @@ export async function bulkCreateNotifications(
user: TokenPayload, user: TokenPayload,
input: BulkCreateNotificationRequest, input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> { ): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'notification:publish'); await assertPermissionOrClassDevice(user, 'notification:publish');
const classIds = normalizeBulkClassIds(input.classIds); const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = []; const results: BulkPublishClassResult[] = [];
@@ -208,8 +205,8 @@ export async function createNotification(
user: TokenPayload, user: TokenPayload,
input: CreateNotificationInput, input: CreateNotificationInput,
): Promise<NotificationDto> { ): Promise<NotificationDto> {
await assertStaffPermission(user, 'notification:publish'); await assertPermissionOrClassDevice(user, 'notification:publish');
await assertCanPublishNotification(user, input.classId); await assertPublish(user, 'notification:publish', input.classId, { allowClassDevice: true });
const priority = input.priority ?? 'NORMAL'; const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) { if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -251,7 +248,9 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
await assertPermission(user, 'notification:delete'); await assertPermission(user, 'notification:delete');
const current = await prisma.notification.findUnique({ where: { id: notificationId } }); const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在'); if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishNotification(user, current.classId); await assertClassRead(user, 'notification:delete', current.classId);
if (user.role !== 'ADMIN' && current.createdBy !== user.sub && !(await isHead(user, current.classId)))
throw ApiError.forbidden('只有发布者、班主任或管理员可删除通知');
await prisma.notification.delete({ where: { id: notificationId } }); await prisma.notification.delete({ where: { id: notificationId } });
emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, { emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, {
id: notificationId, id: notificationId,
@@ -267,10 +266,10 @@ export async function markAsRead(
user: TokenPayload, user: TokenPayload,
notificationId: string, notificationId: string,
): Promise<{ notificationId: string; readAt: string; marked: number }> { ): Promise<{ notificationId: string; readAt: string; marked: number }> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const notification = await prisma.notification.findUnique({ where: { id: notificationId } }); const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在'); if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId); await assertClassAccess(user, notification.classId, 'notification:read');
const personalIds = await resolvePersonalIds(user); const personalIds = await resolvePersonalIds(user);
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates, // 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
@@ -293,8 +292,8 @@ export async function markAsRead(
/** 全部标为已读(可按班级限定) */ /** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> { export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId); const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user); const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds); const personalWhere = personalIdWhere(personalIds);
@@ -324,8 +323,8 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
/** 未读数量(客户端红点) */ /** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> { export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
await assertStaffPermission(user, 'notification:read'); await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId); const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user); const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({ return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } }, where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
@@ -1,9 +1,14 @@
import { z } from 'zod'; import { z } from 'zod';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermission, requireAdmin } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import {
validateBadRequest as validate,
validatedBody,
validatedParams,
validatedQuery,
} from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
assignUserGroupsSchema, assignUserGroupsSchema,
@@ -29,17 +34,19 @@ router.get('/me', async (req, res) => {
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功'); sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
}); });
router.use(requireAdmin());
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */ /** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => { router.get('/catalog', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功'); sendOk(res, await permissionService.permissionCatalog(getAuthUser(req)), '获取权限目录成功');
}); });
/** GET /api/permissions/groups - 权限组列表 */ /** GET /api/permissions/groups - 权限组列表 */
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => { router.get('/groups', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listGroups(), '获取权限组成功'); sendOk(res, await permissionService.listGroups(getAuthUser(req)), '获取权限组成功');
}); });
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) => router.get('/scopes', requirePermission('permission:manage'), async (req, res) =>
sendOk(res, await permissionService.scopeOptions()), sendOk(res, await permissionService.scopeOptions()),
); );
router.get( router.get(
@@ -53,14 +60,15 @@ router.get(
}), }),
async (req, res) => { async (req, res) => {
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req); const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
sendOk(res, await permissionService.listAudits(page, pageSize)); sendOk(res, await permissionService.listAudits(getAuthUser(req), page, pageSize));
}, },
); );
router.get( router.get(
'/groups/:id/members', '/groups/:id/members',
requirePermission('permission:manage'), requirePermission('permission:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => sendOk(res, await permissionService.groupMembers(String(req.params.id))), async (req, res) =>
sendOk(res, await permissionService.groupMembers(getAuthUser(req), String(req.params.id))),
); );
/** POST /api/permissions/groups - 新建权限组 */ /** POST /api/permissions/groups - 新建权限组 */
@@ -99,10 +107,29 @@ router.delete(
); );
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */ /** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
router.get('/users', requirePermission('permission:manage'), async (_req, res) => { router.get('/users', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listMembers(), '获取成员成功'); sendOk(res, await permissionService.listMembers(getAuthUser(req)), '获取成员成功');
}); });
router.get(
'/users/:id/effective',
validate({ params: idParamSchema, query: z.object({ assignments: z.string().max(16000).optional() }) }),
async (req, res) => {
const query = validatedQuery<{ assignments?: string }>(req);
let preview: AssignUserGroupsInput | undefined;
if (query.assignments !== undefined) {
try {
preview = assignUserGroupsSchema.parse({ assignments: JSON.parse(query.assignments) });
} catch {
throw ApiError.badRequest('分配预览格式不正确');
}
}
sendOk(
res,
await permissionService.effectivePermissions(getAuthUser(req), String(req.params.id), preview),
);
},
);
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */ /** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
router.put( router.put(
'/users/:id/groups', '/users/:id/groups',
@@ -22,7 +22,7 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200
export const createPermissionGroupSchema = z.object({ export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'), name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').optional(), description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(), scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(), scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(), keys: keysSchema.optional(),
}); });
@@ -36,15 +36,33 @@ export const createPermissionGroupSchema = z.object({
export const updatePermissionGroupSchema = z.object({ export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(), name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').optional(), description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(), scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(), scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(), keys: keysSchema.optional(),
}); });
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */ /** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
export const assignUserGroupsSchema = z.object({ export const assignUserGroupsSchema = z
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'), .object({
}); groupIds: z.array(z.string().min(1)).max(50).optional(),
assignments: z
.array(
z
.object({
groupId: z.string().min(1),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']),
scopeValue: z.string().max(200).default(''),
})
.strict(),
)
.max(50)
.optional(),
})
.strict()
.refine(
(v) => (v.assignments !== undefined) !== (v.groupIds !== undefined),
'请提供 assignments 或 groupIds 之一',
);
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>; export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>; export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
@@ -1,165 +1,72 @@
import { disconnectUserSessions } from '../../realtime/bus.js';
import { import {
PERMISSION_CATEGORIES,
PERMISSION_KEYS, PERMISSION_KEYS,
PERMISSION_META,
PERMISSION_LABELS, PERMISSION_LABELS,
PERMISSION_CATEGORIES,
ROLE_DEFAULT_PERMISSIONS, ROLE_DEFAULT_PERMISSIONS,
parsePermissionScope,
defaultPromotionPlan,
type PermissionKey,
type PermissionScope,
type PermissionGroupDto,
type PermissionMemberDto,
type PermissionCatalogDto,
type EffectivePermissionsDto,
type AssignUserGroupsRequest, type AssignUserGroupsRequest,
type CreatePermissionGroupRequest, type CreatePermissionGroupRequest,
type MyPermissionsDto,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionKey,
type PermissionMemberDto,
type UpdatePermissionGroupRequest, type UpdatePermissionGroupRequest,
type UserRole, type PromotionPlan,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { ensureDefaultGroups } from '../../lib/permissions.js';
import { import {
parsePermissionScope, assertAdmin,
defaultPromotionPlan, invalidateAuthPrincipals,
type PermissionScope, loadAuthPrincipal,
type PromotionPlan, principalForAssignments,
} from '@classhelper/shared'; classIdsFor,
import { assertPermission } from '../../lib/permissions.js'; manageClassIdsFor,
actionScopesFor,
type AssignmentRow,
} from '../../lib/authz/index.js';
import { getSetting } from '../../lib/settings.js'; import { getSetting } from '../../lib/settings.js';
import {
ADMIN_UNREMOVABLE_PERMISSIONS,
ensureDefaultGroups,
loadEffectivePermissions,
} from '../../lib/permissions.js';
/** const include = { items: true, _count: { select: { users: true } } } as const;
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。 type Group = Awaited<ReturnType<typeof prisma.permissionGroup.findMany<{ include: typeof include }>>>[number];
* const known = (key: string): key is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(key);
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions` const keysFor = (group: { items: { key: string }[] }) => group.items.map((i) => i.key).filter(known);
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。 const activeKeys = (group: { items: { key: string }[] }) =>
*/ keysFor(group).filter((key) => PERMISSION_META[key].kind !== 'admin');
function label(scope: string, scopeValue: string) {
const GROUP_SELECT = {
id: true,
name: true,
description: true,
builtin: true,
defaultRole: true,
scope: true,
scopeValue: true,
createdAt: true,
updatedAt: true,
items: { select: { key: true } },
_count: { select: { users: true } },
} as const;
type GroupRow = {
id: string;
name: string;
description: string;
builtin: boolean;
defaultRole: string | null;
scope: string;
scopeValue: string;
createdAt: Date;
updatedAt: Date;
items: { key: string }[];
_count: { users: number };
};
const MEMBER_SELECT = {
id: true,
username: true,
name: true,
role: true,
permissionGroups: { select: { groupId: true } },
} as const;
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
function knownKey(key: string): key is PermissionKey {
return KEY_ORDER.has(key);
}
function byCatalogOrder(a: string, b: string): number {
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
}
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
const unique = new Set<PermissionKey>();
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
return [...unique].sort(byCatalogOrder);
}
function toGroupDto(row: GroupRow): PermissionGroupDto {
return {
id: row.id,
scope: row.scope as PermissionGroupDto['scope'],
scopeValue: row.scopeValue,
scopeLabel: scopeLabel(row),
name: row.name,
description: row.description,
builtin: row.builtin,
defaultRole: row.defaultRole as UserRole | null,
keys: row.items
.map((item) => item.key)
.filter(knownKey)
.sort(byCatalogOrder),
memberCount: row._count.users,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function scopeLabel(group: { scope: string; scopeValue: string }) {
try { try {
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel; return parsePermissionScope(scope as PermissionScope, scopeValue).scopeLabel;
} catch { } catch {
return '未配置范围(无班级访问权)'; return '未配置范围(无班级访问权)';
} }
} }
function dto(group: Group): PermissionGroupDto {
export async function scopeOptions() { const inactiveKeys = keysFor(group).filter((k) => PERMISSION_META[k].kind === 'admin');
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
const raw = await getSetting('term.promotionPlan');
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
const planGrades = plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade));
return { return {
grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(), ...group,
planGrades, keys: activeKeys(group),
divisions: plan.tracks.map((track) => ({ scope: group.scope as PermissionScope,
name: track.name, scopeLabel: label(group.scope, group.scopeValue),
grades: track.stages.map((stage) => stage.grade), defaultScope: group.scope as PermissionScope,
})), defaultScopeValue: group.scopeValue,
defaultScopeLabel: label(group.scope, group.scopeValue),
defaultRole: group.defaultRole === 'TEACHER' ? 'TEACHER' : null,
kind: group.defaultRole ? 'default' : group.builtin ? 'builtin' : 'custom',
inactiveKeys,
warnings: inactiveKeys.length ? ['旧管理员专用键已停用,仅管理员固定拥有'] : [],
memberCount: group._count.users,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
}; };
} }
export function auditData(
async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) {
try {
return parsePermissionScope(
input.scope ?? 'SELF_CLASS',
input.scopeValue ?? '',
(await scopeOptions()).grades,
);
} catch (error) {
throw ApiError.badRequest(String(error instanceof Error ? error.message : error));
}
}
async function assertGrant(
actor: TokenPayload,
group: { scope: string; defaultRole?: string | null; keys: readonly string[] },
) {
await assertPermission(actor, 'permission:manage');
if (actor.role === 'ADMIN') return;
if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组');
if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组');
const own = await loadEffectivePermissions(actor);
if (group.keys.some((key) => !own.has(key as PermissionKey)))
throw ApiError.forbidden('不能授予或管理超出自己生效权限的组');
}
function auditData(
actor: TokenPayload, actor: TokenPayload,
action: string, action: string,
targetType: string, targetType: string,
@@ -177,17 +84,34 @@ function auditData(
afterJson: JSON.stringify(after), afterJson: JSON.stringify(after),
}; };
} }
export async function scopeOptions() {
export async function createGroup( const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
actor: TokenPayload, const raw = await getSetting('term.promotionPlan');
input: CreatePermissionGroupRequest, const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
): Promise<PermissionGroupDto> { const planGrades = plan.tracks.flatMap((t) => t.stages.map((s) => s.grade));
const scope = await validateScope(input); return {
const keys = normalizeKeys(input.keys); grades: [...new Set([...classes.map((c) => c.grade), ...planGrades])].sort(),
await assertGrant(actor, { ...scope, keys }); planGrades,
if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } })) divisions: plan.tracks.map((t) => ({ name: t.name, grades: t.stages.map((s) => s.grade) })),
throw ApiError.conflict('权限组名称已存在'); };
const created = await prisma.$transaction(async (tx) => { }
async function validateScope(scope: PermissionScope = 'TEACHING', scopeValue = '') {
try {
return parsePermissionScope(scope, scopeValue, (await scopeOptions()).grades);
} catch (error) {
throw ApiError.badRequest(error instanceof Error ? error.message : String(error));
}
}
function validateKeys(keys: readonly string[]) {
if (keys.some((k) => !known(k) || PERMISSION_META[k].kind === 'admin'))
throw ApiError.badRequest('教师模板不能包含管理员专用键或未知键');
return [...new Set(keys)] as PermissionKey[];
}
export async function createGroup(actor: TokenPayload, input: CreatePermissionGroupRequest) {
assertAdmin(actor);
const scope = await validateScope(input.scope, input.scopeValue);
const keys = validateKeys(input.keys ?? []);
return prisma.$transaction(async (tx) => {
const row = await tx.permissionGroup.create({ const row = await tx.permissionGroup.create({
data: { data: {
name: input.name.trim(), name: input.name.trim(),
@@ -196,35 +120,34 @@ export async function createGroup(
scopeValue: scope.scopeValue, scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) }, items: { create: keys.map((key) => ({ key })) },
}, },
select: GROUP_SELECT, include,
}); });
await tx.permissionAudit.create({ await tx.permissionAudit.create({ data: auditData(actor, 'create', 'group', row.id, null, dto(row)) });
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)), invalidateAuthPrincipals();
}); return dto(row);
return row;
}); });
return toGroupDto(created);
} }
export async function updateGroup(actor: TokenPayload, id: string, input: UpdatePermissionGroupRequest) {
export async function updateGroup( assertAdmin(actor);
actor: TokenPayload, const current = await prisma.permissionGroup.findUnique({ where: { id }, include });
groupId: string, if (!current || current.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
input: UpdatePermissionGroupRequest, const scope = await validateScope(
): Promise<PermissionGroupDto> { input.scope ?? (current.scope as PermissionScope),
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT }); input.scopeValue ?? current.scopeValue,
if (!current) throw ApiError.notFound('权限组不存在'); );
const before = toGroupDto(current); if (current.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
await assertGrant(actor, { ...current, keys: before.keys }); throw ApiError.badRequest('教师默认模板固定使用任教班级范围');
const scope = await validateScope({ // 旧管理员键仅停用;编辑可分配能力时保留它们,不能通过写入口重新授予。
scope: input.scope ?? before.scope, const inactiveKeys = keysFor(current).filter((key) => PERMISSION_META[key].kind === 'admin');
scopeValue: input.scopeValue ?? before.scopeValue, const keys =
}); input.keys === undefined
const keys = normalizeKeys(input.keys ?? before.keys); ? current.items.map((i) => i.key)
await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys }); : [...new Set([...validateKeys(input.keys), ...inactiveKeys])];
const updated = await prisma.$transaction(async (tx) => { let affected: string[] = [];
await tx.permissionGroupItem.deleteMany({ where: { groupId } }); const result = await prisma.$transaction(async (tx) => {
await tx.permissionGroupItem.deleteMany({ where: { groupId: id } });
const row = await tx.permissionGroup.update({ const row = await tx.permissionGroup.update({
where: { id: groupId }, where: { id },
data: { data: {
name: input.name?.trim(), name: input.name?.trim(),
description: input.description?.trim(), description: input.description?.trim(),
@@ -232,120 +155,204 @@ export async function updateGroup(
scopeValue: scope.scopeValue, scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) }, items: { create: keys.map((key) => ({ key })) },
}, },
select: GROUP_SELECT, include,
}); });
await tx.permissionAudit.create({
data: auditData(actor, 'update', 'group', id, dto(current), dto(row)),
});
const members =
current.defaultRole === 'TEACHER'
? await tx.user.findMany({ where: { role: 'TEACHER' }, select: { id: true } })
: await tx.userPermissionGroup
.findMany({ where: { groupId: id }, select: { userId: true } })
.then((rows) => rows.map((r) => ({ id: r.userId })));
affected = members.map((u) => u.id);
return dto(row);
});
invalidateAuthPrincipals();
disconnectUserSessions(affected);
return result;
}
export async function deleteGroup(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id }, include });
if (!group) throw ApiError.notFound('模板不存在');
if (group.builtin || group.defaultRole) throw ApiError.badRequest('内置或默认模板不可删除');
const members = await prisma.userPermissionGroup.findMany({
where: { groupId: id },
select: { userId: true },
});
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id } });
await tx.permissionAudit.create({ data: auditData(actor, 'delete', 'group', id, dto(group), null) });
});
invalidateAuthPrincipals();
disconnectUserSessions(members.map((m) => m.userId));
return { id };
}
export async function listMembers(actor: TokenPayload): Promise<PermissionMemberDto[]> {
assertAdmin(actor);
await ensureDefaultGroups();
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: 'TEACHER' },
include: { items: true },
});
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
orderBy: { name: 'asc' },
});
return Promise.all(
users.map(async (u) => {
const user: TokenPayload = {
sub: u.id,
role: u.role as TokenPayload['role'],
username: u.username,
name: u.name,
classId: null,
};
const auth = await principalForAssignments(user, u.permissionGroups, defaultGroup);
const assignments =
u.role === 'ADMIN'
? []
: u.permissionGroups.map((a) => ({
groupId: a.groupId,
groupName: a.group.name,
scope: a.scope as PermissionScope,
scopeValue: a.scopeValue,
scopeLabel: label(a.scope, a.scopeValue),
}));
return {
id: u.id,
username: u.username,
name: u.name,
role: user.role,
fixed: u.role === 'ADMIN',
assignments,
groupIds: [...new Set(assignments.map((a) => a.groupId))],
permissions: [...auth.permissions],
permissionCount: auth.permissions.size,
scopeLabels: assignments.length
? [...new Set(assignments.map((a) => a.scopeLabel))]
: [u.role === 'ADMIN' ? '固定全部权限' : '默认模板 · 任教班级'],
};
}),
);
}
export async function groupMembers(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id } });
if (!group || group.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
return (await listMembers(actor)).filter(
(u) =>
!u.fixed && (u.groupIds.includes(id) || (group.defaultRole === 'TEACHER' && !u.assignments.length)),
);
}
async function assignmentRows(input: AssignUserGroupsRequest): Promise<AssignmentRow[]> {
const ids = input.assignments?.map((a) => a.groupId) ?? input.groupIds ?? [];
const groups = await prisma.permissionGroup.findMany({
where: { id: { in: ids } },
include: { items: true },
});
if (new Set(ids).size !== groups.length || groups.some((g) => g.defaultRole === 'ADMIN'))
throw ApiError.badRequest('模板不存在或已停用');
const assignments =
input.assignments ??
ids.map((groupId) => {
const g = groups.find((g) => g.id === groupId)!;
return { groupId, scope: g.scope as PermissionScope, scopeValue: g.scopeValue };
});
const rows: AssignmentRow[] = [];
for (const assignment of assignments) {
const group = groups.find((g) => g.id === assignment.groupId)!;
const scope = await validateScope(assignment.scope, assignment.scopeValue);
if (group.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
throw ApiError.badRequest('教师默认模板只允许任教班级范围');
if (
activeKeys(group).some(
(k) => PERMISSION_META[k].kind === 'class' && !PERMISSION_META[k].allowedScopes.includes(scope.scope),
)
)
throw ApiError.badRequest('分配范围不适用于模板能力');
rows.push({ groupId: group.id, scope: scope.scope, scopeValue: scope.scopeValue, group });
}
return [...new Map(rows.map((a) => [`${a.groupId}:${a.scope}:${a.scopeValue}`, a])).values()];
}
export async function effectivePermissions(
actor: TokenPayload,
id: string,
preview?: AssignUserGroupsRequest,
): Promise<EffectivePermissionsDto> {
assertAdmin(actor);
await ensureDefaultGroups();
const u = await prisma.user.findUnique({
where: { id },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
});
if (!u) throw ApiError.notFound('账号不存在');
const user: TokenPayload = {
sub: id,
name: u.name,
username: u.username,
role: u.role as TokenPayload['role'],
classId: null,
};
if (preview && u.role === 'ADMIN') throw ApiError.badRequest('管理员固定全部权限,不接受分配');
const auth = await principalForAssignments(
user,
preview ? await assignmentRows(preview) : u.permissionGroups,
);
const classes = await prisma.class.findMany({ select: { id: true, name: true } });
const entries = await Promise.all(
[...auth.permissions].map(async (key) => {
const ids = await classIdsFor(user, key, auth);
return {
key,
scopes: [...new Set((auth.grants.get(key) ?? []).map((g) => label(g.scope, g.scopeValue)))],
classes: classes.filter((c) => ids.includes(c.id)),
classCount: ids.length,
};
}),
);
return {
permissions: [...auth.permissions],
entries,
conflicts: entries
.filter((e) => e.scopes.length > 1)
.map((e) => `${PERMISSION_LABELS[e.key]}:实际合并范围 ${e.scopes.join(' ∪ ')};较窄分配可能被覆盖`),
headOnlyKeys: [...auth.permissions].filter((k) => PERMISSION_META[k].relation === 'head'),
};
}
export async function assignUserGroups(actor: TokenPayload, userId: string, input: AssignUserGroupsRequest) {
assertAdmin(actor);
const u = await prisma.user.findUnique({ where: { id: userId } });
if (!u) throw ApiError.notFound('账号不存在');
if (u.role !== 'TEACHER') throw ApiError.badRequest('管理员固定全部权限,不能分配模板');
const rows = await assignmentRows(input);
const before = (await listMembers(actor)).find((m) => m.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (rows.length)
await tx.userPermissionGroup.createMany({
data: rows.map(({ groupId, scope, scopeValue }) => ({ userId, groupId, scope, scopeValue })),
});
await tx.permissionAudit.create({ await tx.permissionAudit.create({
data: auditData( data: auditData(
actor, actor,
current.defaultRole ? 'update-default' : 'update', 'assign',
'group', 'user',
groupId, userId,
before, before.assignments,
toGroupDto(row), rows.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
), ),
}); });
return row;
}); });
return toGroupDto(updated); invalidateAuthPrincipals();
disconnectUserSessions([userId]);
return (await listMembers(actor)).find((m) => m.id === userId)!;
} }
export async function listAudits(actor: TokenPayload, page = 1, pageSize = 30) {
export async function deleteGroup(actor: TokenPayload, groupId: string) { assertAdmin(actor);
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
if (!current) throw ApiError.notFound('权限组不存在');
await assertGrant(actor, { ...current, keys: current.items.map((item) => item.key) });
if (current.builtin) throw ApiError.badRequest('内置权限组不可删除');
// 删除最后一组会回落默认组;非管理员不能借删除制造额外授权。
if (actor.role !== 'ADMIN') throw ApiError.forbidden('删除组会改变成员默认权限,仅管理员可执行');
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id: groupId } });
await tx.permissionAudit.create({
data: auditData(actor, 'delete', 'group', groupId, toGroupDto(current), null),
});
});
return { id: groupId };
}
/** 组和成员各预取一次,账号数增长不会增加权限查询次数。 */
export async function listMembers(): Promise<PermissionMemberDto[]> {
await ensureDefaultGroups();
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ include: { items: true } }),
]);
return rows.map((row) => {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const effective = groups.filter((group) =>
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
);
const permissions = normalizeKeys([
...effective.flatMap((group) => group.items.map((item) => item.key)),
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]);
return {
id: row.id,
username: row.username,
name: row.name,
role: row.role as UserRole,
groupIds,
permissions,
permissionCount: permissions.length,
scopeLabels: [...new Set(effective.map(scopeLabel))],
};
});
}
export async function groupMembers(groupId: string) {
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
if (!group) throw ApiError.notFound('权限组不存在');
return (await listMembers()).filter((member) =>
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
);
}
export async function assignUserGroups(
actor: TokenPayload,
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
await assertPermission(actor, 'permission:manage');
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
const ids = [...new Set(input.groupIds)];
const groups = await prisma.permissionGroup.findMany({
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
include: { items: true },
});
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
for (const group of groups) {
if (group.defaultRole && group.defaultRole !== user.role)
throw ApiError.forbidden('默认组只能分配给同角色账号');
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
}
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
const before = (await listMembers()).find((member) => member.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (ids.length)
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
await tx.permissionAudit.create({
data: auditData(actor, 'assign', 'user', userId, before, {
groupIds: ids,
permissions: normalizeKeys([
...groups.flatMap((group) => group.items.map((item) => item.key)),
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]),
}),
});
});
return (await listMembers()).find((member) => member.id === userId)!;
}
export async function listAudits(page = 1, pageSize = 30) {
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
prisma.permissionAudit.findMany({ prisma.permissionAudit.findMany({
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
@@ -356,71 +363,68 @@ export async function listAudits(page = 1, pageSize = 30) {
]); ]);
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
export async function permissionCatalog(actor: TokenPayload): Promise<PermissionCatalogDto> {
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */ assertAdmin(actor);
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
await ensureDefaultGroups(); await ensureDefaultGroups();
const defaults = await prisma.permissionGroup.findMany({ const group = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: { not: null } }, where: { defaultRole: 'TEACHER' },
include: { items: true }, include: { items: true },
}); });
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
const keysForRole = (role: UserRole): PermissionKey[] => {
const keys =
defaults.find((group) => group.defaultRole === role)?.items.map((item) => item.key) ??
ROLE_DEFAULT_PERMISSIONS[role];
return normalizeKeys(role === 'ADMIN' ? [...keys, ...ADMIN_UNREMOVABLE_PERMISSIONS] : keys);
};
return { return {
keys: [...PERMISSION_KEYS], keys: [...PERMISSION_KEYS],
labels: { ...PERMISSION_LABELS }, meta: PERMISSION_META,
categories: PERMISSION_CATEGORIES.map((category) => ({ labels: PERMISSION_LABELS,
title: category.title, categories: PERMISSION_CATEGORIES.map((c) => ({ title: c.title, keys: [...c.keys] })),
keys: [...category.keys],
})),
defaultRoles: Object.fromEntries( defaultRoles: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]), PERMISSION_KEYS.map((k) => [k, [...PERMISSION_META[k].defaultRoles]]),
) as Record<PermissionKey, UserRole[]>, ) as PermissionCatalogDto['defaultRoles'],
roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record< roleDefaults: {
UserRole, ...ROLE_DEFAULT_PERMISSIONS,
PermissionKey[] ADMIN: [...PERMISSION_KEYS],
>, TEACHER: activeKeys(group),
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS], CLASS_DEVICE: [],
},
unremovable: [...PERMISSION_KEYS],
}; };
} }
export async function listGroups(actor: TokenPayload): Promise<PermissionGroupDto[]> {
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */ assertAdmin(actor);
export async function listGroups(): Promise<PermissionGroupDto[]> {
await ensureDefaultGroups(); await ensureDefaultGroups();
const rows = await prisma.permissionGroup.findMany({ const members = await listMembers(actor);
select: GROUP_SELECT, return (
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }], await prisma.permissionGroup.findMany({
}); where: { OR: [{ defaultRole: null }, { defaultRole: 'TEACHER' }] },
const members = await listMembers(); include,
return (rows as GroupRow[]).map((row) => ({ orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
...toGroupDto(row), })
memberCount: members.filter((m) => ).map((group) => ({
m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role, ...dto(group),
memberCount: members.filter(
(m) =>
!m.fixed &&
(m.groupIds.includes(group.id) || (group.defaultRole === 'TEACHER' && !m.assignments.length)),
).length, ).length,
})); }));
} }
export async function myPermissions(user: TokenPayload) {
/** const auth = await loadAuthPrincipal(user);
* GET /api/permissions/me - 当前账号的生效权限。 const classKeys = [...auth.permissions].filter((key) => PERMISSION_META[key].kind === 'class');
* const managementKeys = classKeys.filter((key) =>
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。 ['roster:manage', 'schedule:write', 'import:students', 'import:schedule', 'grade:write'].includes(key),
* 班级端会话恒为空集:它的能力由班级维度断言决定。 );
*/ const [scopes, management] = await Promise.all([
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> { Promise.all(classKeys.map(async (key) => [key, await classIdsFor(user, key, auth)] as const)),
const permissions = await loadEffectivePermissions(user); Promise.all(managementKeys.map(async (key) => [key, await manageClassIdsFor(user, key, auth)] as const)),
const fromGroups = ]);
user.classSession || user.role === 'CLASS_DEVICE'
? false
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
return { return {
role: user.role, role: user.role,
permissions: [...permissions].sort(byCatalogOrder), permissions: [...auth.permissions],
fromGroups, fromGroups: auth.fromGroups,
classScopes: Object.fromEntries(scopes),
manageScopes: Object.fromEntries(management),
actionScopes: await actionScopesFor(user, scopes, management),
allScopeKeys: [...auth.grants]
.filter(([, grants]) => grants.some((g) => g.scope === 'ALL'))
.map(([key]) => key),
}; };
} }
@@ -40,8 +40,8 @@ export async function listSchedules(
user: TokenPayload, user: TokenPayload,
options: ListScheduleOptions, options: ListScheduleOptions,
): Promise<ScheduleDto[]> { ): Promise<ScheduleDto[]> {
if (options.classId) await assertClassAccess(user, options.classId); if (options.classId) await assertClassAccess(user, options.classId, 'schedule:read');
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user); const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user, 'schedule:read');
const result: ScheduleDto[] = []; const result: ScheduleDto[] = [];
for (const classId of classIds) { for (const classId of classIds) {
if (options.week) { if (options.week) {
@@ -98,7 +98,7 @@ export async function getActiveScheduleWeek(
) { ) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级'); if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'schedule:read');
const context = await loadTermContext(classId), const context = await loadTermContext(classId),
week = options.week ?? resolveCurrentWeek(new Date(), context); week = options.week ?? resolveCurrentWeek(new Date(), context);
if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数'); if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数');
@@ -124,7 +124,7 @@ export async function computeClassStatus(classId: string, at = new Date()): Prom
export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) { export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级'); if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId); await assertClassAccess(user, classId, 'schedule:read');
return computeClassStatus(classId, options.at); return computeClassStatus(classId, options.at);
} }
@@ -151,8 +151,8 @@ function matches(p: PlanRow, day: number, week: number) {
(p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv) (p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv)
); );
} }
async function assertCourse(courseId: string, classId: string) { async function assertCourse(courseId: string, classId: string, db: Tx = prisma) {
if (!(await prisma.course.findFirst({ where: { id: courseId, classId } }))) if (!(await db.course.findFirst({ where: { id: courseId, classId } })))
throw ApiError.badRequest('课程不属于本班'); throw ApiError.badRequest('课程不属于本班');
} }
export async function createSchedule( export async function createSchedule(
@@ -161,10 +161,8 @@ export async function createSchedule(
transaction?: Tx, transaction?: Tx,
identity?: string, identity?: string,
): Promise<ScheduleDto> { ): Promise<ScheduleDto> {
if (!transaction) { await assertCanManageSchedule(user, input.classId, transaction);
await assertCanManageSchedule(user, input.classId); await assertCourse(input.courseId, input.classId, transaction);
await assertCourse(input.courseId, input.classId);
}
const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }), const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }),
max = transaction max = transaction
? normalizeMaxWeeks( ? normalizeMaxWeeks(
@@ -282,6 +280,8 @@ export async function updateSchedule(
const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored
? JSON.parse(stored.value) ? JSON.parse(stored.value)
: {}; : {};
for (const targetClassId of new Set(entries.map((e) => e.plan.classId)))
await assertCanManageSchedule(user, targetClassId);
const classId = entries[0]!.plan.classId; const classId = entries[0]!.plan.classId;
await assertCanManageSchedule(user, classId); await assertCanManageSchedule(user, classId);
if (input.courseId) await assertCourse(input.courseId, classId); if (input.courseId) await assertCourse(input.courseId, classId);
@@ -10,7 +10,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import { import {
authenticate, authenticate,
getAuthUser, getAuthUser,
requireStaffPermission, requirePermissionOrClassDevice,
requirePermission, requirePermission,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -30,18 +30,18 @@ const router = Router();
router.use(authenticate()); router.use(authenticate());
router.get( router.get(
'/active-week', '/active-week',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }), validate({ query: gridQuerySchema }),
async (req, res) => async (req, res) =>
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))), sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
); );
router.get( router.get(
'/scheduled', '/scheduled',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }), validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => { async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req); const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId); await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk( sendOk(
res, res,
await prisma.scheduledClassPlan.findMany({ await prisma.scheduledClassPlan.findMany({
@@ -109,7 +109,7 @@ router.post(
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */ /** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get( router.get(
'/', '/',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: listSchedulesQuerySchema }), validate({ query: listSchedulesQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -121,7 +121,7 @@ router.get(
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */ /** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get( router.get(
'/grid', '/grid',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }), validate({ query: gridQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -145,7 +145,7 @@ router.get(
*/ */
router.get( router.get(
'/current', '/current',
requireStaffPermission('schedule:read'), requirePermissionOrClassDevice('schedule:read'),
validate({ query: classStatusQuerySchema }), validate({ query: classStatusQuerySchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -8,11 +8,11 @@ import {
getAccessibleClassIds, getAccessibleClassIds,
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js'; import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js'; import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
import { activeStudentWhere } from '../../lib/students.js'; import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { hasAllScope } from '../../lib/permission-scope.js'; import { hasOrganizationalGrant } from '../../lib/authz/index.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js'; import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
@@ -38,7 +38,7 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
/** 学生名单(按班级权限过滤) */ /** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> { export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read'); await assertPermissionOrClassDevice(user, 'roster:read');
const scope = await resolveClassScope(user, options.classId, 'roster:read'); const scope = await resolveClassScope(user, options.classId, 'roster:read');
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
@@ -219,16 +219,16 @@ export async function listTransfers(
options: ListTransfersQuery, options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> { ): Promise<PaginatedResult<StudentTransferDto>> {
await assertPermission(user, 'roster:read'); await assertPermission(user, 'roster:read');
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read')); const hasGlobalRosterScope = isAdmin(user) || (await hasOrganizationalGrant(user, 'roster:read'));
if (options.classId) await assertClassAccess(user, options.classId); if (options.classId) await assertClassAccess(user, options.classId, 'roster:read');
if (options.studentId) { if (options.studentId) {
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { id: options.studentId }, where: { id: options.studentId },
select: { classId: true }, select: { classId: true },
}); });
if (!student) throw ApiError.notFound('学生不存在'); if (!student) throw ApiError.notFound('学生不存在');
if (student.classId) await assertClassAccess(user, student.classId); if (student.classId) await assertClassAccess(user, student.classId, 'roster:read');
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史'); else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
} }
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { Router } from 'express'; import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared'; import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js'; import { sendOk } from '../../lib/http.js';
@@ -13,7 +14,7 @@ import {
import * as systemService from './system.service.js'; import * as systemService from './system.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate(), requireAdmin());
/** /**
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。 * 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
@@ -46,7 +47,7 @@ router.put(
validate({ body: updateAutostartSchema }), validate({ body: updateAutostartSchema }),
async (req, res) => { async (req, res) => {
const input = validatedBody<UpdateAutostartInput>(req); const input = validatedBody<UpdateAutostartInput>(req);
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效'); sendOk(res, await systemService.updateAutostart(getAuthUser(req), input), '开机自启设置已生效');
}, },
); );
@@ -1,3 +1,5 @@
import { assertAdmin } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { createAutostartController } from './autostart.js'; import { createAutostartController } from './autostart.js';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -10,7 +12,6 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js'; import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
import { APP_VERSION } from '../../lib/version.js'; import { APP_VERSION } from '../../lib/version.js';
@@ -39,6 +40,7 @@ export async function updateSystemSettings(
user: TokenPayload, user: TokenPayload,
input: UpdateSystemSettingsInput, input: UpdateSystemSettingsInput,
): Promise<SystemSettingsDto> { ): Promise<SystemSettingsDto> {
assertAdmin(user);
if (input.developerMode !== undefined) { if (input.developerMode !== undefined) {
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode); await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`); logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
@@ -55,7 +57,11 @@ const autostart = createAutostartController({
export async function getAutostartStatus(): Promise<AutostartStatusDto> { export async function getAutostartStatus(): Promise<AutostartStatusDto> {
return autostart.read(); return autostart.read();
} }
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> { export async function updateAutostart(
actor: TokenPayload,
input: UpdateAutostartInput,
): Promise<AutostartStatusDto> {
assertAdmin(actor);
return autostart.write(input.enabled); return autostart.write(input.enabled);
} }
@@ -1,7 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requirePermission, requireAnyPermission } from '../../middleware/auth.js'; import {
authenticate,
getAuthUser,
requireAdmin,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -27,39 +33,50 @@ router.get(
validate({ query: listTeachersQuerySchema }), validate({ query: listTeachersQuerySchema }),
async (req, res) => { async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req); const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); sendOk(res, await teacherService.listTeachers(getAuthUser(req), keyword), '获取教师列表成功');
}, },
); );
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */ /** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post( router.post(
'/', '/',
requireAdmin(),
requirePermission('teacher:manage'), requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }), validate({ body: createTeacherSchema }),
async (req, res) => { async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功'); sendCreated(
res,
await teacherService.createTeacher(getAuthUser(req), req.body as CreateTeacherInput),
'教师账号创建成功',
);
}, },
); );
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */ /** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch( router.patch(
'/:id', '/:id',
requireAdmin(),
requirePermission('teacher:manage'), requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }), validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功'); sendOk(
res,
await teacherService.updateTeacher(getAuthUser(req), id, req.body as UpdateTeacherInput),
'教师信息更新成功',
);
}, },
); );
/** POST /api/teachers/:id/reset-password - 重置教师密码 */ /** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post( router.post(
'/:id/reset-password', '/:id/reset-password',
requireAdmin(),
requirePermission('teacher:manage'), requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }), validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput); await teacherService.resetTeacherPassword(getAuthUser(req), id, req.body as ResetTeacherPasswordInput);
sendOk(res, { id }, '密码已重置'); sendOk(res, { id }, '密码已重置');
}, },
); );
@@ -67,11 +84,12 @@ router.post(
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */ /** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete( router.delete(
'/:id', '/:id',
requireAdmin(),
requirePermission('teacher:manage'), requirePermission('teacher:manage'),
validate({ params: idParamSchema }), validate({ params: idParamSchema }),
async (req, res) => { async (req, res) => {
const { id } = validatedParams<{ id: string }>(req); const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id); await teacherService.deleteTeacher(getAuthUser(req), id);
sendOk(res, { id }, '教师账号已删除'); sendOk(res, { id }, '教师账号已删除');
}, },
); );
@@ -1,3 +1,8 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { disconnectUserSessions } from '../../realtime/bus.js';
import { assertAdmin, assertPermission } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { auditData } from '../permissions/permissions.service.js';
import type { UserDto } from '@classhelper/shared'; import type { UserDto } from '@classhelper/shared';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
@@ -17,7 +22,8 @@ import type {
*/ */
/** 教师列表(教师 + 管理员账号) */ /** 教师列表(教师 + 管理员账号) */
export async function listTeachers(keyword?: string): Promise<UserDto[]> { export async function listTeachers(actor: TokenPayload, keyword?: string): Promise<UserDto[]> {
await assertPermission(actor, 'teacher:read');
const teachers = await prisma.user.findMany({ const teachers = await prisma.user.findMany({
where: { where: {
role: { in: ['TEACHER', 'ADMIN'] }, role: { in: ['TEACHER', 'ADMIN'] },
@@ -29,7 +35,8 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
return teachers.map(toUserDto); return teachers.map(toUserDto);
} }
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> { export async function createTeacher(actor: TokenPayload, input: CreateTeacherInput): Promise<UserDto> {
assertAdmin(actor);
const created = await prisma.user.create({ const created = await prisma.user.create({
data: { data: {
username: input.username, username: input.username,
@@ -38,6 +45,12 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword), passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
}, },
}); });
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-create', 'user', created.id, null, {
role: created.role,
name: created.name,
}),
});
return toUserDto(created); return toUserDto(created);
} }
@@ -53,29 +66,50 @@ async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role
return teacher; return teacher;
} }
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> { export async function updateTeacher(
actor: TokenPayload,
teacherId: string,
input: UpdateTeacherInput,
): Promise<UserDto> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId); const teacher = await findTeacherOrThrow(teacherId);
const updated = await prisma.user.update({ const updated = await prisma.$transaction(async (tx) => {
where: { id: teacher.id }, if (teacher.role === 'ADMIN' && input.role && input.role !== 'ADMIN') await protectLastAdmin(tx);
data: { const result = await tx.user.update({
...(input.name ? { name: input.name } : {}), where: { id: teacher.id },
...(input.username ? { username: input.username } : {}), data: {
...(input.role ? { role: input.role } : {}), ...(input.name ? { name: input.name } : {}),
}, ...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
});
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-update', 'user', teacher.id, teacher, {
role: result.role,
name: result.name,
}),
});
return result;
}); });
disconnectUserSessions([teacher.id]);
return toUserDto(updated); return toUserDto(updated);
} }
/** 重置教师密码(未指定时用默认初始密码) */ /** 重置教师密码(未指定时用默认初始密码) */
export async function resetTeacherPassword( export async function resetTeacherPassword(
actor: TokenPayload,
teacherId: string, teacherId: string,
input: ResetTeacherPasswordInput, input: ResetTeacherPasswordInput,
): Promise<void> { ): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId); const teacher = await findTeacherOrThrow(teacherId);
await prisma.user.update({ await prisma.user.update({
where: { id: teacher.id }, where: { id: teacher.id },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) }, data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
}); });
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-password', 'user', teacher.id, null, { passwordChanged: true }),
});
} }
/** /**
@@ -101,8 +135,11 @@ export async function resetTeacherPassword(
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条), * 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
* 否则返回 409 让管理员先转移/清理。 * 否则返回 409 让管理员先转移/清理。
*/ */
export async function deleteTeacher(userId: string): Promise<void> { export async function deleteTeacher(actor: TokenPayload, userId: string): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(userId); const teacher = await findTeacherOrThrow(userId);
if (teacher.role === 'ADMIN' && (await prisma.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除最后一个管理员');
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([ const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }), prisma.class.count({ where: { teacherId: teacher.id } }),
@@ -128,7 +165,21 @@ export async function deleteTeacher(userId: string): Promise<void> {
); );
} }
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`); if (coursewares > 0)
throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
await prisma.user.delete({ where: { id: teacher.id } }); await prisma.$transaction(async (tx) => {
if (teacher.role === 'ADMIN') await protectLastAdmin(tx);
await tx.user.delete({ where: { id: teacher.id } });
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-delete', 'user', teacher.id, teacher, null),
});
});
disconnectUserSessions([teacher.id]);
}
async function protectLastAdmin(tx: Prisma.TransactionClient) {
await tx.user.updateMany({ where: { role: 'ADMIN' }, data: { role: 'ADMIN' } });
if ((await tx.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除或降级最后一个管理员');
} }
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -441,6 +442,7 @@ async function executePromotion(year: number, options: RunOptions) {
for (const track of preview.tracks) for (const track of preview.tracks)
for (const cls of track.classes) for (const cls of track.classes)
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' }); emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
refreshClassSubscriptions();
await notifyScheduleChanged(); await notifyScheduleChanged();
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false }; return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
} }
@@ -9,7 +9,13 @@ import {
} from './promotion.service.js'; } from './promotion.service.js';
import { Router } from 'express'; import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js'; import { ApiError, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import {
authenticate,
getAuthUser,
requireAnyPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js'; import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -74,16 +80,21 @@ router.post(
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位; * - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。 * - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/ */
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => { router.get(
const user = getAuthUser(req); '/',
const { classId } = validatedQuery<{ classId?: string }>(req); requirePermissionOrClassDevice('term:read'),
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功'); validate({ query: termQuerySchema }),
}); async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
},
);
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */ /** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put( router.put(
'/', '/',
requirePermission('term:write'), requireAnyPermission('term:write', 'class:settings'),
validate({ body: updateTermWeeksSchema }), validate({ body: updateTermWeeksSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -102,7 +113,7 @@ router.put(
*/ */
router.post( router.post(
'/auto', '/auto',
requirePermission('term:write'), requireAnyPermission('term:write', 'class:settings'),
validate({ body: autoTermWeeksSchema }), validate({ body: autoTermWeeksSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -121,7 +132,7 @@ router.post(
*/ */
router.get( router.get(
'/holidays', '/holidays',
requirePermission('term:write'), requireAnyPermission('term:write', 'class:settings'),
validate({ query: holidayQuerySchema }), validate({ query: holidayQuerySchema }),
async (req, res) => { async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req); const { year } = validatedQuery<{ year?: number }>(req);
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js';
import { import {
buildAutoTermWeeks, buildAutoTermWeeks,
validateTermWeeks, validateTermWeeks,
@@ -38,9 +39,15 @@ function scopeOf(classId?: string | null): string {
/** 读取某个范围的周次配置 */ /** 读取某个范围的周次配置 */
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> { export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
const scope = scopeOf(classId); const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId);
if (scope) await assertClassAccess(user, scope); if (user.role === 'CLASS_DEVICE' && classId && classId !== scope)
throw ApiError.forbidden('不能读取其他班级周次');
if (!scope) await assertPermission(user, 'term:read');
if (scope) await assertClassAccess(user, scope, 'term:read');
return readTermWeeks(scope);
}
async function readTermWeeks(scope: string): Promise<TermWeeksDto> {
const [weeks] = await Promise.all([ const [weeks] = await Promise.all([
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }), prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
scope scope
@@ -75,8 +82,9 @@ export async function updateTermWeeks(
user: TokenPayload, user: TokenPayload,
input: UpdateTermWeeksInput, input: UpdateTermWeeksInput,
): Promise<TermWeeksDto> { ): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId); const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks()); const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改'); if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改');
@@ -132,7 +140,7 @@ export async function updateTermWeeks(
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`); logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum)); if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
await publishTermChange(scope); await publishTermChange(scope);
return getTermWeeks(user, scope || null); return readTermWeeks(scope);
} }
/** /**
@@ -142,8 +150,9 @@ export async function updateTermWeeks(
* 比从空白开始一周周填要快得多。 * 比从空白开始一周周填要快得多。
*/ */
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> { export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId); const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const context = await loadTermContext(scope || null); const context = await loadTermContext(scope || null);
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate; const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
const maxWeek = input.maxWeek ?? context.maxWeek; const maxWeek = input.maxWeek ?? context.maxWeek;
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Transform } from 'node:stream'; import { Transform } from 'node:stream';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@@ -54,10 +56,14 @@ function releaseRoot(): string {
} }
function releaseDir(version: string): string { function releaseDir(version: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version) || version.includes('..'))
throw ApiError.badRequest('版本号不是安全路径段');
return path.join(releaseRoot(), version); return path.join(releaseRoot(), version);
} }
function releaseFilePath(version: string, fileName: string): string { function releaseFilePath(version: string, fileName: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fileName) || fileName.includes('..'))
throw ApiError.badRequest('文件名不是安全路径段');
return path.join(releaseDir(version), fileName); return path.join(releaseDir(version), fileName);
} }
@@ -128,9 +134,11 @@ export async function resolveReleaseFile(
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */ /** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
export async function registerRelease( export async function registerRelease(
userId: string, actor: TokenPayload,
input: RegisterClientReleaseInput, input: RegisterClientReleaseInput,
): Promise<ClientReleaseDto> { ): Promise<ClientReleaseDto> {
assertAdmin(actor);
const userId = actor.sub;
const version = input.version; const version = input.version;
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致) // 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null; const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
@@ -171,11 +179,12 @@ export async function registerRelease(
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */ /** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
export async function fetchReleaseFromGithub( export async function fetchReleaseFromGithub(
userId: string, actor: TokenPayload,
input: FetchClientReleaseInput, input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> { ): Promise<ClientReleaseDto> {
if (['downloading', 'verifying'].includes(getReleaseProgress().stage)) if (['downloading', 'verifying'].includes(getReleaseProgress().stage))
throw ApiError.conflict('已有安装包正在下载'); throw ApiError.conflict('已有安装包正在下载');
assertAdmin(actor);
const version = input.version; const version = input.version;
setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' }); setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' });
const fileName = clientReleaseFileName(version); const fileName = clientReleaseFileName(version);
@@ -221,13 +230,14 @@ export async function fetchReleaseFromGithub(
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算 // 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
setReleaseProgress({ stage: 'verifying' }); setReleaseProgress({ stage: 'verifying' });
const release = await registerRelease(userId, { version, fileName }); const release = await registerRelease(actor, { version, fileName });
setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size }); setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size });
return release; return release;
} }
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */ /** 删除一份缓存的安装包(元数据 + 磁盘文件) */
export async function deleteRelease(version: string): Promise<{ version: string }> { export async function deleteRelease(actor: TokenPayload, version: string): Promise<{ version: string }> {
assertAdmin(actor);
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null; const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包'); if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
await prisma.clientRelease.delete({ where: { version } }); await prisma.clientRelease.delete({ where: { version } });
@@ -1,3 +1,4 @@
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
@@ -7,9 +8,10 @@ import {
hasAuthPermission, hasAuthPermission,
requireAnyPermission, requireAnyPermission,
requirePermission, requirePermission,
requireStaffPermission, requireAdmin,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js'; } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; import { validateBadRequest as validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import * as clientReleases from './client-releases.service.js'; import * as clientReleases from './client-releases.service.js';
import * as updateService from './update.service.js'; import * as updateService from './update.service.js';
@@ -39,7 +41,7 @@ router.get('/client/progress', requirePermission('client:update'), async (_req,
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存, * 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。 * 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
*/ */
router.get('/check', requireStaffPermission('update:check'), async (req, res) => { router.get('/check', requirePermissionOrClassDevice('update:check'), async (req, res) => {
const force = req.query.force === '1' && hasAuthPermission(req, 'update:force'); const force = req.query.force === '1' && hasAuthPermission(req, 'update:force');
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成'); sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
}); });
@@ -68,13 +70,14 @@ router.get(
*/ */
router.post( router.post(
'/client/fetch', '/client/fetch',
requireAdmin(),
requirePermission('client:update'), requirePermission('client:update'),
validate({ body: fetchClientReleaseSchema }), validate({ body: fetchClientReleaseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req); const input = validatedBody<FetchClientReleaseInput>(req);
try { try {
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); sendCreated(res, await clientReleases.fetchReleaseFromGithub(user, input), '安装包已下载并登记');
} catch (error) { } catch (error) {
if ( if (
!(error instanceof ApiError && error.status === 409) && !(error instanceof ApiError && error.status === 409) &&
@@ -97,23 +100,25 @@ router.post(
*/ */
router.post( router.post(
'/client/releases', '/client/releases',
requireAdmin(),
requirePermission('client:update'), requirePermission('client:update'),
validate({ body: registerClientReleaseSchema }), validate({ body: registerClientReleaseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const input = validatedBody<RegisterClientReleaseInput>(req); const input = validatedBody<RegisterClientReleaseInput>(req);
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记'); sendCreated(res, await clientReleases.registerRelease(user, input), '安装包已登记');
}, },
); );
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */ /** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete( router.delete(
'/client/releases/:version', '/client/releases/:version',
requireAdmin(),
requirePermission('client:update'), requirePermission('client:update'),
validate({ params: clientReleaseVersionParamSchema }), validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => { async (req, res) => {
const { version } = validatedParams<{ version: string }>(req); const { version } = validatedParams<{ version: string }>(req);
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除'); sendOk(res, await clientReleases.deleteRelease(getAuthUser(req), version), '安装包已删除');
}, },
); );
@@ -128,6 +133,13 @@ router.get(
'/client/download/:version', '/client/download/:version',
validate({ params: clientReleaseVersionParamSchema }), validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req);
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
else {
await assertPermission(user, 'client:read');
if (user.role !== 'ADMIN' && !(await classIdsFor(user, 'client:read')).length)
throw ApiError.forbidden('无可访问班级');
}
const { version } = validatedParams<{ version: string }>(req); const { version } = validatedParams<{ version: string }>(req);
const found = await clientReleases.resolveReleaseFile(version); const found = await clientReleases.resolveReleaseFile(version);
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包'); if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
@@ -13,7 +13,7 @@ export const clientReleaseVersionSchema = z
.trim() .trim()
.min(1, '请输入版本号') .min(1, '请输入版本号')
.max(40, '版本号最多 40 个字符') .max(40, '版本号最多 40 个字符')
.refine((value) => SAFE_SEGMENT.test(value), { .refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '版本号只能包含字母、数字、点、下划线与短横线', message: '版本号只能包含字母、数字、点、下划线与短横线',
}); });
@@ -30,7 +30,9 @@ export const registerClientReleaseSchema = z.object({
.trim() .trim()
.min(1) .min(1)
.max(120) .max(120)
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' }) .refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '文件名只能包含字母、数字、点、下划线与短横线',
})
.optional(), .optional(),
notes: z.string().trim().max(500).optional(), notes: z.string().trim().max(500).optional(),
}); });
+14 -2
View File
@@ -1,4 +1,4 @@
import { SOCKET_ROOMS } from '@classhelper/shared'; import { SOCKET_ROOMS, SOCKET_EVENT_CHANNELS } from '@classhelper/shared';
/** /**
* 实时事件总线。 * 实时事件总线。
@@ -21,7 +21,19 @@ export function emitToRooms(rooms: string[], event: string, payload: unknown): v
/** 按班级房间广播(class:{classId}) */ /** 按班级房间广播(class:{classId}) */
export function emitToClass(classId: string, event: string, payload: unknown): void { export function emitToClass(classId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload); emitToRooms([SOCKET_ROOMS.classChannel(classId, SOCKET_EVENT_CHANNELS[event] ?? 'core')], event, payload);
}
let disconnectUsers: (ids: string[] | null) => void = () => {};
export function registerSessionInvalidator(invalidate: (ids: string[] | null) => void) {
disconnectUsers = invalidate;
}
export function disconnectUserSessions(ids: string[]) {
disconnectUsers(ids);
}
/** 班级集合或任课关系变化后,重连重新解析按键范围。 */
export function refreshClassSubscriptions() {
disconnectUsers(null);
} }
/** /**
+91 -103
View File
@@ -3,36 +3,42 @@ import { Server } from 'socket.io';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
SOCKET_ROOMS, SOCKET_ROOMS,
SOCKET_CHANNELS,
type SocketChannel,
type PermissionKey,
type ClientToServerEvents, type ClientToServerEvents,
type ServerToClientEvents, type ServerToClientEvents,
type SocketAuthPayload, type SocketAuthPayload,
type UserRole,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { getAccessibleClassIds } from '../lib/access.js'; import { verifyToken, type TokenPayload } from '../lib/jwt.js';
import { verifyToken } from '../lib/jwt.js'; import { loadSessionUser } from '../lib/authz/session.js';
import { classIdsFor, loadAuthPrincipal, classDeviceClassId } from '../lib/authz/index.js';
import { logger } from '../lib/logger.js'; import { logger } from '../lib/logger.js';
import { registerEmitter, type RealtimeEmitter } from './bus.js'; import { registerEmitter, registerSessionInvalidator } from './bus.js';
interface SocketData { interface SocketData {
user: { user: TokenPayload;
id: string; token: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
classSession: boolean;
};
} }
export type RealtimeServer = Server< export type RealtimeServer = Server<
ClientToServerEvents, ClientToServerEvents,
ServerToClientEvents, ServerToClientEvents,
Record<string, never>, Record<string, never>,
SocketData SocketData
>; >;
const channelKeys: Record<SocketChannel, PermissionKey[]> = {
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */ core: ['class:read', 'schedule:read'],
homework: ['homework:read'],
notification: ['notification:read'],
grade: ['grade:read'],
courseware: ['courseware:read'],
};
async function channelClasses(user: TokenPayload, channel: SocketChannel): Promise<Set<string>> {
const id = classDeviceClassId(user);
if (id) return new Set([id]);
return new Set((await Promise.all(channelKeys[channel].map((key) => classIdsFor(user, key)))).flat());
}
export function initRealtime(httpServer: HttpServer): RealtimeServer { export function initRealtime(httpServer: HttpServer): RealtimeServer {
const io: RealtimeServer = new Server(httpServer, { const io: RealtimeServer = new Server(httpServer, {
cors: { origin: env.corsOrigins, credentials: true }, cors: { origin: env.corsOrigins, credentials: true },
@@ -40,110 +46,92 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
pingInterval: 25000, pingInterval: 25000,
pingTimeout: 20000, pingTimeout: 20000,
}); });
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
io.use((socket, next) => { io.use((socket, next) => {
try { void (async () => {
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>; try {
const queryToken = const auth = socket.handshake.auth as Partial<SocketAuthPayload>;
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null; const token =
const token = auth.token ?? queryToken; auth.token ??
if (!token) { (typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : '');
next(new Error('缺少认证 token')); const user = await loadSessionUser(verifyToken(token));
return; await loadAuthPrincipal(user);
socket.data = { user, token };
next();
} catch {
next(new Error('认证失败,请重新登录'));
} }
const payload = verifyToken(token); })();
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
socket.data.user = {
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
classSession: payload.classSession === true,
};
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
const user = socket.data.user; const user = socket.data.user;
void (async () => { // 手动订阅每次回查账号与权限;不沿用握手时的权限快照。
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)]; const ready = (async () => {
const base = [
try { SOCKET_ROOMS.session(user.sub),
const classIds = await getAccessibleClassIds({ SOCKET_ROOMS.role(user.role),
sub: user.id, user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers,
username: user.username, ];
name: user.name, await socket.join(base);
role: user.role, for (const channel of SOCKET_CHANNELS)
classId: user.classId, for (const id of await channelClasses(user, channel))
classSession: user.classSession, await socket.join(SOCKET_ROOMS.classChannel(id, channel));
}); socket.emit(SOCKET_EVENTS.connected, {
for (const classId of classIds) { sessionId: user.sub,
await socket.join(SOCKET_ROOMS.class(classId)); role: user.role,
rooms.push(SOCKET_ROOMS.class(classId)); rooms: [...socket.rooms].filter((r) => r !== socket.id),
} });
} catch (error) { })().catch((error) => {
logger.warn(`加入班级房间失败(user=${user.id})`, error); logger.warn('实时订阅失败', error);
} socket.disconnect(true);
});
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers socket.on('class:join', (request, ack) => {
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
await socket.join(audienceRoom);
rooms.push(audienceRoom);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)
socket.on('class:join', (classId, ack) => {
void (async () => { void (async () => {
await ready;
try { try {
const allowed = await getAccessibleClassIds({ const current = await loadSessionUser(verifyToken(socket.data.token));
sub: user.id, const classId = typeof request === 'string' ? request : request.classId;
username: user.username, const requested =
name: user.name, typeof request === 'string' ? SOCKET_CHANNELS : (request.channels ?? SOCKET_CHANNELS);
role: user.role, if (!requested.length || requested.some((c) => !SOCKET_CHANNELS.includes(c))) {
classId: user.classId, ack?.({ ok: false, message: '未知事件通道' });
classSession: user.classSession,
});
if (!allowed.includes(classId)) {
ack?.({ ok: false, message: '无权订阅该班级' });
return; return;
} }
await socket.join(SOCKET_ROOMS.class(classId)); const allowed: SocketChannel[] = [];
for (const channel of requested)
if ((await channelClasses(current, channel)).has(classId)) allowed.push(channel);
// 旧字符串协议只加入有权通道;新协议显式通道有任一无权则全部拒绝。
if (!allowed.length || (typeof request !== 'string' && allowed.length !== requested.length)) {
ack?.({ ok: false, message: '无权订阅该班级或通道' });
return;
}
for (const channel of allowed) await socket.join(SOCKET_ROOMS.classChannel(classId, channel));
ack?.({ ok: true }); ack?.({ ok: true });
} catch { } catch {
ack?.({ ok: false, message: '订阅失败' }); ack?.({ ok: false, message: '订阅失败' });
} }
})(); })();
}); });
socket.on('class:leave', (request) => {
socket.on('class:leave', (classId) => { void (async () => {
void socket.leave(SOCKET_ROOMS.class(classId)); await ready;
}); const classId = typeof request === 'string' ? request : request.classId;
for (const channel of typeof request === 'string'
socket.on('ping', (ack) => { ? SOCKET_CHANNELS
ack?.(); : (request.channels ?? SOCKET_CHANNELS))
}); await socket.leave(SOCKET_ROOMS.classChannel(classId, channel));
})();
socket.on('disconnect', (reason) => {
logger.debug(`实时连接断开:${user.name} (${reason})`);
}); });
socket.on('ping', (ack) => ack?.());
});
registerEmitter((rooms, event, payload) => {
(io.to(rooms) as unknown as { emit(event: string, payload: unknown): void }).emit(event, payload);
});
registerSessionInvalidator((ids) => {
if (ids === null) {
io.in(SOCKET_ROOMS.teachers).disconnectSockets(true);
return;
}
for (const id of ids) io.in(SOCKET_ROOMS.session(id)).disconnectSockets(true);
}); });
const emit: RealtimeEmitter = (rooms, event, payload) => {
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
target.emit(event, payload);
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
};
registerEmitter(emit);
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / session:{id})');
return io; return io;
} }
+1 -1
View File
@@ -89,7 +89,7 @@ export function getMaxTermWeeks(configured?: unknown): number {
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : 30; return Number.isInteger(value) && value >= 1 && value <= 60 ? value : 30;
} }
export type PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL'; export type PermissionScope = 'TEACHING' | 'HEAD' | 'GRADE' | 'DIVISION' | 'ALL';
export interface ReleaseProgressDto { export interface ReleaseProgressDto {
version: string; version: string;
stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed'; stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed';
+18
View File
@@ -493,6 +493,22 @@ export const SOCKET_EVENTS = {
connected: 'connected', connected: 'connected',
} as const; } as const;
/** 班级事件域;订阅资格按对应读能力逐域求值。 */
export const SOCKET_CHANNELS = ['core', 'homework', 'notification', 'grade', 'courseware'] as const;
export type SocketChannel = (typeof SOCKET_CHANNELS)[number];
export const SOCKET_EVENT_CHANNELS: Record<string, SocketChannel> = {
'class:updated': 'core',
'schedule:updated': 'core',
'homework:new': 'homework',
'homework:updated': 'homework',
'homework:status': 'homework',
'notification:new': 'notification',
'notification:deleted': 'notification',
'call:new': 'notification',
'grade:updated': 'grade',
'courseware:changed': 'courseware',
};
/** ClassIsland JSON 导入:时间点类型(与 TimeLayoutItem.TimeType 一致) */ /** ClassIsland JSON 导入:时间点类型(与 TimeLayoutItem.TimeType 一致) */
export const CLASSISLAND_TIME_TYPES = { export const CLASSISLAND_TIME_TYPES = {
class: 0, class: 0,
@@ -560,6 +576,8 @@ export function buildCallTitle(studentName: string, teacherName: string): string
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */ /** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = { export const SOCKET_ROOMS = {
classChannel: (classId: string, channel: SocketChannel): string => `class:${classId}:${channel}`,
/** @deprecated 仅供旧协议测试识别,生产不再订阅。 */
class: (classId: string): string => `class:${classId}`, class: (classId: string): string => `class:${classId}`,
/** /**
* 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。 * 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。
+11 -5
View File
@@ -2,14 +2,13 @@ import type { PermissionScope } from './class-plan.js';
/** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */ /** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */
export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) { export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) {
if (scope === 'ALL') return { scope, scopeValue: '', grades: [], scopeLabel: '全校' }; if (scope === 'ALL' || scope === 'TEACHING' || scope === 'HEAD') {
if (scope === 'SELF_CLASS') { if (value !== '') throw new Error('关联班级或全校范围不接受范围值');
if (value !== '' && value !== 'HEAD') throw new Error('关联班级范围只允许全部关联班级或仅班主任班');
return { return {
scope, scope,
scopeValue: value, scopeValue: '',
grades: [], grades: [],
scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级', scopeLabel: scope === 'ALL' ? '全校' : scope === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
}; };
} }
if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围'); if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围');
@@ -50,3 +49,10 @@ export function defaultPromotionPlan(classCount = 1): PromotionPlan {
resetTermWeeks: true, resetTermWeeks: true,
}; };
} }
/** 仅迁移和旧快照恢复使用;运行时不接受旧枚举。 */
export function migratePermissionScope(scope: string, scopeValue = '') {
return scope === 'SELF_CLASS'
? { scope: scopeValue === 'HEAD' ? 'HEAD' : 'TEACHING', scopeValue: '' }
: { scope, scopeValue };
}
+92 -7
View File
@@ -1,4 +1,5 @@
import type { UserRole } from './types.js'; import type { UserRole } from './types.js';
import type { PermissionScope } from './class-plan.js';
/** /**
* 班级内的角色(相对"某一个班级"而言)。 * 班级内的角色(相对"某一个班级"而言)。
@@ -168,6 +169,8 @@ export const PERMISSION_KEYS = [
// 班级与人员 // 班级与人员
'class:read', 'class:read',
'class:manage', 'class:manage',
'class:account',
'class:settings',
'roster:read', 'roster:read',
'roster:manage', 'roster:manage',
'teacher:read', 'teacher:read',
@@ -243,11 +246,22 @@ export const BUILTIN_TEACHER_GROUPS = [
{ {
id: 'builtin-head', id: 'builtin-head',
name: '班主任', name: '班主任',
scope: 'SELF_CLASS', scope: 'HEAD',
scopeValue: 'HEAD', scopeValue: '',
keys: [...TEACHING_KEYS, 'client:read', 'client:manage'], keys: [
...TEACHING_KEYS,
'class:read',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
'import:students',
'import:schedule',
'client:read',
'client:manage',
],
}, },
{ id: 'builtin-teacher', name: '教师', scope: 'SELF_CLASS', scopeValue: '', keys: TEACHING_KEYS }, { id: 'builtin-teacher', name: '教师', scope: 'TEACHING', scopeValue: '', keys: TEACHING_KEYS },
{ id: 'builtin-grade', name: '级长', scope: 'GRADE', scopeValue: '', keys: LEAD_KEYS }, { id: 'builtin-grade', name: '级长', scope: 'GRADE', scopeValue: '', keys: LEAD_KEYS },
{ id: 'builtin-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS }, { id: 'builtin-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS },
] as const; ] as const;
@@ -257,7 +271,9 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'courseware:read': '查看与下载课件', 'courseware:read': '查看与下载课件',
'courseware:write': '上传、设置可见范围与推送自己的课件', 'courseware:write': '上传、设置可见范围与推送自己的课件',
'class:read': '查看班级与班级列表', 'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)', 'class:manage': '创建 / 修改 / 删除班级',
'class:account': '管理本班班级码与密码(仅班主任)',
'class:settings': '管理本班学期设置(仅班主任)',
'roster:read': '查看学生名单', 'roster:read': '查看学生名单',
'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)', 'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)',
'teacher:read': '查看教师名单', 'teacher:read': '查看教师名单',
@@ -305,6 +321,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
keys: [ keys: [
'class:read', 'class:read',
'class:manage', 'class:manage',
'class:account',
'class:settings',
'roster:read', 'roster:read',
'roster:manage', 'roster:manage',
'teacher:read', 'teacher:read',
@@ -376,6 +394,8 @@ export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]
'courseware:write': ['ADMIN', 'TEACHER'], 'courseware:write': ['ADMIN', 'TEACHER'],
'class:read': ['ADMIN', 'TEACHER'], 'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'], 'class:manage': ['ADMIN'],
'class:account': ['ADMIN', 'TEACHER'],
'class:settings': ['ADMIN', 'TEACHER'],
'roster:read': ['ADMIN', 'TEACHER'], 'roster:read': ['ADMIN', 'TEACHER'],
'roster:manage': ['ADMIN', 'TEACHER'], 'roster:manage': ['ADMIN', 'TEACHER'],
'teacher:read': ['ADMIN', 'TEACHER'], 'teacher:read': ['ADMIN', 'TEACHER'],
@@ -422,7 +442,7 @@ export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
/** /**
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。 * 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
* *
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。 * `ADMIN` 固定为全集,不由模板限制。
*/ */
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = { export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
ADMIN: PERMISSION_KEYS, ADMIN: PERMISSION_KEYS,
@@ -460,7 +480,7 @@ export const PERMISSION_MATRIX: readonly {
subject: boolean; subject: boolean;
classDevice?: boolean; classDevice?: boolean;
}[] = [ }[] = [
{ action: '学年升级(默认管理员,可按权限组授权)', admin: true, head: false, subject: false }, { action: '学年升级(仅管理员)', admin: true, head: false, subject: false },
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false }, { action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false }, { action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false }, { action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
@@ -474,3 +494,68 @@ export const PERMISSION_MATRIX: readonly {
{ action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: true }, { action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: true },
{ action: '查看 ClassHelper 班级端在线状态', admin: true, head: true, subject: true }, { action: '查看 ClassHelper 班级端在线状态', admin: true, head: true, subject: true },
]; ];
/** 能力目录:管理员能力不可下放,关系提示不替代服务层数据校验。 */
export const PERMISSION_SCOPES = ['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL'] as const;
export interface PermissionMeta {
kind: 'admin' | 'class' | 'org';
risk: 'normal' | 'high';
relation: 'head' | 'subject' | 'publish' | 'creator' | 'none';
allowedScopes: readonly PermissionScope[];
defaultRoles: readonly UserRole[];
label: string;
category: string;
}
const ADMIN_KEYS: readonly PermissionKey[] = [
'class:manage',
'teacher:manage',
'permission:manage',
'system:manage',
'database:manage',
'update:force',
'ai:manage',
'import:teachers',
'import:class-teachers',
'timetable:manage',
'promotion:manage',
'archive:manage',
'term:write',
'client:update',
];
const ORG_KEYS: readonly PermissionKey[] = ['teacher:read', 'update:check'];
const HIGH_KEYS: readonly PermissionKey[] = [
...ADMIN_KEYS,
'class:account',
'client:manage',
'client:update-push',
'remote:control',
'import:students',
'import:schedule',
'grade:import',
];
export const PERMISSION_META: Record<PermissionKey, PermissionMeta> = Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const kind = ADMIN_KEYS.includes(key) ? 'admin' : ORG_KEYS.includes(key) ? 'org' : 'class';
const relation = ['class:account', 'class:settings'].includes(key)
? 'head'
: ['homework:write', 'grade:write', 'grade:import'].includes(key)
? 'subject'
: ['notification:publish', 'call:create'].includes(key)
? 'publish'
: key === 'notification:delete'
? 'creator'
: 'none';
return [
key,
{
kind,
relation,
risk: HIGH_KEYS.includes(key) ? 'high' : 'normal',
allowedScopes: kind === 'class' ? PERMISSION_SCOPES : [],
defaultRoles: PERMISSION_DEFAULT_ROLES[key],
label: PERMISSION_LABELS[key],
category: PERMISSION_CATEGORIES.find((c) => c.keys.includes(key))?.title ?? '',
},
];
}),
) as unknown as Record<PermissionKey, PermissionMeta>;
+51 -13
View File
@@ -12,7 +12,8 @@ import type {
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导): // 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。 // 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
import type { PermissionKey } from './permissions.js'; import type { PermissionKey, PermissionMeta } from './permissions.js';
import type { SocketChannel } from './constants.js';
/** /**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。 * 账号角色:管理员 / 教师 / ClassHelper 班级端。
@@ -495,14 +496,12 @@ export interface CreateClassRequest {
} }
export interface UpdateClassRequest { export interface UpdateClassRequest {
/** 本班学期开始日期(YYYY-MM-DD,第 1 教学周的周一);留空串表示回落到全局配置 */
termStartDate?: string | null;
/** 入学年份(有名无实的旧数据需要补填;填了会重算 name) */
enrollmentYear?: number; enrollmentYear?: number;
/** 班号(同上) */
classIndex?: number; classIndex?: number;
/** 本学期教学周数(1~30,仅管理员可调) */ }
export interface UpdateClassSettingsRequest {
termWeeks?: number; termWeeks?: number;
termStartDate?: string | null;
} }
/** 班级端成绩查询开关 */ /** 班级端成绩查询开关 */
@@ -1205,6 +1204,10 @@ export interface IslandState {
/* ------------------------------------------------------------------ 仪表盘 */ /* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary { export interface DashboardSummary {
sections: Record<
'classes' | 'students' | 'courses' | 'schedules' | 'homeworks' | 'notifications' | 'grades',
boolean
>;
classCount: number; classCount: number;
studentCount: number; studentCount: number;
courseCount: number; courseCount: number;
@@ -1325,8 +1328,11 @@ export interface TimeLayoutImportResult {
/** 客户端 -> 服务端事件名 */ /** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents { export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void; 'class:join': (
'class:leave': (classId: string) => void; classId: string | { classId: string; channels?: SocketChannel[] },
ack?: (result: { ok: boolean; message?: string }) => void,
) => void;
'class:leave': (classId: string | { classId: string; channels?: SocketChannel[] }) => void;
ping: (ack?: () => void) => void; ping: (ack?: () => void) => void;
} }
@@ -1465,6 +1471,12 @@ export interface UpdateInfo {
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。 * **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
*/ */
export interface PermissionGroupDto { export interface PermissionGroupDto {
defaultScope: ContractPermissionScope;
defaultScopeValue: string;
defaultScopeLabel: string;
kind: 'builtin' | 'custom' | 'default';
inactiveKeys: PermissionKey[];
warnings: string[];
scope?: ContractPermissionScope; scope?: ContractPermissionScope;
scopeValue?: string; scopeValue?: string;
scopeLabel: string; scopeLabel: string;
@@ -1481,14 +1493,32 @@ export interface PermissionGroupDto {
} }
/** 权限页里的一个成员(管理员/教师账号) */ /** 权限页里的一个成员(管理员/教师账号) */
export interface PermissionAssignment {
groupId: string;
scope: ContractPermissionScope;
scopeValue: string;
}
export interface EffectivePermissionsDto {
permissions: PermissionKey[];
entries: {
key: PermissionKey;
scopes: string[];
classes: { id: string; name: string }[];
classCount: number;
}[];
conflicts: string[];
headOnlyKeys: PermissionKey[];
}
export interface PermissionMemberDto { export interface PermissionMemberDto {
fixed: boolean;
assignments: (PermissionAssignment & { groupName: string; scopeLabel: string })[];
id: string; id: string;
username: string; username: string;
name: string; name: string;
role: UserRole; role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */ /** 所属模板的派生 id;空数组 = 教师默认模板 */
groupIds: string[]; groupIds: string[];
/** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */ /** 生效能力数量;管理员固定为全集 */
permissionCount: number; permissionCount: number;
permissions: PermissionKey[]; permissions: PermissionKey[];
scopeLabels: string[]; scopeLabels: string[];
@@ -1496,6 +1526,7 @@ export interface PermissionMemberDto {
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */ /** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
export interface PermissionCatalogDto { export interface PermissionCatalogDto {
meta: Record<PermissionKey, PermissionMeta>;
keys: PermissionKey[]; keys: PermissionKey[];
labels: Record<PermissionKey, string>; labels: Record<PermissionKey, string>;
categories: { title: string; keys: PermissionKey[] }[]; categories: { title: string; keys: PermissionKey[] }[];
@@ -1503,7 +1534,7 @@ export interface PermissionCatalogDto {
defaultRoles: Record<PermissionKey, UserRole[]>; defaultRoles: Record<PermissionKey, UserRole[]>;
/** 每个角色的默认模板("没有分组时会是什么") */ /** 每个角色的默认模板("没有分组时会是什么") */
roleDefaults: Record<UserRole, PermissionKey[]>; roleDefaults: Record<UserRole, PermissionKey[]>;
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */ /** 兼容字段:管理员固定拥有的全部目录键 */
unremovable: PermissionKey[]; unremovable: PermissionKey[];
} }
@@ -1525,7 +1556,8 @@ export interface UpdatePermissionGroupRequest {
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */ /** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
export interface AssignUserGroupsRequest { export interface AssignUserGroupsRequest {
groupIds: string[]; groupIds?: string[];
assignments?: PermissionAssignment[];
} }
/** /**
@@ -1535,9 +1567,15 @@ export interface AssignUserGroupsRequest {
* 真正的拦截始终在服务端。 * 真正的拦截始终在服务端。
*/ */
export interface MyPermissionsDto { export interface MyPermissionsDto {
/** 各能力实际班级集合,供界面选择器与按钮使用;服务端仍须再授权。 */
classScopes?: Partial<Record<PermissionKey, string[]>>;
manageScopes?: Partial<Record<PermissionKey, string[]>>;
/** 结合班主任、任课及发布者关系后的动作范围,用于维护页面入口。 */
actionScopes?: Partial<Record<PermissionKey, string[]>>;
allScopeKeys?: PermissionKey[];
role: UserRole; role: UserRole;
permissions: PermissionKey[]; permissions: PermissionKey[];
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */ /** true = 教师有显式分配;管理员和班级端恒为 false */
fromGroups: boolean; fromGroups: boolean;
} }
+4
View File
@@ -105,6 +105,10 @@ export const classApi = {
create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload), create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload),
update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> => update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}`, payload), api.patch(`${API_PATHS.classes}/${id}`, payload),
updateSettings: (
id: string,
payload: { termWeeks?: number; termStartDate?: string | null },
): Promise<ClassDto> => api.patch(`${API_PATHS.classes}/${id}/settings`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`), remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`),
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`), students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
addStudent: ( addStudent: (
@@ -21,6 +21,7 @@ const props = defineProps<{
layouts: TimeLayoutDetailDto[]; layouts: TimeLayoutDetailDto[];
courses: CourseDto[]; courses: CourseDto[];
canEdit: boolean; canEdit: boolean;
canCreate: boolean;
saving: boolean; saving: boolean;
/** 引用当前时间表的日课表数量,显示在「时间表信息」里。 */ /** 引用当前时间表的日课表数量,显示在「时间表信息」里。 */
planCount: number; planCount: number;
@@ -321,7 +322,7 @@ onUnmounted(() => {
<aside class="layout-explorer" aria-label="时间表列表"> <aside class="layout-explorer" aria-label="时间表列表">
<el-input v-model="search" placeholder="搜索时间表" clearable /> <el-input v-model="search" placeholder="搜索时间表" clearable />
<div class="explorer-actions"> <div class="explorer-actions">
<button class="command" :disabled="!canEdit" @click="emit('create')"> <button class="command" :disabled="!canCreate" @click="emit('create')">
<ArchiveIcon name="plus" /> <ArchiveIcon name="plus" />
新时间表 新时间表
</button> </button>
@@ -388,7 +389,7 @@ onUnmounted(() => {
<div v-if="!layout" class="panel-empty"> <div v-if="!layout" class="panel-empty">
<ArchiveIcon name="clock" /> <ArchiveIcon name="clock" />
<p>新建一份时间表开始编辑</p> <p>新建一份时间表开始编辑</p>
<button class="command" :disabled="!canEdit" @click="emit('create')">+ 新时间表</button> <button class="command" :disabled="!canCreate" @click="emit('create')">+ 新时间表</button>
</div> </div>
<div v-else-if="view === 'list'" class="point-list" aria-label="时间点列表"> <div v-else-if="view === 'list'" class="point-list" aria-label="时间点列表">
<div <div
+7 -2
View File
@@ -1,4 +1,9 @@
/** 只定义分组;子页标题、图标和权限直接读取路由,避免菜单与路由各维护一套权限。 */ import type { PermissionKey } from '@classhelper/shared';
/** 字符串动作使用服务端业务范围;manage 用于班级管理,permission 用于无班级目标的操作。 */
export type MenuAction = PermissionKey | { key: PermissionKey; scope: 'manage' | 'permission' };
/** 只定义分组;标题、图标、读权限和可操作条件均读取路由。 */
export const MANAGEMENT_GROUPS = [ export const MANAGEMENT_GROUPS = [
{ id: 'workspace', title: '工作台', icon: 'Odometer', paths: ['/dashboard', '/assistant'] }, { id: 'workspace', title: '工作台', icon: 'Odometer', paths: ['/dashboard', '/assistant'] },
{ {
@@ -11,7 +16,7 @@ export const MANAGEMENT_GROUPS = [
id: 'school', id: 'school',
title: '班级与人员', title: '班级与人员',
icon: 'School', icon: 'School',
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'], paths: ['/classes', '/students', '/teachers', '/term-weeks', '/promotion', '/archives'],
}, },
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] }, { id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
{ {
+4 -20
View File
@@ -77,18 +77,7 @@ function openAssistant(): void {
void router.push('/assistant'); void router.push('/assistant');
} }
/** /** 菜单标题、图标、能力取路由;服务端负责范围与业务关系的最终校验。 */
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
* - 作业 / 通知:所有教师
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
* (见 `visibleMenuItems`)
*/
const menuItems = router const menuItems = router
.getRoutes() .getRoutes()
.filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path))) .filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path)))
@@ -96,16 +85,11 @@ const menuItems = router
path: record.path, path: record.path,
title: String(record.meta.title || record.path), title: String(record.meta.title || record.path),
icon: String(record.meta.icon || 'Setting'), icon: String(record.meta.icon || 'Setting'),
roles: record.meta.roles as string[] | undefined,
permission: record.meta.permission as PermissionKey | undefined, permission: record.meta.permission as PermissionKey | undefined,
menuActions: record.meta.menuActions,
})); }));
const visibleMenuItems = computed(() => const visibleMenuItems = computed(() =>
menuItems.filter( menuItems.filter((item) => !!auth.role && auth.canShowMenu(item.permission, item.menuActions)),
(item) =>
!!auth.role &&
(!item.roles?.length || item.roles.includes(auth.role)) &&
(!item.permission || auth.can(item.permission)),
),
); );
const menuGroups = computed(() => const menuGroups = computed(() =>
MANAGEMENT_GROUPS.map((group) => ({ MANAGEMENT_GROUPS.map((group) => ({
@@ -123,12 +107,12 @@ const searchResults = computed(() =>
); );
const tabs = ref<Array<ManagementMenuItem & { fullPath: string }>>([]); const tabs = ref<Array<ManagementMenuItem & { fullPath: string }>>([]);
function addCurrentTab() { function addCurrentTab() {
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
const item = visibleMenuItems.value.find((item) => item.path === route.path); const item = visibleMenuItems.value.find((item) => item.path === route.path);
if (!item) return; if (!item) return;
const tab = tabs.value.find((tab) => tab.path === item.path); const tab = tabs.value.find((tab) => tab.path === item.path);
if (tab) tab.fullPath = route.fullPath; if (tab) tab.fullPath = route.fullPath;
else tabs.value.push({ path: item.path, title: item.title, icon: item.icon, fullPath: route.fullPath }); else tabs.value.push({ path: item.path, title: item.title, icon: item.icon, fullPath: route.fullPath });
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
if (tabs.value.length > 20) tabs.value.splice(tabs.value[0]?.path === '/dashboard' ? 1 : 0, 1); if (tabs.value.length > 20) tabs.value.splice(tabs.value[0]?.path === '/dashboard' ? 1 : 0, 1);
} }
watch([() => route.fullPath, visibleMenuItems], addCurrentTab, { immediate: true }); watch([() => route.fullPath, visibleMenuItems], addCurrentTab, { immediate: true });
+78 -46
View File
@@ -21,8 +21,8 @@ const routes: RouteRecordRaw[] = [
meta: { meta: {
title: '课件', title: '课件',
icon: 'FolderOpened', icon: 'FolderOpened',
roles: ['ADMIN', 'TEACHER'],
permission: 'courseware:read', permission: 'courseware:read',
menuActions: [{ key: 'courseware:write', scope: 'permission' }],
}, },
}, },
{ {
@@ -32,15 +32,13 @@ const routes: RouteRecordRaw[] = [
meta: { meta: {
title: '课表档案', title: '课表档案',
icon: 'Calendar', icon: 'Calendar',
roles: ['ADMIN', 'TEACHER'],
permission: 'schedule:read', permission: 'schedule:read',
menuActions: ['schedule:write', 'timetable:manage'],
}, },
}, },
{ {
path: 'roster', path: 'roster',
name: 'roster', redirect: (to) => ({ path: '/students', query: to.query, hash: to.hash }),
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' },
}, },
{ {
path: 'system-settings', path: 'system-settings',
@@ -52,37 +50,59 @@ const routes: RouteRecordRaw[] = [
path: 'dashboard', path: 'dashboard',
name: 'dashboard', name: 'dashboard',
component: () => import('@/views/DashboardView.vue'), component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' }, meta: { title: '仪表盘', icon: 'Odometer', permission: 'dashboard:read' },
}, },
{ {
path: 'classes', path: 'classes',
name: 'classes', name: 'classes',
component: () => import('@/views/ClassesView.vue'), component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School', permission: 'class:read' }, meta: {
title: '班级管理',
icon: 'School',
permission: 'class:read',
menuActions: [
'class:manage',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
{ key: 'grade:write', scope: 'manage' },
],
},
}, },
{ {
path: 'students', path: 'students',
name: 'students', name: 'students',
component: () => import('@/views/StudentsView.vue'), component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User', permission: 'roster:read' }, meta: {
title: '学生管理',
icon: 'User',
permission: 'roster:read',
menuActions: ['roster:manage', 'call:create'],
},
}, },
{ {
path: 'teachers', path: 'teachers',
name: 'teachers', name: 'teachers',
component: () => import('@/views/TeachersView.vue'), component: () => import('@/views/TeachersView.vue'),
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] }, meta: { title: '教师管理', icon: 'UserFilled', permission: 'teacher:manage' },
}, },
{ {
path: 'term-weeks', path: 'term-weeks',
name: 'term-weeks', name: 'term-weeks',
component: () => import('@/views/TermWeeksView.vue'), component: () => import('@/views/TermWeeksView.vue'),
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限 // 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' }, meta: {
title: '学期周次',
icon: 'Calendar',
permission: 'term:read',
menuActions: ['term:write', 'class:settings'],
},
}, },
{ {
path: 'promotion', path: 'promotion',
name: 'promotion', name: 'promotion',
component: () => import('@/components/PromotionSettings.vue'), component: () => import('@/views/PromotionView.vue'),
meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' }, meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' },
}, },
{ {
@@ -96,25 +116,40 @@ const routes: RouteRecordRaw[] = [
path: 'schedules', path: 'schedules',
name: 'schedules', name: 'schedules',
redirect: '/plan-editor', redirect: '/plan-editor',
meta: { title: '课表管理', icon: 'Calendar' }, meta: { title: '课表管理', icon: 'Calendar', permission: 'schedule:read' },
}, },
{ {
path: 'homeworks', path: 'homeworks',
name: 'homeworks', name: 'homeworks',
component: () => import('@/views/HomeworksView.vue'), component: () => import('@/views/HomeworksView.vue'),
meta: { title: '作业发布', icon: 'Notebook' }, meta: {
title: '作业发布',
icon: 'Notebook',
permission: 'homework:read',
menuActions: ['homework:write'],
},
}, },
{ {
path: 'notifications', path: 'notifications',
name: 'notifications', name: 'notifications',
component: () => import('@/views/NotificationsView.vue'), component: () => import('@/views/NotificationsView.vue'),
meta: { title: '通知发布', icon: 'Bell' }, meta: {
title: '通知发布',
icon: 'Bell',
permission: 'notification:read',
menuActions: ['notification:publish', 'notification:delete', 'call:create'],
},
}, },
{ {
path: 'grades', path: 'grades',
name: 'grades', name: 'grades',
component: () => import('@/views/GradesView.vue'), component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' }, meta: {
title: '成绩录入',
icon: 'Trophy',
permission: 'grade:read',
menuActions: ['grade:write', 'grade:import'],
},
}, },
{ {
path: 'assistant', path: 'assistant',
@@ -122,7 +157,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AssistantView.vue'), component: () => import('@/views/AssistantView.vue'),
// 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板): // 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板):
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失 // 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' }, meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use', menuActions: ['ai:use'] },
}, },
{ {
path: 'ai-settings', path: 'ai-settings',
@@ -136,25 +171,35 @@ const routes: RouteRecordRaw[] = [
name: 'database', name: 'database',
component: () => import('@/views/DatabaseView.vue'), component: () => import('@/views/DatabaseView.vue'),
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员 // 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] }, meta: { title: '数据库管理', icon: 'Coin', permission: 'database:manage' },
}, },
{ {
path: 'permissions', path: 'permissions',
name: 'permissions', name: 'permissions',
component: () => import('@/views/PermissionsView.vue'), component: () => import('@/views/PermissionsView.vue'),
// 这一页**按生效权限**门控而不是按角色:教师被某个权限组授予 permission:manage 后同样可用, // 管理员专用能力不可下放,菜单与路由仍统一按服务端能力集合过滤。
// 与后端 requirePermission('permission:manage') 对齐(详见下方守卫) meta: { title: '权限与范围', icon: 'Lock', permission: 'permission:manage' },
meta: { title: '权限组管理', icon: 'Lock', permission: 'permission:manage' },
}, },
{ {
path: 'clients', path: 'clients',
name: 'clients', name: 'clients',
component: () => import('@/views/ClientsView.vue'), component: () => import('@/views/ClientsView.vue'),
meta: { title: '客户端', icon: 'Monitor', permission: 'client:read' }, meta: {
title: '客户端',
icon: 'Monitor',
permission: 'client:read',
menuActions: ['client:manage', 'client:update-push', 'remote:control', 'client:update'],
},
}, },
{ path: 'client-devices', redirect: '/clients' }, { path: 'client-devices', redirect: '/clients' },
], ],
}, },
{
path: '/no-access',
name: 'no-access',
component: () => import('@/views/NoAccessView.vue'),
meta: { title: '暂无功能授权' },
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'not-found', name: 'not-found',
@@ -168,18 +213,6 @@ export const router = createRouter({
routes, routes,
}); });
/** 读取已保存的登录用户角色(路由守卫用;未登录/解析失败返回 null) */
function storedRole(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
const parsed = JSON.parse(raw) as { role?: string };
return parsed?.role ?? null;
} catch {
return null;
}
}
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const token = localStorage.getItem(STORAGE_KEYS.token); const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) { if (to.meta.public) {
@@ -189,23 +222,22 @@ router.beforeEach(async (to) => {
if (!token) { if (!token) {
return { name: 'login', query: { redirect: to.fullPath } }; return { name: 'login', query: { redirect: to.fullPath } };
} }
// 仅管理员可见的页面(班级/学生/教师管理):直接输网址也要挡回去,
// 不能依赖"菜单隐藏"——菜单隐藏只是看不见,不是权限。
const roles = to.meta.roles;
if (Array.isArray(roles) && roles.length > 0) {
const role = storedRole();
if (!role || !(roles as string[]).includes(role)) {
return { name: 'dashboard' };
}
}
// 按**生效权限**门控的页面(权限组管理):角色判不出来 —— 教师被权限组授予
// permission:manage 后同样该放行,所以这里异步取一次生效权限。
// `ensurePermissions()` 在 store 内是幂等的(本次会话只真拉一次),不会每次跳转都打接口。
const permission = to.meta.permission; const permission = to.meta.permission;
if (typeof permission === 'string') { if (typeof permission === 'string') {
const auth = useAuthStore(); const auth = useAuthStore();
await auth.ensurePermissions(); await auth.ensurePermissions();
if (!auth.can(permission as PermissionKey)) return { name: 'dashboard' }; if (!auth.can(permission as PermissionKey)) {
if (auth.can('dashboard:read')) return { name: 'dashboard' };
const fallback = router
.getRoutes()
.find(
(r) =>
r.name &&
typeof r.meta.permission === 'string' &&
auth.canShowMenu(r.meta.permission, r.meta.menuActions),
);
return fallback ? { name: fallback.name! } : { name: 'no-access' };
}
} }
return true; return true;
}); });
+62 -2
View File
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
ROLE_LABELS, ROLE_LABELS,
PERMISSION_META,
STORAGE_KEYS, STORAGE_KEYS,
hasPermission, hasPermission,
type LoginRequest, type LoginRequest,
@@ -10,6 +11,7 @@ import {
type SessionUser, type SessionUser,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { authApi, permissionApi } from '@/api'; import { authApi, permissionApi } from '@/api';
import type { MenuAction } from '@/config/navigation';
/** /**
* 会话主体。 * 会话主体。
@@ -44,6 +46,54 @@ export const useAuthStore = defineStore('auth', () => {
/** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */ /** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */
const permissionsFromGroups = ref(false); const permissionsFromGroups = ref(false);
const permissionsLoaded = ref(false); const permissionsLoaded = ref(false);
const classScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const manageScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const actionScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const allScopeKeys = ref<PermissionKey[]>([]);
function canForClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId ? (classScopes.value[key] ?? []).includes(classId) : allScopeKeys.value.includes(key))
);
}
function canManageClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId
? (manageScopes.value[key] ?? classScopes.value[key] ?? []).includes(classId)
: allScopeKeys.value.includes(key))
);
}
function canShowMenu(permission: PermissionKey | undefined, actions?: readonly MenuAction[]): boolean {
if (permission && !can(permission)) return false;
if (!actions) return true;
return actions.some((action) => {
const key = typeof action === 'string' ? action : action.key;
if (!can(key)) return false;
if (
(typeof action !== 'string' && action.scope === 'permission') ||
PERMISSION_META[key].kind !== 'class'
)
return true;
const ids =
typeof action !== 'string' && action.scope === 'manage'
? (manageScopes.value[key] ?? [])
: (actionScopes.value[key] ?? []);
// 全校名单维护还允许新建未分班学生。
if (
key === 'roster:manage' &&
allScopeKeys.value.includes(key) &&
(!permission || allScopeKeys.value.includes(permission))
)
return true;
return ids.some(
(id) =>
!permission ||
PERMISSION_META[permission].kind !== 'class' ||
(classScopes.value[permission] ?? []).includes(id),
);
});
}
const isAuthenticated = computed(() => Boolean(token.value)); const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null); const role = computed(() => user.value?.role ?? null);
@@ -54,10 +104,9 @@ export const useAuthStore = defineStore('auth', () => {
/** /**
* 是否有某个生效权限。 * 是否有某个生效权限。
* *
* 管理员同样遵守生效集合,只保留与服务端一致的两个管理入口,避免锁死。 * 页面直接使用服务端生效集合;管理员全集也由服务端返回。
*/ */
function can(key: PermissionKey): boolean { function can(key: PermissionKey): boolean {
if (isAdmin.value && (key === 'permission:manage' || key === 'system:manage')) return true;
return hasPermission(permissions.value, key); return hasPermission(permissions.value, key);
} }
@@ -73,6 +122,10 @@ export const useAuthStore = defineStore('auth', () => {
try { try {
const mine = await permissionApi.me(); const mine = await permissionApi.me();
permissions.value = mine.permissions; permissions.value = mine.permissions;
classScopes.value = mine.classScopes ?? {};
manageScopes.value = mine.manageScopes ?? {};
actionScopes.value = mine.actionScopes ?? {};
allScopeKeys.value = mine.allScopeKeys ?? [];
permissionsFromGroups.value = mine.fromGroups; permissionsFromGroups.value = mine.fromGroups;
permissionsLoaded.value = true; permissionsLoaded.value = true;
} catch { } catch {
@@ -83,6 +136,10 @@ export const useAuthStore = defineStore('auth', () => {
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */ /** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
function resetPermissions(): void { function resetPermissions(): void {
permissions.value = []; permissions.value = [];
classScopes.value = {};
manageScopes.value = {};
actionScopes.value = {};
allScopeKeys.value = [];
permissionsFromGroups.value = false; permissionsFromGroups.value = false;
permissionsLoaded.value = false; permissionsLoaded.value = false;
} }
@@ -146,6 +203,9 @@ export const useAuthStore = defineStore('auth', () => {
displayName, displayName,
roleLabel, roleLabel,
can, can,
canForClass,
canManageClass,
canShowMenu,
ensurePermissions, ensurePermissions,
login, login,
logout, logout,
+14 -1
View File
@@ -12,6 +12,7 @@ import {
type ServerToClientEvents, type ServerToClientEvents,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { SOCKET_URL } from '@/config'; import { SOCKET_URL } from '@/config';
import { useAuthStore } from '@/stores/auth';
type RealtimeEventName = keyof ServerToClientEvents; type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
@@ -55,8 +56,20 @@ export const useRealtimeStore = defineStore('realtime', () => {
connected.value = true; connected.value = true;
connecting.value = false; connecting.value = false;
}); });
instance.on('disconnect', () => { instance.on('disconnect', (reason: string) => {
connected.value = false; connected.value = false;
if (reason === 'io server disconnect') {
void (async () => {
const auth = useAuthStore();
try {
await auth.fetchProfile();
await auth.ensurePermissions(true);
if (socket === instance && auth.token) instance.connect();
} catch {
/* 身份失效时等待重新登录。 */
}
})();
}
}); });
instance.on('connect_error', (error: Error) => { instance.on('connect_error', (error: Error) => {
connected.value = false; connected.value = false;
+11
View File
@@ -0,0 +1,11 @@
import 'vue-router';
import type { PermissionKey } from '@classhelper/shared';
import type { MenuAction } from '@/config/navigation';
declare module 'vue-router' {
interface RouteMeta {
permission?: PermissionKey;
/** 维护页面任一动作在至少一个可读班级生效时显示;纯查看页面省略此项。 */
menuActions?: readonly MenuAction[];
}
}
+61 -17
View File
@@ -32,7 +32,10 @@ const realtime = useRealtimeStore();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */ /** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const canManageClass = computed(() => auth.can('class:manage')); const canManageClass = computed(() => auth.isAdmin && auth.can('class:manage'));
function canHeadAction(row: ClassDto, key: 'class:account' | 'class:settings') {
return auth.canForClass(key, row.id) && (auth.isAdmin || row.teacher?.id === auth.user?.id);
}
/** 小屏下详情描述改为单列,避免文字被压成竖排 */ /** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3)); const detailColumns = computed(() => (isMobile.value ? 1 : 3));
@@ -131,7 +134,6 @@ async function submitForm(): Promise<void> {
await classApi.update(editingId.value, { await classApi.update(editingId.value, {
enrollmentYear: form.enrollmentYear, enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex, classIndex: form.classIndex,
termWeeks: form.termWeeks,
}); });
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩) // 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (canManageClass.value && form.headTeacherId) { if (canManageClass.value && form.headTeacherId) {
@@ -246,6 +248,9 @@ async function removeClass(row: ClassDto): Promise<void> {
const detailVisible = ref(false); const detailVisible = ref(false);
const detailLoading = ref(false); const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null); const detail = ref<ClassDetailDto | null>(null);
const canManageDetailRoster = computed(() => auth.canManageClass('roster:manage', detail.value?.id));
const canManageDetailCourses = computed(() => auth.canManageClass('schedule:write', detail.value?.id));
const canManageDetailGradeQuery = computed(() => auth.canManageClass('grade:write', detail.value?.id));
const activeTab = ref('students'); const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> { async function openDetail(row: ClassDto): Promise<void> {
@@ -285,7 +290,7 @@ const studentRules: FormRules = {
}; };
async function addStudent(): Promise<void> { async function addStudent(): Promise<void> {
if (!detail.value) return; if (!detail.value || !canManageDetailRoster.value) return;
if (!studentForm.studentNo.trim() || !studentForm.name.trim()) { if (!studentForm.studentNo.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写学号和姓名'); ElMessage.warning('请填写学号和姓名');
return; return;
@@ -307,7 +312,7 @@ async function addStudent(): Promise<void> {
} }
async function removeStudent(row: StudentDto): Promise<void> { async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return; if (!detail.value || !canManageDetailRoster.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' }); await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id); await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级'); ElMessage.success('已移出班级');
@@ -320,6 +325,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
const courseForm = reactive({ name: '' }); const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> { async function addCourse(): Promise<void> {
if (!canManageDetailCourses.value) return;
if (!detail.value || !courseForm.name.trim()) { if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称'); ElMessage.warning('请输入课程名称');
return; return;
@@ -331,6 +337,7 @@ async function addCourse(): Promise<void> {
} }
async function removeCourse(row: CourseDto): Promise<void> { async function removeCourse(row: CourseDto): Promise<void> {
if (!auth.canManageClass('schedule:write', row.classId)) return;
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', { await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning', type: 'warning',
}); });
@@ -409,7 +416,7 @@ const gradeQuerySaving = ref(false);
/** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */ /** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */
async function toggleGradeQuery(enabled: boolean): Promise<void> { async function toggleGradeQuery(enabled: boolean): Promise<void> {
if (!detail.value) return; if (!detail.value || !canManageDetailGradeQuery.value) return;
gradeQuerySaving.value = true; gradeQuerySaving.value = true;
try { try {
await classApi.setGradeQuery(detail.value.id, enabled); await classApi.setGradeQuery(detail.value.id, enabled);
@@ -470,6 +477,24 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged); realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged); realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
}); });
const settings = reactive({ visible: false, id: '', termWeeks: 20, termStartDate: '' });
function openSettings(row: ClassDto) {
Object.assign(settings, {
visible: true,
id: row.id,
termWeeks: row.termWeeks,
termStartDate: row.termStartDate ?? '',
});
}
async function saveSettings() {
await classApi.updateSettings(settings.id, {
termWeeks: settings.termWeeks,
termStartDate: settings.termStartDate,
});
settings.visible = false;
await loadClasses();
ElMessage.success('学期设置已保存');
}
</script> </script>
<template> <template>
@@ -546,9 +571,17 @@ onUnmounted(() => {
<el-table-column label="操作" width="310" fixed="right"> <el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button> <el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)"> <el-button
v-if="canHeadAction(row, 'class:account')"
link
type="warning"
@click="openAccount(row)"
>
班级账号 班级账号
</el-button> </el-button>
<el-button v-if="canHeadAction(row, 'class:settings')" link @click="openSettings(row)">
学期设置
</el-button>
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button> <el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button> <el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
</template> </template>
@@ -556,6 +589,20 @@ onUnmounted(() => {
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="settings.visible" title="本班学期设置" width="440px">
<el-form label-width="100px">
<el-form-item label="教学周数">
<el-input-number v-model="settings.termWeeks" :min="1" :max="maxTermWeeks" />
</el-form-item>
<el-form-item label="开学日期">
<el-date-picker v-model="settings.termStartDate" type="date" value-format="YYYY-MM-DD" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settings.visible = false">取消</el-button>
<el-button type="primary" @click="saveSettings">保存设置</el-button>
</template>
</el-dialog>
<!-- 班级账号:班级码 + 班级密码(教室机器上 ClassHelper 班级端的登录凭据) --> <!-- 班级账号:班级码 + 班级密码(教室机器上 ClassHelper 班级端的登录凭据) -->
<el-dialog v-model="accountVisible" title="班级账号(ClassHelper 班级端登录)" width="480px"> <el-dialog v-model="accountVisible" title="班级账号(ClassHelper 班级端登录)" width="480px">
<el-alert type="info" :closable="false" show-icon class="mb-12"> <el-alert type="info" :closable="false" show-icon class="mb-12">
@@ -625,7 +672,7 @@ onUnmounted(() => {
班主任可以管理本班课表与成绩;留空则默认由创建者本人担任 班主任可以管理本班课表与成绩;留空则默认由创建者本人担任
</span> </span>
</el-form-item> </el-form-item>
<el-form-item label="学期周数"> <el-form-item v-if="!editingId" label="学期周数">
<el-input-number v-model="form.termWeeks" :min="1" :max="maxTermWeeks" :step="1" /> <el-input-number v-model="form.termWeeks" :min="1" :max="maxTermWeeks" :step="1" />
<span class="text-muted ml-8"> <span class="text-muted ml-8">
本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来 本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来
@@ -680,7 +727,7 @@ onUnmounted(() => {
<el-tab-pane label="学生名单" name="students"> <el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12"> <div class="toolbar mt-12">
<el-button <el-button
v-if="auth.can('roster:manage')" v-if="canManageDetailRoster"
type="primary" type="primary"
size="small" size="small"
:icon="'Plus'" :icon="'Plus'"
@@ -703,11 +750,9 @@ onUnmounted(() => {
<el-table-column label="加入时间" width="180"> <el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template> <template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column v-if="canManageDetailRoster" label="操作" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)"> <el-button link type="danger" @click="removeStudent(row)">移出</el-button>
移出
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -719,15 +764,14 @@ onUnmounted(() => {
<el-table-column label="任课教师" width="140"> <el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template> <template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90"> <el-table-column v-if="canManageDetailCourses" label="操作" width="90">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)"> <el-button link type="danger" @click="removeCourse(row)">删除</el-button>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-input <el-input
v-if="canManageDetailCourses"
v-model="courseForm.name" v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加" placeholder="输入课程名称后回车快速添加"
class="mt-12" class="mt-12"
@@ -817,7 +861,7 @@ onUnmounted(() => {
<el-switch <el-switch
:model-value="detail?.studentGradeQueryEnabled ?? true" :model-value="detail?.studentGradeQueryEnabled ?? true"
:loading="gradeQuerySaving" :loading="gradeQuerySaving"
:disabled="!auth.can('grade:write')" :disabled="!canManageDetailGradeQuery"
@change="(value: boolean) => toggleGradeQuery(value)" @change="(value: boolean) => toggleGradeQuery(value)"
/> />
</div> </div>
+3 -3
View File
@@ -672,15 +672,15 @@ const deviceEmptyText = computed(() =>
<div class="client-actions"> <div class="client-actions">
<el-tooltip <el-tooltip
:disabled="can('client:update')" :disabled="can('client:update-push')"
content="当前账号没有「向客户端统一下发更新」权限(client:update)" content="当前账号没有「向客户端统一下发更新」权限(client:update-push)"
placement="top" placement="top"
> >
<span> <span>
<el-button <el-button
type="primary" type="primary"
:icon="'Upload'" :icon="'Upload'"
:disabled="!can('client:update')" :disabled="!can('client:update-push')"
@click="openUpdateDialog" @click="openUpdateDialog"
> >
一键推送更新 一键推送更新
+30 -12
View File
@@ -37,8 +37,20 @@ const quickEntries = computed(() =>
icon: 'Calendar', icon: 'Calendar',
enabled: auth.can('schedule:read'), enabled: auth.can('schedule:read'),
}, },
{ path: '/homeworks', title: '发布作业', hint: '布置并追踪完成情况', icon: 'Notebook', enabled: true }, {
{ path: '/notifications', title: '通知与叫人', hint: '实时送达教室', icon: 'Bell', enabled: true }, path: '/homeworks',
title: '发布作业',
hint: '布置并追踪完成情况',
icon: 'Notebook',
enabled: auth.can('homework:read'),
},
{
path: '/notifications',
title: '通知与叫人',
hint: '实时送达教室',
icon: 'Bell',
enabled: auth.can('notification:read'),
},
{ {
path: '/clients', path: '/clients',
title: '客户端', title: '客户端',
@@ -52,18 +64,24 @@ const quickEntries = computed(() =>
const statCards = computed(() => { const statCards = computed(() => {
const data = summary.value; const data = summary.value;
return [ return [
{ label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' }, { section: 'classes', label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' }, { section: 'students', label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' }, { section: 'courses', label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' }, { section: 'schedules', label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{ {
section: 'homeworks',
label: '作业总数', label: '作业总数',
value: data?.homeworkCount ?? 0, value: data?.homeworkCount ?? 0,
hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`, hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`,
}, },
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` }, {
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' }, section: 'notifications',
]; label: '通知总数',
value: data?.notificationCount ?? 0,
hint: `未读 ${unreadCount.value}`,
},
{ section: 'grades', label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
].filter((card) => data?.sections[card.section as keyof DashboardSummary['sections']]);
}); });
/** /**
@@ -76,7 +94,7 @@ async function load(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
summary.value = await dashboardApi.summary(); summary.value = await dashboardApi.summary();
const result = await notificationApi.unreadCount(); const result = auth.can('notification:read') ? await notificationApi.unreadCount() : { count: 0 };
unreadCount.value = result.count; unreadCount.value = result.count;
} finally { } finally {
loading.value = false; loading.value = false;
@@ -158,7 +176,7 @@ function openNotification(item: NotificationDto): void {
<el-row :gutter="12" class="mt-16"> <el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="24"> <el-col :xs="24" :md="24">
<el-card shadow="never"> <el-card v-if="summary?.sections.notifications" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>最新通知</span> <span>最新通知</span>
@@ -184,7 +202,7 @@ function openNotification(item: NotificationDto): void {
</el-col> </el-col>
</el-row> </el-row>
<el-card shadow="never" class="mt-16 table-card"> <el-card v-if="summary?.sections.homeworks" shadow="never" class="mt-16 table-card">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>最近作业</span> <span>最近作业</span>
+18 -9
View File
@@ -35,7 +35,7 @@ const auth = useAuthStore();
* (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮, * (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮,
* 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。 * 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。
*/ */
const canManageGrades = computed(() => auth.role === 'ADMIN' || auth.role === 'TEACHER'); const canManageGrades = computed(() => auth.can('grade:write'));
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const loading = ref(false); const loading = ref(false);
@@ -221,10 +221,7 @@ const ownCourses = computed(() =>
); );
/** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */ /** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */
const canCreateCourse = computed(() => { const canCreateCourse = computed(() => auth.canManageClass('schedule:write', filter.classId));
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === filter.classId)?.teacherId === auth.user?.id;
});
/** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */ /** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */
function defaultFormCourseId(): string { function defaultFormCourseId(): string {
@@ -489,6 +486,12 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent); realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
}); });
function canEditContent(row: GradeDto): boolean {
if (!auth.canForClass('grade:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script> </script>
<template> <template>
@@ -519,7 +522,7 @@ onUnmounted(() => {
<el-button v-if="canManageGrades" type="success" :icon="'Upload'" @click="openBulk"> <el-button v-if="canManageGrades" type="success" :icon="'Upload'" @click="openBulk">
批量录入 批量录入
</el-button> </el-button>
<el-button v-if="canManageGrades" type="warning" :icon="'Document'" @click="openImport"> <el-button v-if="auth.can('grade:import')" type="warning" :icon="'Document'" @click="openImport">
导入表格 导入表格
</el-button> </el-button>
<el-button v-if="canManageGrades" :icon="'Sort'" @click="openLevelBatch">批量改等级</el-button> <el-button v-if="canManageGrades" :icon="'Sort'" @click="openLevelBatch">批量改等级</el-button>
@@ -537,7 +540,11 @@ onUnmounted(() => {
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记, <!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
客户端据此显示总分榜)。导入成绩时顺手就能定下来。 --> 客户端据此显示总分榜)。导入成绩时顺手就能定下来。 -->
<el-card v-if="canManageGrades && filter.classId && filter.examName" shadow="never" class="mt-12"> <el-card
v-if="auth.canManageClass('grade:write', filter.classId) && filter.examName"
shadow="never"
class="mt-12"
>
<div class="toolbar"> <div class="toolbar">
<span class="exam-label">考试口径</span> <span class="exam-label">考试口径</span>
<el-tag effect="plain">{{ filter.examName }}</el-tag> <el-tag effect="plain">{{ filter.examName }}</el-tag>
@@ -615,7 +622,7 @@ onUnmounted(() => {
</el-tag> </el-tag>
<!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 --> <!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 -->
<el-select <el-select
v-if="canManageGrades" v-if="canEditContent(row)"
:model-value="row.level" :model-value="row.level"
size="small" size="small"
style="width: 88px" style="width: 88px"
@@ -639,7 +646,9 @@ onUnmounted(() => {
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90" fixed="right"> <el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canManageGrades" link type="danger" @click="removeGrade(row)">删除</el-button> <el-button v-if="canEditContent(row)" link type="danger" @click="removeGrade(row)">
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
+17 -9
View File
@@ -78,6 +78,7 @@ const notSubmittedNames = computed(() =>
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */ /** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<boolean> { async function loadSubmissions(homeworkId: string): Promise<boolean> {
if (!current.value || !canEditContent(current.value)) return false;
const requestId = ++submissionsRequestId; const requestId = ++submissionsRequestId;
submissionsLoading.value = true; submissionsLoading.value = true;
try { try {
@@ -237,10 +238,7 @@ const ownFormCourses = computed(() =>
); );
/** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */ /** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */
const canCreateCourse = computed(() => { const canCreateCourse = computed(() => auth.canManageClass('schedule:write', form.classId));
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === form.classId)?.teacherId === auth.user?.id;
});
async function loadFormCourses(): Promise<void> { async function loadFormCourses(): Promise<void> {
const classId = form.classId; const classId = form.classId;
@@ -500,6 +498,12 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent); realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent); realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
}); });
function canEditContent(row: HomeworkDto): boolean {
if (!auth.canForClass('homework:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script> </script>
<template> <template>
@@ -545,8 +549,10 @@ onUnmounted(() => {
@keyup.enter="loadHomeworks" @keyup.enter="loadHomeworks"
@clear="loadHomeworks" @clear="loadHomeworks"
/> />
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button> <el-button v-if="auth.can('homework:write')" type="primary" :icon="'Plus'" @click="openCreate">
<el-button @click="openBulk">批量布置</el-button> 发布作业
</el-button>
<el-button v-if="auth.can('homework:write')" @click="openBulk">批量布置</el-button>
</div> </div>
</div> </div>
@@ -565,8 +571,10 @@ onUnmounted(() => {
<el-table-column label="操作" width="210" fixed="right"> <el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button> <el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button> <el-button v-if="canEditContent(row)" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeHomework(row)">删除</el-button> <el-button v-if="canEditContent(row)" link type="danger" @click="removeHomework(row)">
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -715,7 +723,7 @@ onUnmounted(() => {
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%) 已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" /> <el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="未交名单" :span="2"> <el-descriptions-item v-if="current && canEditContent(current)" label="未交名单" :span="2">
<div v-loading="submissionsLoading" class="submission-summary"> <div v-loading="submissionsLoading" class="submission-summary">
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template> <template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
<template v-else-if="notSubmittedNames.length === 0"> <template v-else-if="notSubmittedNames.length === 0">
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
async function logout() {
await auth.logout();
await router.replace('/login');
}
</script>
<template>
<el-result icon="info" title="暂无可访问的功能" sub-title="请联系管理员检查模板与分配范围。">
<template #extra><el-button @click="logout">退出登录</el-button></template>
</el-result>
</template>
File diff suppressed because it is too large. Load diff
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more