diff --git a/AGENTS.md b/AGENTS.md index b383a9f..6d72f4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,9 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界 - 模块由 `packages/server/src/modules/registry.ts` 注册;新增或下线功能改注册表,不另改 `app.ts` 挂载。 - 路由用 `middleware/validate.ts` 与 zod;响应复用 `lib/http.ts`,错误交给统一中间件。 - 学生是 `Student` 名单记录,没有账号、角色或密码。教师/管理员存 `User`;班级登录读取 `Class.code/passwordHash`,会话角色 `CLASS_DEVICE`,主体 id 为班级 id。 -- 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `assertCanManageSubjectContent`。班级端可录作业,不可写成绩;员工还须对应功能权限。 -- 功能权限取 `lib/permissions.ts` 的 `loadEffectivePermissions()`:已分组取并集;未分组使用数据库对应角色的可编辑默认组;管理员永远保留 `permission:manage/system:manage`;班级端为空集。 -- 岗位组不新增 `User.role`。班主任/教师/级长/部长仍为 TEACHER;范围在 `PermissionGroup.scope/scopeValue`,由 `lib/permission-scope.ts` 收口,空年级/部范围拒绝。设备写权限只合并含对应键的分组范围,范围不替代科目授权。 +- 任课关系只在 `Course(classId, name, teacherId)`。班主任不自动获得别的科目的作业、成绩写权限,统一走 `lib/authz/assert.ts` 的 `assertContentWrite`。班级端可录作业,不可写成绩;员工还须对应功能权限。 +- 功能、范围、关系统一由 `lib/authz/` 判定,所有服务显式传权限键。ADMIN 固定全部能力;TEACHER 永久忽略管理员专用键;CLASS_DEVICE 为空集,接口明确允许并校验本班。权限管理仅 ADMIN。 +- 岗位模板只管能力与建议范围,生效范围存 `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` 与旧库兼容逻辑。 - `deleteTeacher` 必须保护班主任、课程任课、发布的作业/通知和所属课件,避免级联删除业务内容。 - 作业和通知按本地 `assignDate` 归类,使用 `dayKeyLocal/isDayKey/shiftDayKey`,不用 UTC 日期切片。 @@ -44,7 +44,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界 - `/api/schedules/*` 是新模型兼容门面。启用解析统一走 `lib/class-plan.ts`,在线客户端不得另写启用优先级。 - 时间点有上课、课间、分割线、行动四种;课程显式引用时间点外键。排序不能靠数组下标代替关联。 - 支持 1–9 周轮换、群组、预定及临时层;完整档案导入走 `profile-import.service.ts`,不经过单双周有损格式。 -- 全校时间表写入用 `timetable:manage`;班级日课表写入还须班主任/管理员断言。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。 +- 全校时间表写入仅管理员;班级时间表按存储 classId 校验 `schedule:write` 与班主任关系,引用班级也校验。日课表/名单在 TEACHING/HEAD 范围要求班主任,显式年级/部/全校分配可在范围内维护。离线整套课表导入可建立班级自有作息,不把 JSON 的 TeacherName 当授权。 - 周次统一 `loadTermContext/resolveCurrentWeek`:班级逐周区间优先于全校区间,缺省再线性推算。开学日会参考班级设置、第 1 周区间、全校设置与环境兜底,详见 `lib/term.ts`。 - 全校周数上限存 `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`。 - 保留 CSP、导航同源校验与弹窗限制。安装包不能含凭据,保留 `check-bundle-secrets` 构建门禁。 - 离线缓存按服务端与班级隔离,缓存故障不应阻断在线读取。退出、切服务器时清理会话状态,设备 `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,客户端立即执行、持久去重并回执。冒烟始终假执行,不得真关机/重启/注销/锁屏。 -- 更新写入口用 `client:update`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。 +- 安装包登记/删除为管理员 `client:update`;班级推送用 `client:update-push`;服务端缓存包并登记 SHA256,教室机从本服务下载;无哈希拒收,按立即/放学后/空闲策略执行,关闭自动更新不得强装。冒烟不运行安装器。 - 灵动岛方案保存在本机 `config.json.islandLayout`,默认 `layout=fixed`;`custom` 当前即常驻模式,旧 `residentEnabled` 是兼容字段。新尺寸上报仅在 custom 模式执行。 - `LayoutIslandApp.vue` 选择经典 `IslandApp.vue` 或 `CustomIslandRuntime.vue`,共用一个原生岛窗口;课件传输状态优先显示 `CoursewareTransfer.vue`。 - 经典模式:普通通知/作业上课暂存;普通叫人保留可点击胶囊;紧急消息立即展开。自定义常驻模式:消息先提示铃铛数量,用户主动展开;上课也可主动查看。不要把两个模式规则混写。 @@ -71,7 +71,7 @@ pnpm monorepo,根 `package.json` 是版本单一来源。文档、注释、界 ## 5. 实时、界面与资源 -- 事件名与房间名取 shared 常量。业务用 `emitToClass/emitToSession`,不直接依赖 Socket.IO 实例。 +- 事件名与房间名取 shared 常量。班级房间为 `class:{id}:{channel}`,教师按每域读键及范围订阅,班级端仅本班全部通道;握手回查数据库身份。业务用 `emitToClass/emitToSession`,由总线选择事件域。 - 会话房间为 `session:{id}`;教师/管理员 id 为账号 id,班级端为班级 id。`students` 是班级端广播房间,不是个人学生账号房间。 - 新实时事件同步服务端、Web 订阅、客户端订阅。成绩实时载荷不可泄漏个人成绩。 - Web 菜单分组在 `config/navigation.ts`,标题/图标/权限取路由;守卫与服务端都检查权限,前端隐藏不能代替鉴权。 diff --git a/docs/app/classes.md b/docs/app/classes.md index c001b49..951539e 100644 --- a/docs/app/classes.md +++ b/docs/app/classes.md @@ -17,6 +17,8 @@ description: 名单、班级会话、任课关系与年度升级。 ## 学生名单 +Web 统一从“学生管理”查看和维护名单;旧 `/roster` 地址转到同一页面,不再提供重复菜单。班级详情中的名单与课程按当前班级的生效范围展示维护入口,任课关系本身不授予跨班名单、课程或班级设置管理权。 + Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。 学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。 具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。 @@ -35,8 +37,9 @@ Student 存学号、姓名、班级、性别、家长电话和状态,没有密 ## 权限、学年与归档 -角色给默认权限模板,实际入口由可编辑权限组决定;岗位组还能配置年级或部范围。 +管理员固定拥有全部能力;教师按岗位模板及每项分配的范围获得能力,未显式分配时使用教师默认模板与固定任教范围。 范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。 +“学期周次”只维护教学周与日期区间;年度轨道、执行计划与补跑统一在管理员专用的“学年升级”页维护。 学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。 学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。 默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录, diff --git a/docs/dev/api.md b/docs/dev/api.md index 5408042..651238a 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -12,18 +12,18 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib ## 身份与班级 -| 方法与路径(省略 /api) | 行为及边界 | -| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 | -| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 | -| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 与目标范围,创建/删除需全校范围 | -| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 | -| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 | -| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 | -| GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 | -| /students、/teachers | 名单与账号 CRUD;读/写使用不同权限键,具体角色限制读取对应模块 | -| POST /teachers/:id/reset-password | 指定新密码或默认重置;学生无对应接口 | -| /permissions/me、/catalog、/groups、/users | 当前有效权限、目录、组与账号分组 | +| 方法与路径(省略 /api) | 行为及边界 | +| ----------------------------------------------------------------------- | -------------------------------------------------------------- | +| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 | +| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 | +| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 且仅管理员 | +| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据 class:account + 班主任;班主任分配仅管理员 | +| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 | +| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 | +| GET/POST /classes/:id/students;DELETE /classes/:id/students/:studentId | 本班名单及维护,受名单功能权限和班级关系约束 | +| /students、/teachers | 名单与账号 CRUD;读/写使用不同权限键,具体角色限制读取对应模块 | +| POST /teachers/:id/reset-password | 指定新密码或默认重置;学生无对应接口 | +| /permissions/me、/catalog、/groups、/users | 当前有效权限、目录、组与账号分组 | ## 教学与文件 @@ -58,11 +58,11 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib | POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 | | GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 | | 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/client/releases | 缓存版本读取,按路由允许的读取权限 | -| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update | -| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 | +| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口仅管理员 client:update | +| GET /update/client/download/:version | 本班 CLASS_DEVICE 或 client:read + 有效班级范围 | | /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write | | GET/PUT /term/promotion;POST /term/promotion/run | 升级计划与执行,promotion: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/groups/:id/members | permission:manage;包含显式分组及角色默认回落的生效成员 | | GET /permissions/audit?page=&pageSize= | permission:manage;分页操作人、目标、前后 JSON,pageSize 最大 100 | -| POST/PATCH/DELETE /permissions/groups[/:id] | permission:manage;范围校验与防提权;默认组仅 ADMIN 可改,内置组不可删 | -| PUT /permissions/users/:id/groups | groupIds 整体替换;默认组角色须匹配,空数组恢复角色默认模板;禁止越键越范围 | +| POST/PATCH/DELETE /permissions/groups[/:id] | 仅 ADMIN;仅可分配键,范围校验,教师默认模板固定 TEACHING,内置组不可删 | +| PUT /permissions/users/:id/groups | assignments 整体替换,每项 groupId/scope/scopeValue;兼容 groupIds 按建议范围分配;空数组恢复教师默认模板;ADMIN 不可分配 | | GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 | | POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 | | 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。 归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。 所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 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 对名单、日课表、导入和考试配置叠加班主任或显式组织范围规则。界面范围提示不替代服务端断言。 diff --git a/docs/dev/data-model.md b/docs/dev/data-model.md index 17ff418..673774a 100644 --- a/docs/dev/data-model.md +++ b/docs/dev/data-model.md @@ -27,9 +27,9 @@ description: 现行模型职责、迁移与快照边界。 | `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 | | `Grade` | studentId、课程、考试、分数和等级 | | `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 | -| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue | +| `PermissionGroup` | 岗位模板与建议 scope/scopeValue | | `PermissionGroupItem` | 组内权限键 | -| `UserPermissionGroup` | 账号与组的多对多分配 | +| `UserPermissionGroup` | 账号与模板的分配及实际 scope/scopeValue | | `AppSetting` | 周次、升级、AI 和系统设置 | | `ClientDevice` | deviceKey、心跳、更新目标与状态 | | `ClientCommand` | 白名单远控、有效期与执行回执 | @@ -85,3 +85,11 @@ User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好 SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind; 没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。 升级计划存 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。新快照按实际分配列恢复。真实库升级前先备份快照,隔离回归不替代真实库升级演练。 diff --git a/docs/dev/implementation.md b/docs/dev/implementation.md index 6ccf4a3..814b1cb 100644 --- a/docs/dev/implementation.md +++ b/docs/dev/implementation.md @@ -18,7 +18,7 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入 ## 权限、成绩与年度升级 -默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。 +能力、分配范围、业务关系统一由 lib/authz 的 principal/scope/relations/assert 维护;access 只提供带显式键的窄封装。ADMIN 固定全集,TEACHER 过滤管理员专用键。 全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。 个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。 年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。 @@ -44,9 +44,11 @@ JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见 学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。 资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。 权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。 -范围解析在 shared,默认组或分组均按当前功能键过滤,不能借另一功能的范围扩大当前操作。 +范围解析在 shared,实际范围存分配记录,模板范围仅作新分配建议。各键独立合并,无键为空集;默认教师范围固定 TEACHING。 权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。 年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算; 先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。 周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。 + +实时班级事件按 core/homework/notification/grade/courseware 分房间,事件总线按 shared 映射选择通道,教师仅订阅对应读键范围。HTTP 与 Socket 握手共用数据库身份加载;敏感权限与账号变更使旧订阅失效。事务路径传递同一数据库连接给授权断言,保留原/目标资源的校验。 diff --git a/docs/dev/permissions.md b/docs/dev/permissions.md index 35f98d3..8086ae4 100644 --- a/docs/dev/permissions.md +++ b/docs/dev/permissions.md @@ -1,89 +1,76 @@ --- title: 角色与权限 -description: 功能权限、岗位范围与班级科目边界。 +description: 能力目录、岗位模板、分配范围与业务关系的授权契约。 --- # 角色与权限 -授权分为功能权限、数据范围和业务关系三层,任何一层拒绝都不能操作。 -shared 的 `permissions.ts` 定义键、标签、默认模板与班级关系规则;服务端 `lib/permissions.ts`、 -`lib/permission-scope.ts`、`lib/access.ts` 分别计算生效键、查询范围和具体业务断言。 +授权依次检查能力、该能力的数据范围、数据行上的业务关系。服务端统一入口为 `lib/authz/`,路由预检不能替代服务断言。共享目录 `PERMISSION_META` 提供分类、风险、关系提示、合法范围、标签和默认角色;完整键目录读取源码,不在文档维护固定数量。 -## 身份与权限组 +## 身份与模板 -账号角色为 ADMIN、TEACHER;班级登录形成 CLASS_DEVICE 会话。学生是 Student 名单,不是账号。 -HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.role。 +| 主体 | 生效能力 | +| -------------------- | -------------------------------------------------- | +| ADMIN | 固定拥有全部目录键及全校范围,忽略权限组 | +| TEACHER | 显式分配模板的可分配能力并集;管理员专用键永久无效 | +| 未显式分配的 TEACHER | 数据库教师默认模板与固定 TEACHING 范围 | +| CLASS_DEVICE | 空集;接口明确允许班级端并校验本班关系 | -| 情况 | 有效功能权限 | -| ------------ | ----------------------------------------------------- | -| 已分配权限组 | 所有分组键的并集,可以比角色默认更少 | -| 没有分组 | 数据库对应角色的可编辑默认组 | -| ADMIN | 同上,始终额外保留 permission:manage 与 system:manage | -| CLASS_DEVICE | 空集;通过明确允许班级会话的接口与业务断言操作 | +学生是 Student 名单记录,没有账号。HEAD/SUBJECT 是班主任和科任关系,级长/部长是岗位模板,不增加 User.role。权限管理只允许 ADMIN;旧 ADMIN 默认模板保留存储但隐藏、忽略。 -每请求读取生效权限,编辑后无需等待令牌过期。内置组可改不可删,默认组不是写死的全权限快捷方式。 -路由使用 requirePermission(AND)、requireAnyPermission(OR),员工与班级端共享读取入口按 requireStaffPermission 等规则检查。 -具体 API 的额外角色限制以模块路由和服务断言为准,不能把“默认只分配给管理员”写成所有接口硬编码 ADMIN。 +`PermissionGroup` 保存能力和新分配的建议范围,`UserPermissionGroup` 保存实际 `scope/scopeValue`。模板改建议范围不会改变已有分配;模板改能力会影响其成员。同一模板可分配不同范围,多条分配按键取并集。空分配恢复默认模板,界面不会将未分组成员自动转换成显式默认组。 -## 数据范围与科目 +## 能力目录与范围 -| 岗位/身份 | 范围 | -| ----------- | --------------------------------------------- | -| 管理员 | 全部班级;仍须功能键 | -| 班主任/教师 | 自己担任班主任或任课的班级,按分组 scope 收敛 | -| 级长 | 配置年级 | -| 部长 | 配置的一组年级,scopeValue 用逗号分隔 | -| 班级端 | 登录绑定班级 | +能力分为管理员专用、班级级、组织只读。管理员专用能力包括组织与账号维护、权限/系统/数据库、全局周次、全校时间表、升级归档、安装包管理等;具体清单以 `PERMISSION_META.kind` 为准。`teacher:read` 和 `update:check` 为组织只读,忽略班级范围。其余班级级能力按分配范围求值。 -空年级/部配置不放行。范围按当前功能键独立合并,不能借其他只读组扩大名单、班级或设备写权限。 -科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。 -班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。 +| 范围 | 语义 | +| -------- | -------------------------- | +| TEACHING | 本人担任班主任或任课的班级 | +| HEAD | 仅本人担任班主任的班级 | +| GRADE | 指定一个年级 | +| DIVISION | 指定多个年级 | +| ALL | 全校 | -| 业务 | 主要功能键与额外边界 | -| ---------------- | ----------------------------------------------------------------------------------------------------- | -| 名单读取/管理 | 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 | +`parsePermissionScope` 统一校验范围。GRADE/DIVISION 必须选择已有年级或升级计划中的年级,范围值按逗号分隔、去重排序;其余范围值必须为空串。旧 SELF_CLASS 仅在迁移和旧快照恢复中映射为 TEACHING/HEAD,不是运行时枚举。 -完整键目录读取 `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 与班级归属。 -远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256, -按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。 +成绩列表、考试、排名和集体分析都要求 grade:read 与对应班级范围。学生明细另要求 student:grade-query。关闭班级自助查询后,本班设备仍可获得集体聚合,个人明细、原始成绩、考试列表和排名拒绝。实时载荷同样受隐私开关保护,服务端执行稀疏样本和补集抑制。 -## 范围配置与权限治理 +## 实时与设备 -范围由 shared 的 `parsePermissionScope` 统一解析。关联班级只接受空值(班主任与任课班)或 HEAD(仅班主任班); -GRADE 必选一个年级,DIVISION 必选多个年级组成的集合;全角逗号、顿号和空白统一分隔、去重排序。 -ALL 存空范围值。年级选项来自数据库已有年级和升级计划,空范围或未知年级返回 400;旧库未配置的岗位组不放行。 -`GET /permissions/scopes` 提供选择器选项,组 DTO 的 scopeLabel 供页面展示。 +班级房间为 `class:{id}:{channel}`,事件映射见 `SOCKET_EVENT_CHANNELS`。班级端加入本班全部通道;教师按各域读键及范围加入,core 由 class:read/schedule:read 决定。显式订阅再次校验通道,握手与 HTTP 共用数据库身份加载;权限/账号变更断开旧订阅。 -默认组只能分配给同角色;非 ADMIN 的权限管理员只能管理关联班级范围的自定义组, -组原有键和新键均不得超过自己当前权限。角色默认组只有 ADMIN 可改;删除组可能触发默认回落,仅 ADMIN 可执行。 -分配空数组恢复数据库角色默认组,分配任何组则仅取所选组并集;需要保留默认时显式同时选择同角色默认组。 +心跳、进度和命令回执只接受 CLASS_DEVICE,deviceKey 必须属于会话班级。远控只排队允许的系统指令,自动化始终假执行。安装包登记/删除使用管理员 client:update;班级推送使用 client:update-push。下载要求本班班级端或有范围的 client:read。版本与文件名在请求校验和路径构造两处拒绝不安全路径段。 -权限页保存前展示成员与权限差异,支持复制组、查看生效成员和分页审计。 -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 验证接管班级、管理员提权、跨键借范围、科目改挂、跨班时间表/周次/导入、成绩隐私、通道订阅、设备冒用和旧快照等攻击场景。其他专项回归保留生命周期、课表、课件、设备与升级覆盖。验证不触及真实班级、不访问外网、不执行系统指令。 diff --git a/docs/dev/realtime.md b/docs/dev/realtime.md index 7767093..3230bff 100644 --- a/docs/dev/realtime.md +++ b/docs/dev/realtime.md @@ -5,22 +5,21 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。 # 实时事件(Socket.IO) -连接使用 `io(url, { auth: { token } })`,服务端在握手校验 JWT。 +连接使用 `io(url, { auth: { token } })`,服务端握手校验 JWT 并与 HTTP 共用数据库身份加载。 事件与房间定义在 shared 的 `constants.ts`,载荷类型在 shared 的 `types.ts`,以这些契约和发送点为准。 ## 房间与定向推送 -| 房间 | 作用 | -| --------------- | -------------------------------------------------- | -| class:{classId} | 某班级的业务广播 | -| session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id | -| role:{role} | 角色房间名,定义在共享常量 | -| teacher:{id} | 教师房间名,定义在共享常量 | -| students | 班级端连接的广播房间,代表全班,不是个人学生账号 | -| teachers | 教师和管理员连接的广播房间 | +| 房间 | 作用 | +| ------------------------- | -------------------------------------------------- | +| class:{classId}:{channel} | 某班级对应读域的业务广播 | +| session:{id} | 会话定向名;员工 id 为账号 id,班级端 id 为班级 id | +| role:{role} | 角色房间名,定义在共享常量 | +| teacher:{id} | 教师房间名,定义在共享常量 | +| students | 班级端连接的广播房间,代表全班,不是个人学生账号 | +| teachers | 教师和管理员连接的广播房间 | -`realtime/socket.ts` 当前显式 join 可访问的 class 房间与 students/teachers 广播房间; -同时在 connected 载荷列出 session/role 名。不能把共享常量或该列表当成所有房间均已成功 join 的证明,实际订阅以 join 调用为准。 +`realtime/socket.ts` 按域加入授权班级房间,并加入 session/role 与 students/teachers 房间;connected 载荷列出实际已加入房间。 业务经 `realtime/bus.ts` 的 emitToClass/emitToSession/emitToRooms 推送。 叫人记录属于班级通知,并发送班级广播与班级会话定向事件;学生本身没有连接和个人房间。 @@ -47,3 +46,7 @@ description: 实际事件与房间、断线恢复和客户端提醒分工。 同步修改共享事件名与载荷、服务端发送点、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}`,后者逐通道校验,有任一无权则拒绝整次请求。握手回查账号与角色,权限变更断开旧订阅。 diff --git a/docs/screenshots/plan-editor-list.png b/docs/screenshots/plan-editor-list.png index 27cc515..a0d016c 100644 Binary files a/docs/screenshots/plan-editor-list.png and b/docs/screenshots/plan-editor-list.png differ diff --git a/docs/screenshots/plan-editor-live-api.png b/docs/screenshots/plan-editor-live-api.png index 2ed3b54..b23159d 100644 Binary files a/docs/screenshots/plan-editor-live-api.png and b/docs/screenshots/plan-editor-live-api.png differ diff --git a/docs/screenshots/plan-editor-shell.png b/docs/screenshots/plan-editor-shell.png index 0d83a7a..89e078f 100644 Binary files a/docs/screenshots/plan-editor-shell.png and b/docs/screenshots/plan-editor-shell.png differ diff --git a/docs/screenshots/plan-editor-timeline.png b/docs/screenshots/plan-editor-timeline.png index 43d745b..2ce4e31 100644 Binary files a/docs/screenshots/plan-editor-timeline.png and b/docs/screenshots/plan-editor-timeline.png differ diff --git a/package.json b/package.json index 95f170b..d669922 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build", "check:icons": "node scripts/check-icons.mjs", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", - "lint": "eslint .", + "lint": "node scripts/check-authz.mjs && eslint .", "lint:fix": "eslint . --fix", "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}\"", @@ -34,8 +34,8 @@ "verify:lifecycle": "node scripts/verify-lifecycle.mjs", "verify:client-agent": "node scripts/verify-client-agent.mjs", "verify:review-client": "node scripts/verify-review-client.mjs", - "verify:review-api": "node scripts/verify-review-api.mjs", - "verify:clients-api": "node scripts/verify-clients-api.mjs", + "verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api", + "verify:clients-api": "node scripts/verify-e2e-isolated.mjs --clients-api", "verify:client-migrations": "node scripts/verify-client-migrations.mjs", "verify:desktop": "pnpm --filter @classhelper/desktop-client smoke", "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:all": "pnpm dist:server && pnpm dist:win", "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": { "concurrently": "^10.0.5", diff --git a/packages/desktop-client/src/renderer/stores/realtime.ts b/packages/desktop-client/src/renderer/stores/realtime.ts index fb51a5e..937a183 100644 --- a/packages/desktop-client/src/renderer/stores/realtime.ts +++ b/packages/desktop-client/src/renderer/stores/realtime.ts @@ -60,9 +60,10 @@ export const useRealtimeStore = defineStore('realtime', () => { connecting.value = false; dispatch('__connected__', null); }); - instance.on('disconnect', () => { + instance.on('disconnect', (reason: string) => { connected.value = false; dispatch('__disconnected__', null); + if (reason === 'io server disconnect' && socket === instance) instance.connect(); }); instance.on('connect_error', (error: Error) => { connected.value = false; diff --git a/packages/server/prisma/migrations/20261031000000_authz_assignments/migration.sql b/packages/server/prisma/migrations/20261031000000_authz_assignments/migration.sql new file mode 100644 index 0000000..ef23830 --- /dev/null +++ b/packages/server/prisma/migrations/20261031000000_authz_assignments/migration.sql @@ -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"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 2a763b1..4fe0c16 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -451,7 +451,8 @@ model Grade { } model PermissionGroup { - scope String @default("SELF_CLASS") + /// 新分配的建议范围,不参与已有分配求值。 + scope String @default("TEACHING") scopeValue String @default("") /// 角色默认组;自定义组为空,改名不影响对应角色。 defaultRole String? @unique @@ -483,6 +484,9 @@ model PermissionGroupItem { /// 账号 ←→ 权限组 的分配关系(一个账号可属多个组) model UserPermissionGroup { + /// 此条分配的生效范围。 + scope String @default("TEACHING") + scopeValue String @default("") id String @id @default(cuid()) userId String groupId String @@ -491,7 +495,7 @@ model UserPermissionGroup { user User @relation(fields: [userId], references: [id], onDelete: Cascade) group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) - @@unique([userId, groupId]) + @@unique([userId, groupId, scope, scopeValue]) @@index([userId]) @@index([groupId]) } diff --git a/packages/server/scripts/verify-authz.ts b/packages/server/scripts/verify-authz.ts new file mode 100644 index 0000000..9e3775f --- /dev/null +++ b/packages/server/scripts/verify-authz.ts @@ -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 { + 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((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((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((resolve) => realtime.close(() => resolve())); + server.closeAllConnections(); + if (server.listening) await new Promise((resolve) => server.close(() => resolve())); + await prisma.$disconnect(); +} diff --git a/packages/server/scripts/verify-courseware.ts b/packages/server/scripts/verify-courseware.ts index 5c60a18..cf57a47 100644 --- a/packages/server/scripts/verify-courseware.ts +++ b/packages/server/scripts/verify-courseware.ts @@ -255,7 +255,9 @@ try { emitted .filter((e) => e.event === SOCKET_EVENTS.coursewareChanged) .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((resolve) => server.close(() => resolve())); @@ -357,24 +359,27 @@ try { state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } }); check('取消目标后忽略旧任务进度', state.updateProgress === ''); const teacherDeviceKey = 'courseware-teacher-device'; - await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }); - let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } }); - check('教师电脑设备不绑定班级', teacherDevice.classId === null); - await prisma.clientDevice.update({ - where: { id: teacherDevice.id }, - data: { updateTargetVersion: '2.0.0' }, + check( + '教师会话不能注册设备心跳', + (await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' })) + .status === 403, + ); + const teacherDevice = await prisma.clientDevice.create({ + data: { deviceKey: teacherDeviceKey, updateTargetVersion: '2.0.0' }, }); check( - '教师电脑可上报自己的更新进度', + '旧未绑定设备也不能由教师冒报进度', ( await request('/clients/update-progress', tokens[0], { deviceKey: teacherDeviceKey, 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'); check( '备份包含课件、可见范围、推送和回执', diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index f92faf7..d5a85ee 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1563,28 +1563,18 @@ async function main() { `含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`, ); - // 管理员可以分配权限组,但始终保留两个管理入口 const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, { method: 'PUT', token: adminToken, - body: { groupIds: permissionGroupId ? [permissionGroupId] : [] }, + body: { assignments: [] }, }); + const fixedAdmin = await api('/permissions/me', { token: adminToken }); record( - '管理员可以分配权限组(PUT /users/:id/groups → 200)', - Boolean(adminMember) && - assignAdmin.status === 200 && - 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 ?? ''}`, + '管理员固定全集并拒绝分配模板', + assignAdmin.status === 400 && fixedAdmin.payload?.data?.permissions.includes('teacher:manage'), + `status=${assignAdmin.status}`, ); - await api(`/permissions/users/${adminMember?.id}/groups`, { - method: 'PUT', - token: adminToken, - body: { groupIds: [] }, - }); - // 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败) const groupsList = await api('/permissions/groups', { token: adminToken }); const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null; diff --git a/packages/server/scripts/verify-lifecycle.ts b/packages/server/scripts/verify-lifecycle.ts index b421fcc..66d1676 100644 --- a/packages/server/scripts/verify-lifecycle.ts +++ b/packages/server/scripts/verify-lifecycle.ts @@ -6,7 +6,7 @@ import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@cl import { prisma } from '../src/lib/db.js'; import { createApp } from '../src/app.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 * as students from '../src/modules/students/students.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); const events: { classId: string; event: string }[] = []; 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()); await new Promise((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), ); const delegate = await account('临时教务授权', 'TEACHER'); - const governance = await permissions.createGroup(admin, { - name: '教务授权', - scope: 'ALL', - keys: ['class:read', 'class:manage', 'archive:manage', 'promotion:manage'], - }); - await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [governance.id] }); - const delegatedClass = await request( - '/classes', - delegate, - 'POST', - { grade: '高一', enrollmentYear: 2028, classIndex: 99 }, - 201, + await denied( + permissions.createGroup(admin, { + name: '不可下放组织能力', + keys: ['class:manage', 'archive:manage', 'promotion:manage'], + }), + 400, ); - await request(`/classes/${delegatedClass.id}`, delegate, 'PATCH', { termWeeks: 10 }); - await request(`/classes/${delegatedClass.id}`, delegate, 'DELETE'); - await request('/archives', delegate); - await request('/term/promotion', delegate); - check('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {}); + for (const url of ['/classes', '/archives', '/term/promotion']) + await request( + url, + delegate, + url === '/classes' ? 'POST' : 'GET', + url === '/classes' ? {} : undefined, + 403, + ); + check('组织管理能力不能下放给教师,路由永久拒绝', () => {}); const allRoster = await permissions.createGroup(admin, { name: '全校名单', scope: 'ALL', @@ -132,50 +130,41 @@ try { scope: 'ALL', 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] }); const foreignDetail = await request(`/classes/${a.id}`, delegate); 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('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {}); const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } }); 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, { - name: '追加升级权限', + name: '追加设备读权限', scope: 'ALL', - keys: ['promotion:manage'], + keys: ['client:read'], }); await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] }); - check('无组回落、显式默认组与多组并集', () => {}); - assert((await loadEffectivePermissions(teacher)).has('promotion:manage')); + assert((await loadAuthPrincipal(teacher)).permissions.has('client:read')); + assert(!(await loadAuthPrincipal(teacher)).permissions.has('promotion:manage')); await denied( 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: '越范围', scope: 'ALL', keys: ['roster:read'] }), - 403, - ); await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 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', scopeValue: '未知' }), 400); check('范围统一归一化并拒绝空值和未知年级', () => assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'), ); - const memberList = await permissions.listMembers(); + const memberList = await permissions.listMembers(admin); 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('审计记录操作人与变更前后快照', () => assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)), ); @@ -197,7 +186,7 @@ try { } } try { - await permissions.listMembers(); + await permissions.listMembers(admin); const small = queryCount; queryCount = 0; await prisma.user.createMany({ @@ -208,7 +197,7 @@ try { passwordHash: '临时', })), }); - await permissions.listMembers(); + await permissions.listMembers(admin); check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small)); } finally { restoreMethods.forEach((restore) => restore()); diff --git a/packages/server/scripts/verify-v14.ts b/packages/server/scripts/verify-v14.ts index ac4a0e1..6449aca 100644 --- a/packages/server/scripts/verify-v14.ts +++ b/packages/server/scripts/verify-v14.ts @@ -114,8 +114,17 @@ for (const [userId, id, scopeValue] of [ [head.id, 'builtin-head', 'HEAD'], [teacher.id, 'builtin-teacher', ''], ] as const) { - await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } }); - await prisma.userPermissionGroup.create({ data: { userId, groupId: id } }); + const scope = + 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()); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); diff --git a/packages/server/src/lib/access.ts b/packages/server/src/lib/access.ts index f03c995..9a37412 100644 --- a/packages/server/src/lib/access.ts +++ b/packages/server/src/lib/access.ts @@ -1,379 +1,129 @@ -import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared'; -import { - canManageGrades, - canManageRoster, - canManageSchedule, - canManageSubjectContent, - canPublishNotification, - resolveClassRole, - type ClassRole, -} from '@classhelper/shared'; -import { teacherScopeWhere } from './permission-scope.js'; +import type { Prisma } from '../generated/prisma/client.js'; +import type { PermissionKey, ClassRole } from '@classhelper/shared'; +import { resolveClassRole } from '@classhelper/shared'; import { prisma } from './db.js'; import { ApiError } from './http.js'; -import { assertPermission, currentRequestPermission } from './permissions.js'; -import { hasAllScope } from './permission-scope.js'; import type { TokenPayload } from './jwt.js'; - -/** - * 权限(RBAC)规则。 - * - * | 角色 | 范围 | 能做什么 | - * | ---- | ---- | -------- | - * | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 | - * | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** | - * | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 | - * | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 | - * | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 | - * - * 三条铁律(都是这次重构的落点): - * 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表; - * 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有; - * 3. **所有权限都在后端判**,前端只做展示控制。 - * - * 判定逻辑与前端共用 `@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 外键。 */ +import { + assertAdmin, + assertPermission, + assertClassRead, + assertManage, + classIdsFor, + classWhere, + classDeviceClassId, + isHead, + hasOrganizationalGrant, +} from './authz/index.js'; +export { classDeviceClassId } from './authz/relations.js'; +export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN'; +export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER'; +export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER'; +export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE'; export async function resolveContentCreator(user: TokenPayload, classId: string) { if (!isClassDevice(user)) return user.sub; 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; } - -/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */ -export function classDeviceClassId(user: TokenPayload): string | null { - return isClassDevice(user) ? (user.classId ?? user.sub) : null; +export function requireClassDeviceClassId(user: TokenPayload): string { + const id = classDeviceClassId(user); + if (!id) throw ApiError.forbidden('当前会话未绑定班级'); + return id; } - -/** - * 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。 - * - * 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表, - * 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。 - */ -/** 当前用户可访问的全部班级 id */ -export async function getAccessibleClassIds( - user: TokenPayload, - permission?: ContractPermissionKey, -): Promise { - 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 const getAccessibleClassIds = classIdsFor; export async function canAccessClass( user: TokenPayload, classId: string, - permission?: ContractPermissionKey, + key: PermissionKey, ): Promise { - if (isAdmin(user)) return true; - if (isTeacher(user)) { - const count = await prisma.class.count({ - where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] }, - }); - return count > 0; - } - return classDeviceClassId(user) === classId; + if (isClassDevice(user)) return classDeviceClassId(user) === classId; + return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })); } - -/** 读权限断言 */ +/** 兼容窄封装,键必须由调用点显式提供。 */ export async function assertClassAccess( user: TokenPayload, classId: string, - permission?: ContractPermissionKey, + key: PermissionKey, ): Promise { - const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); - if (!exists) throw ApiError.notFound('班级不存在'); - if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据'); + await assertClassRead(user, key, classId, { allowClassDevice: true }); } - -/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */ -export async function assertClassWritable(user: TokenPayload, classId: string): Promise { - 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 { - if (user.role === 'ADMIN') return 'ADMIN'; - if (user.role === 'CLASS_DEVICE') return (await canAccessClass(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'; + if (isAdmin(user)) return 'ADMIN'; + if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE'; return resolveClassRole({ role: user.role, - isHeadTeacher: record.teacherId === user.sub, - teachesSubjects: record.courses.length > 0, + isHeadTeacher: await isHead(user, classId), + teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })), }); } - -/** 班级本身(增删改)与人员分配:仅管理员 */ -export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise { - await assertPermission(user, 'class:manage'); - 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 async function assertCanManageClasses(user: TokenPayload, classId?: string) { + assertAdmin(user); + if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId) + throw ApiError.conflict('该班级已归档'); } - -export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) { +export const assertCanAssignTeachers = assertCanManageClasses; +export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) { + assertAdmin(user); await assertPermission(user, key); - if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围'); } - -/** 班主任 / 各科科任老师的分配:仅管理员 */ -export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise { - await assertCanManageClasses(user, classId); +export async function assertClassInScope(user: TokenPayload, classId: string, key: PermissionKey) { + await assertClassRead(user, key, classId); } - -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) { +export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) { for (const id of new Set(ids)) await assertClassInScope(user, id, key); } - -/** - * 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。 - * - * @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员 - */ -export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise { - await assertPermission(user, 'roster:manage'); +export async function assertCanManageRoster( + user: TokenPayload, + classId?: string, + db: Prisma.TransactionClient = prisma, +) { if (!classId) { - if (!(await hasAllScope(user, 'roster:manage'))) - throw ApiError.forbidden('未分班学生操作需要全校名单范围'); + await assertPermission(user, 'roster:manage'); + if (!(await hasOrganizationalGrant(user, 'roster:manage'))) + throw ApiError.forbidden('未分班学生需要全校名单范围'); return; } - await assertClassInScope(user, classId, 'roster:manage'); - if (await hasAllScope(user, 'roster:manage')) return; - const classRole = await resolveUserClassRole(user, classId); - if (!canManageRoster(classRole)) { - throw ApiError.forbidden( - classRole === 'SUBJECT' - ? '科任老师不能管理学生名单,请联系班主任或管理员' - : '只能管理自己担任班主任的班级的学生名单', - ); - } + await assertManage(user, 'roster:manage', classId, db); } - -/** 课表管理(含时间配置导入):管理员或本班班主任 */ -export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise { - 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( +export async function assertCanManageSchedule( user: TokenPayload, classId: string, - writeKey: ContractPermissionKey = 'schedule:write', -): Promise { - await assertClassInScope(user, classId, 'import:schedule'); - await assertClassInScope(user, classId, writeKey); + db: Prisma.TransactionClient = prisma, +) { + await assertManage(user, 'schedule:write', classId, db); +} +export async function assertCanImportSchedule(user: TokenPayload, classId: string) { + await assertManage(user, 'import:schedule', classId); await assertCanManageSchedule(user, classId); } - -/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */ -export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise { - 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 { - 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 { - 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 async function resolveClassScope( user: TokenPayload, - requestedClassId?: string, - permission?: ContractPermissionKey, + requestedClassId: string | undefined, + key: PermissionKey, ): Promise { if (requestedClassId) { - await assertClassAccess(user, requestedClassId, permission); + await assertClassAccess(user, requestedClassId, key); return { mode: 'in', classIds: [requestedClassId] }; } - if (isAdmin(user)) return { mode: 'all' }; - const scopePermission = permission ?? currentRequestPermission(user); - if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission))) - return { mode: 'all' }; - - const classIds = await getAccessibleClassIds(user, permission); - // 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错 - return { mode: 'in', classIds }; + if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] }; + await assertPermission(user, key); + if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' }; + // 其余范围显式枚举班级,避免空集合被下游当成全量。 + return { mode: 'in', classIds: await classIdsFor(user, key) }; } - -/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */ export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } { return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } }; } - -/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */ export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } { return scope.mode === 'all' ? {} : { id: { in: scope.classIds } }; } - -/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */ 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; } diff --git a/packages/server/src/lib/authz/assert.ts b/packages/server/src/lib/authz/assert.ts new file mode 100644 index 0000000..b6850cc --- /dev/null +++ b/packages/server/src/lib/authz/assert.ts @@ -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 { + if (!(await loadAuthPrincipal(user)).permissions.has(key)) + throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`); +} +export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('只有班主任或本班任课老师可以发布'); +} diff --git a/packages/server/src/lib/authz/index.ts b/packages/server/src/lib/authz/index.ts new file mode 100644 index 0000000..4857d2a --- /dev/null +++ b/packages/server/src/lib/authz/index.ts @@ -0,0 +1,4 @@ +export * from './principal.js'; +export * from './scope.js'; +export * from './relations.js'; +export * from './assert.js'; diff --git a/packages/server/src/lib/authz/principal.ts b/packages/server/src/lib/authz/principal.ts new file mode 100644 index 0000000..5b3f785 --- /dev/null +++ b/packages/server/src/lib/authz/principal.ts @@ -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; + grants: ReadonlyMap; + fromGroups: boolean; +} +export interface AssignmentRow { + groupId: string; + scope: string; + scopeValue: string; + group: { defaultRole: string | null; items: { key: string }[] }; +} +let requests = new WeakMap>(); + +export function invalidateAuthPrincipals() { + requests = new WeakMap(); +} + +/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */ +export function loadAuthPrincipal(user: TokenPayload): Promise { + let principal = requests.get(user); + if (!principal) { + principal = load(user); + requests.set(user, principal); + } + return principal; +} + +async function load(user: TokenPayload): Promise { + 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 { + const permissions = new Set(); + const grants = new Map(); + 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 }; +} diff --git a/packages/server/src/lib/authz/relations.ts b/packages/server/src/lib/authz/relations.ts new file mode 100644 index 0000000..2fab91c --- /dev/null +++ b/packages/server/src/lib/authz/relations.ts @@ -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 { + return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } })); +} +export async function isTeaching( + user: TokenPayload, + classId: string, + db: Prisma.TransactionClient = prisma, +): Promise { + return ( + (await isHead(user, classId, db)) || + !!(await db.course.count({ where: { classId, teacherId: user.sub } })) + ); +} diff --git a/packages/server/src/lib/authz/scope.ts b/packages/server/src/lib/authz/scope.ts new file mode 100644 index 0000000..a268cf3 --- /dev/null +++ b/packages/server/src/lib/authz/scope.ts @@ -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 { + 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 { + 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 { + 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, + management: ReadonlyArray, +): Promise>> { + 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 { + 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 { + 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); +} diff --git a/packages/server/src/lib/authz/session.ts b/packages/server/src/lib/authz/session.ts new file mode 100644 index 0000000..5ffa62b --- /dev/null +++ b/packages/server/src/lib/authz/session.ts @@ -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 { + 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 }; +} diff --git a/packages/server/src/lib/permission-scope.ts b/packages/server/src/lib/permission-scope.ts deleted file mode 100644 index f3c7845..0000000 --- a/packages/server/src/lib/permission-scope.ts +++ /dev/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 { - 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 - ); -} diff --git a/packages/server/src/lib/permissions.ts b/packages/server/src/lib/permissions.ts index 5ac2406..4bf80d4 100644 --- a/packages/server/src/lib/permissions.ts +++ b/packages/server/src/lib/permissions.ts @@ -1,94 +1,11 @@ import { BUILTIN_TEACHER_GROUPS, - PERMISSION_KEYS, PERMISSION_LABELS, ROLE_DEFAULT_PERMISSIONS, - defaultPermissionsForRole, type PermissionKey, } from '@classhelper/shared'; import { prisma } from './db.js'; import type { TokenPayload } from './jwt.js'; -import { ApiError } from './http.js'; - -// 仅复用当前请求对象上的权限,绝不跨请求缓存。 -const requestPermissions = new WeakMap>(); -const requestPermission = new WeakMap(); -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) { - 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 { - if (!(await hasEffectivePermission(user, key))) - throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`); - bindRequestPermission(user, key); -} -export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise { - 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 { - 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> { - const permissions = new Set(); - - 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(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 | null = null; @@ -114,10 +31,10 @@ async function createDefaultGroups(): Promise { }, }); } - 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 } })) continue; - const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`; + const baseName = '教师默认权限'; let name = baseName; let suffix = 1; // 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。 @@ -132,18 +49,22 @@ async function createDefaultGroups(): Promise { defaultRole: role, name, builtin: true, - scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS', - description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。', + scope: 'TEACHING', + description: '未显式分配时使用此模板与固定任教班级范围。', items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) }, }, }); } } -/** 管理员不可被剥夺的权限键(防锁死) */ -export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage']; - /** 把权限键翻译成中文提示(403 文案与权限页共用) */ export function permissionLabel(key: PermissionKey): string { 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); +} diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts index 4c5adf1..c9efabe 100644 --- a/packages/server/src/lib/snapshot.ts +++ b/packages/server/src/lib/snapshot.ts @@ -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 { DatabaseProvider } from './db.js'; @@ -183,6 +186,24 @@ export async function restoreSnapshot( } for (const table of SNAPSHOT_TABLES) { 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') { const { mode, ...rest } = row; return { @@ -209,6 +230,8 @@ export async function restoreSnapshot( } finally { await setForeignKeys(prisma, provider, true).catch(() => undefined); } + invalidateAuthPrincipals(); + refreshClassSubscriptions(); return { counts }; } diff --git a/packages/server/src/lib/student-lifecycle.ts b/packages/server/src/lib/student-lifecycle.ts index 1466515..c76cf19 100644 --- a/packages/server/src/lib/student-lifecycle.ts +++ b/packages/server/src/lib/student-lifecycle.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '../generated/prisma/client.js'; import { randomUUID } from 'node:crypto'; import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared'; import { prisma } from './db.js'; @@ -31,17 +32,20 @@ export async function changeStudentLifecycle( status?: 'active' | 'inactive'; }; }, + transaction?: Prisma.TransactionClient, ) { const batchId = randomUUID(); 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('部分学生不存在,请刷新后重试'); for (const cls of new Set(existing.map((student) => student.classId))) - await assertCanManageRoster(user, cls ?? undefined); - if (input.toClassId) await assertCanManageRoster(user, input.toClassId); - else if (!existing.length) await assertCanManageRoster(user); + await assertCanManageRoster(user, cls ?? undefined, transaction); + if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction); + else if (!existing.length) await assertCanManageRoster(user, undefined, transaction); 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; if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在'); if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入'); @@ -160,7 +164,8 @@ export async function changeStudentLifecycle( transfers: records.map(toStudentTransferDto), studentId: students[0].id, }; - }); - notifyStudentClasses(changedClasses); + }; + const result = transaction ? await work(transaction) : await prisma.$transaction(work); + if (!transaction) notifyStudentClasses(changedClasses); return result; } diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 3af5fd1..fccd978 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,13 +1,9 @@ import type { Request, RequestHandler } from 'express'; import type { PermissionKey, UserRole } from '@classhelper/shared'; import { PERMISSION_LABELS } from '@classhelper/shared'; -import { prisma } from '../lib/db.js'; import { ApiError } from '../lib/http.js'; -import { - bindRequestPermission, - bindRequestPermissions, - loadEffectivePermissions, -} from '../lib/permissions.js'; +import { loadAuthPrincipal } from '../lib/authz/index.js'; +import { loadSessionUser } from '../lib/authz/session.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; /** @@ -25,44 +21,8 @@ export function authenticate(): RequestHandler { const payload = verifyToken(token); - // ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表 - if (payload.classSession) { - 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!); + const user = await loadSessionUser(payload); + req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions }; next(); } catch (error) { next(error); @@ -89,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler { /** * 权限守卫:要求当前登录主体拥有**全部**列出的权限键。 * - * 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。 + * 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。 * 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。 */ export function requirePermission(...keys: PermissionKey[]): RequestHandler { @@ -105,13 +65,12 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler { next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`)); return; } - if (keys[0]) bindRequestPermission(req.auth.user, keys[0]); next(); }; } /** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */ -export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler { +export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler { const guard = requirePermission(...keys); return (req, res, next) => { if (req.auth?.user.role === 'CLASS_DEVICE') return next(); @@ -139,8 +98,6 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler { ); return; } - const matched = keys.find((key) => permissions.has(key)); - if (matched) bindRequestPermission(req.auth.user, matched); next(); }; } @@ -161,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet { export function hasAuthPermission(req: Request, key: PermissionKey): boolean { return (req.auth?.permissions ?? new Set()).has(key); } + +export const requireAdmin = (): RequestHandler => requireRole('ADMIN'); diff --git a/packages/server/src/middleware/validate.ts b/packages/server/src/middleware/validate.ts index e25e37d..99c2718 100644 --- a/packages/server/src/middleware/validate.ts +++ b/packages/server/src/middleware/validate.ts @@ -1,5 +1,6 @@ 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 { body?: ZodType; @@ -14,6 +15,7 @@ interface ValidationTargets { */ export function validate( targets: ValidationTargets, + errorStatus: 400 | 422 = 422, ): RequestHandler { return (req, _res, next) => { try { @@ -22,11 +24,21 @@ export function validate( if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown; next(); } catch (error) { - next(error); + next( + errorStatus === 400 && error instanceof ZodError + ? ApiError.badRequest('请求参数不合法', error.issues) + : error, + ); } }; } +export function validateBadRequest( + targets: ValidationTargets, +): RequestHandler { + return validate(targets, 400); +} + /** 读取已校验的 query(类型由调用方用 zod schema 保证) */ /** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */ export function validatedBody(req: Request): T { diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts index 6a46f59..16a8f51 100644 --- a/packages/server/src/modules/ai/ai.service.ts +++ b/packages/server/src/modules/ai/ai.service.ts @@ -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 { dayKeyLocal, @@ -84,6 +86,7 @@ export async function updateAiSettings( user: TokenPayload, input: UpdateAiSettingsInput, ): Promise { + assertAdmin(user); 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.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim()); @@ -105,17 +108,33 @@ interface AssistantContext { } async function resolveContext(user: TokenPayload, classId?: string): Promise { - if (classId) await assertClassAccess(user, classId); - const classIds = await getAccessibleClassIds(user); + if (classId) await assertClassAccess(user, classId, 'ai:use'); + 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({ - 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 }, orderBy: { name: 'asc' }, take: 80, }); - const courses = classId - ? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } }) - : []; + const courses = + classId && classIds.includes(classId) + ? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } }) + : []; return { classes, courses }; } @@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr } if (typeof params.classId === 'string') { - await assertClassAccess(user, params.classId); + await assertClassAccess(user, params.classId, 'ai:use'); if (typeof params.courseId === 'string') { const course = await prisma.course.findFirst({ where: { id: params.courseId, classId: params.classId }, diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts index 6f691e3..5c96cfb 100644 --- a/packages/server/src/modules/archives/archives.module.ts +++ b/packages/server/src/modules/archives/archives.module.ts @@ -1,3 +1,4 @@ +import { requireAdmin } from '../../middleware/auth.js'; import { restoreStudentSchema } from '../students/students.schemas.js'; import { listTransferred, restoreTransferred } from './transferred.service.js'; import { Router } from 'express'; @@ -15,7 +16,7 @@ import { import * as archiveService from './archives.service.js'; const router = Router(); -router.use(authenticate()); +router.use(authenticate(), requireAdmin()); router.get('/transferred', requirePermission('archive:manage'), async (req, res) => sendOk(res, await listTransferred(getAuthUser(req))), ); diff --git a/packages/server/src/modules/calls/calls.module.ts b/packages/server/src/modules/calls/calls.module.ts index 9cb57f5..0f19668 100644 --- a/packages/server/src/modules/calls/calls.module.ts +++ b/packages/server/src/modules/calls/calls.module.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; 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 { defineModule } from '../module.types.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; @@ -15,7 +15,7 @@ router.use(authenticate()); */ router.post( '/', - requireStaffPermission('call:create'), + requirePermissionOrClassDevice('call:create'), validate({ body: createCallSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index 3135346..13c3a24 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -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 { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared'; -import { assertCanPublishNotification } from '../../lib/access.js'; import { isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.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 { - await assertStaffPermission(user, 'call:create'); - await assertCanPublishNotification(user, input.classId); + await assertPermissionOrClassDevice(user, 'call:create'); + await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true }); const student = await prisma.student.findUnique({ where: { id: input.studentId }, diff --git a/packages/server/src/modules/class-plans/class-plans.module.ts b/packages/server/src/modules/class-plans/class-plans.module.ts index cc9a772..638c7c2 100644 --- a/packages/server/src/modules/class-plans/class-plans.module.ts +++ b/packages/server/src/modules/class-plans/class-plans.module.ts @@ -9,8 +9,9 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, + requireAnyPermission, } from '../../middleware/auth.js'; import { validate, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; @@ -21,7 +22,7 @@ const layouts = Router(); layouts.use(authenticate()); layouts.get( '/', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: schemas.scopeQuery }), async (req, res) => sendOk( @@ -31,26 +32,27 @@ layouts.get( ); layouts.post( '/', - requirePermission('timetable:manage'), + requireAnyPermission('timetable:manage', 'schedule:write'), 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( '/:id', - requirePermission('timetable:manage'), + requireAnyPermission('timetable:manage', 'schedule:write'), 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( '/:id/clone', - requirePermission('timetable:manage'), + requireAnyPermission('timetable:manage', 'schedule:write'), validate({ params: idParamSchema, body: schemas.cloneSchema }), async (req, res) => { const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id); if (!current) throw ApiError.notFound('时间表不存在'); sendCreated( res, - await service.saveLayout({ + await service.saveLayout(getAuthUser(req), { ...current, name: req.body.name || `${current.name} 副本`, points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), @@ -60,7 +62,7 @@ layouts.post( ); layouts.post( '/:id/import', - requirePermission('timetable:manage'), + requireAnyPermission('timetable:manage', 'schedule:write'), validate({ params: idParamSchema, body: schemas.importLayoutSchema }), async (req, res) => { const all = await service.listLayouts(getAuthUser(req)); @@ -70,6 +72,7 @@ layouts.post( sendOk( res, await service.saveLayout( + getAuthUser(req), { ...target, points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), @@ -81,21 +84,21 @@ layouts.post( ); layouts.post( '/: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) }) }), 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( '/:id', - requirePermission('timetable:manage'), + requireAnyPermission('timetable:manage', 'schedule:write'), validate({ params: idParamSchema }), async (req, res) => { const id = String(req.params.id); - if (await prisma.classPlan.count({ where: { timeLayoutId: id } })) - throw ApiError.conflict('仍有课表使用该时间表'); - await prisma.timeLayout.delete({ where: { id } }); - await service.notifyScheduleChanged(); + await service.deleteLayout(getAuthUser(req), id); sendOk(res, { id }); }, ); @@ -104,7 +107,7 @@ const plans = Router(); plans.use(authenticate()); plans.get( '/profile', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)), @@ -127,7 +130,7 @@ plans.post( ); plans.get( '/', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: schemas.scopeQuery }), async (req, res) => { const q = validatedQuery<{ classId?: string; week?: number }>(req); @@ -175,7 +178,7 @@ plans.post( ); plans.get( '/:id/export', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ params: idParamSchema }), async (req, res) => { const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); @@ -207,22 +210,22 @@ const groups = Router(); groups.use(authenticate()); groups.get( '/selection', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { 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}` } }); sendOk(res, value ? JSON.parse(value.value) : {}); }, ); groups.get( '/', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { 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 } })); }, ); diff --git a/packages/server/src/modules/class-plans/class-plans.service.ts b/packages/server/src/modules/class-plans/class-plans.service.ts index 39b5ebf..bda25b5 100644 --- a/packages/server/src/modules/class-plans/class-plans.service.ts +++ b/packages/server/src/modules/class-plans/class-plans.service.ts @@ -1,3 +1,4 @@ +import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js'; import { SOCKET_EVENTS } from '@classhelper/shared'; import { prisma } from '../../lib/db.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' }); } export async function listLayouts(user: TokenPayload, classId?: string) { - if (classId) await assertClassAccess(user, classId); - const ids = classId ? [classId] : await getAccessibleClassIds(user); + await assertPermissionOrClassDevice(user, 'schedule:read'); + if (classId) await assertClassAccess(user, classId, 'schedule:read'); + const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read'); const rows = await prisma.timeLayout.findMany({ where: { OR: [{ classId: null }, { classId: { in: ids } }] }, include: { points: { orderBy: { position: 'asc' } } }, }); return Promise.all(rows.map((row) => decorateLayout(row))); } -export async function saveLayout(input: z.infer, id?: string) { +export async function saveLayout(user: TokenPayload, input: z.infer, id?: string) { const existing = id ? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } }) : null; if (id && !existing) throw ApiError.notFound('时间表不存在'); 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) => ({ ...point, @@ -83,12 +88,13 @@ export async function saveLayout(input: z.infer, id?: strin * 把时间点的默认课程写入所有引用该时间表的日课表(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({ where: { id: layoutId }, include: { points: true }, }); if (!layout) throw ApiError.notFound('时间表不存在'); + await assertLayoutWrite(user, layout.classId, layout.id); if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程'); const point = layout.points.find((p) => p.id === pointId); if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点'); @@ -121,10 +127,11 @@ export async function applyDefaultCourse(layoutId: string, pointId: string) { return { plans: plans.length }; } 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({ where: { - classId: { in: classId ? [classId] : await getAccessibleClassIds(user) }, + classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') }, ...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}), }, 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) { const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude }); 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; } export async function savePlan(user: TokenPayload, input: z.infer, id?: string) { @@ -285,3 +293,24 @@ export async function createOverlay( await notifyScheduleChanged(source.classId); 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); +} diff --git a/packages/server/src/modules/class-plans/profile-export.service.ts b/packages/server/src/modules/class-plans/profile-export.service.ts index 4497d5b..b6bfd16 100644 --- a/packages/server/src/modules/class-plans/profile-export.service.ts +++ b/packages/server/src/modules/class-plans/profile-export.service.ts @@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js'; /** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */ 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([ prisma.class.findUniqueOrThrow({ where: { id: classId } }), prisma.timeLayout.findMany({ diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index c774914..32ebfc0 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -4,8 +4,9 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, + requireAdmin, } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; @@ -19,6 +20,8 @@ import { promoteClassesSchema, updateClassAccountSchema, updateClassSchema, + updateClassSettingsSchema, + type UpdateClassSettingsInput, updateStudentGradeQuerySchema, type AddStudentInput, type AssignSubjectTeachersInput, @@ -42,7 +45,7 @@ router.use(authenticate()); */ router.get( '/', - requireStaffPermission('class:read'), + requirePermissionOrClassDevice('class:read'), validate({ query: listClassesQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -59,6 +62,7 @@ router.get( */ router.post( '/promote', + requireAdmin(), requirePermission('promotion:manage'), validate({ body: promoteClassesSchema }), async (req, res) => { @@ -71,7 +75,7 @@ router.post( /** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */ router.get( '/:id', - requireStaffPermission('class:read'), + requirePermissionOrClassDevice('class:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -83,6 +87,7 @@ router.get( /** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */ router.post( '/', + requireAdmin(), requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => { @@ -94,6 +99,7 @@ router.post( /** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */ router.patch( '/:id', + requireAdmin(), requirePermission('class:manage'), (req, _res, next) => { 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(req), + ), + ); + }, +); /** DELETE /api/classes/:id - 删除班级 */ router.delete( '/:id', + requireAdmin(), requirePermission('class:manage'), validate({ params: idParamSchema }), async (req, res) => { @@ -126,7 +148,7 @@ router.delete( */ router.patch( '/:id/class-account', - requirePermission('class:manage'), + requirePermission('class:account'), validate({ params: idParamSchema, body: updateClassAccountSchema }), async (req, res) => { const user = getAuthUser(req); @@ -145,6 +167,7 @@ router.patch( */ router.patch( '/:id/head-teacher', + requireAdmin(), requirePermission('class:manage'), validate({ params: idParamSchema, body: assignHeadTeacherSchema }), async (req, res) => { @@ -164,6 +187,7 @@ router.patch( */ router.put( '/:id/subject-teachers', + requireAdmin(), requirePermission('class:manage'), validate({ params: idParamSchema, body: assignSubjectTeachersSchema }), async (req, res) => { @@ -183,7 +207,7 @@ router.put( */ router.get( '/:id/student-grade-query', - requireStaffPermission('class:read'), + requirePermissionOrClassDevice('class:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -210,7 +234,7 @@ router.patch( /** GET /api/classes/:id/students - 班级学生名单 */ router.get( '/:id/students', - requireStaffPermission('roster:read'), + requirePermissionOrClassDevice('roster:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index f5a1920..55ff924 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -41,12 +41,15 @@ export const createClassSchema = z.object({ * `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。 */ export const updateClassSchema = z.strictObject({ + enrollmentYear: enrollmentYearSchema.optional(), + classIndex: classIndexSchema.optional(), +}); + +export const updateClassSettingsSchema = z.strictObject({ /** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */ termStartDate: z .union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')]) .nullish(), - enrollmentYear: enrollmentYearSchema.optional(), - classIndex: classIndexSchema.optional(), termWeeks: z.coerce.number().int().min(1).max(60).optional(), }); @@ -125,3 +128,5 @@ export type UpdateClassAccountInput = z.infer; export type UpdateStudentGradeQueryInput = z.infer; export type AddStudentInput = z.infer; export type AssignSubjectTeachersInput = z.infer; + +export type UpdateClassSettingsInput = z.infer; diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index 55174e7..0463701 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -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 { SOCKET_EVENTS, formatClassName } from '@classhelper/shared'; import { env } from '../../config/env.js'; @@ -23,12 +26,17 @@ import type { AssignSubjectTeachersInput, CreateClassInput, UpdateClassInput, + UpdateClassSettingsInput, UpdateStudentGradeQueryInput, } from './classes.schemas.js'; import { loadPromotionPlan } from '../term/promotion.service.js'; import { assertClassInScope, canAccessClass } from '../../lib/access.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 { getMaxTermWeeks } from '../../lib/term.js'; import { logger } from '../../lib/logger.js'; @@ -79,7 +87,7 @@ function toClassDtoWithAccount( * 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。 */ export async function listClasses(user: TokenPayload, keyword?: string): Promise { - await assertStaffPermission(user, 'class:read'); + await assertPermissionOrClassDevice(user, 'class:read'); const scope = await resolveClassScope(user, undefined, 'class:read'); // Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表) const where = { @@ -102,8 +110,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise /** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */ export async function getClassDetail(user: TokenPayload, classId: string): Promise { - await assertStaffPermission(user, 'class:read'); - await assertClassAccess(user, classId); + await assertPermissionOrClassDevice(user, 'class:read'); + await assertClassAccess(user, classId, 'class:read'); const item = await prisma.class.findUnique({ where: { id: classId }, @@ -147,7 +155,7 @@ export async function listSubjectTeachers( user: TokenPayload, classId: string, ): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> { - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'class:read'); const item = await prisma.class.findUnique({ where: { id: classId }, 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); } @@ -222,6 +237,10 @@ export async function updateHeadTeacher( teacherId: string, ): Promise { await assertCanAssignTeachers(user, classId); + const before = await prisma.class.findUniqueOrThrow({ + where: { id: classId }, + select: { teacherId: true }, + }); const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师'); 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' }); return toClassDtoWithAccount(user, updated); } @@ -256,6 +279,10 @@ export async function assignSubjectTeachers( const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); 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) { const teacherId = assignment.teacherId ? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`) @@ -290,6 +317,17 @@ export async function assignSubjectTeachers( where: { id: classId }, 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) => ({ courseId: course.id, subjectName: course.name, @@ -304,7 +342,8 @@ export async function updateClassAccount( classId: string, input: { code?: string; password?: string }, ): Promise { - await assertCanManageClasses(user, classId); + await assertHeadOnly(user, 'class:account', classId); + const before = await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { code: true } }); if ( 'termWeeks' in input && typeof input.termWeeks === 'number' && @@ -322,6 +361,12 @@ export async function updateClassAccount( }, }); 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); } @@ -340,19 +385,12 @@ export async function updateClass( if (!current) throw ApiError.notFound('班级不存在'); 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 classIndex = input.classIndex ?? current.classIndex; const updated = await prisma.class.update({ where: { id: classId }, 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.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' }); return toClassDto(updated); } +export async function updateClassSettings( + user: TokenPayload, + classId: string, + input: UpdateClassSettingsInput, +): Promise { + 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' }); + refreshClassSubscriptions(); return { promoted: ids.length }; } @@ -414,13 +488,21 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise< if (await prisma.student.count({ where: { classId } })) throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属'); 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 } }); + refreshClassSubscriptions(); + await prisma.permissionAudit.create({ + data: auditData(user, 'class-delete', 'class', classId, before, null), + }); } /** 班级学生名单 */ export async function listClassStudents(user: TokenPayload, classId: string): Promise { - await assertStaffPermission(user, 'roster:read'); - await assertClassAccess(user, classId); + await assertPermissionOrClassDevice(user, 'roster:read'); + await assertClassAccess(user, classId, 'roster:read'); const students = await prisma.student.findMany({ where: { classId, ...activeStudentWhere() }, include: { class: { select: { name: true, grade: true } } }, @@ -472,7 +554,7 @@ export async function getStudentGradeQuery( user: TokenPayload, classId: string, ): Promise<{ studentGradeQueryEnabled: boolean }> { - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'class:read'); const record = await prisma.class.findUnique({ where: { id: classId }, select: { studentGradeQueryEnabled: true }, @@ -493,7 +575,7 @@ export async function updateStudentGradeQuery( classId: string, input: UpdateStudentGradeQueryInput, ): Promise<{ studentGradeQueryEnabled: boolean }> { - await assertClassAccess(user, classId); + await assertManage(user, 'grade:write', classId); if (!isStaff(user)) { throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读'); } diff --git a/packages/server/src/modules/clients/clients.module.ts b/packages/server/src/modules/clients/clients.module.ts index 63c871f..f947cef 100644 --- a/packages/server/src/modules/clients/clients.module.ts +++ b/packages/server/src/modules/clients/clients.module.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import type { ClientUpdateProgressDto } from '@classhelper/shared'; import { sendCreated, sendOk } from '../../lib/http.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 { defineModule } from '../module.types.js'; import { @@ -22,14 +22,19 @@ import * as clientService from './clients.service.js'; const router = Router(); router.use(authenticate()); -router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => { - const input = validatedBody<{ - deviceKey: string; - progress: ClientUpdateProgressDto; - }>(req); - await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress); - sendOk(res, null); -}); +router.post( + '/update-progress', + requireRole('CLASS_DEVICE'), + validate({ body: clientProgressSchema }), + async (req, res) => { + const input = validatedBody<{ + deviceKey: string; + progress: ClientUpdateProgressDto; + }>(req); + await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress); + sendOk(res, null); + }, +); /** * POST /api/clients/heartbeat - 客户端每 30 秒上报一次 @@ -37,22 +42,28 @@ router.post('/update-progress', validate({ body: clientProgressSchema }), async * **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。 * 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。 */ -router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => { - const user = getAuthUser(req); - const input = validatedBody(req); - const result = await clientService.heartbeat(user, input, req.ip ?? ''); - // 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。 - sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result); -}); +router.post( + '/heartbeat', + requireRole('CLASS_DEVICE'), + validate({ body: clientHeartbeatSchema }), + async (req, res) => { + const user = getAuthUser(req); + const input = validatedBody(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 归属校验) */ router.post( '/commands/:id/ack', + requireRole('CLASS_DEVICE'), validate({ params: idParamSchema, body: clientCommandAckSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); const input = validatedBody(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 端设备列表(版本 / 在线 / 待执行命令) */ router.get( '/devices', - requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'), + requirePermission('client:read'), validate({ query: listClientDevicesQuerySchema }), async (req, res) => { const query = validatedQuery(req); @@ -94,7 +105,7 @@ router.get( /** POST /api/clients/updates - 统一下发客户端更新 */ router.post( '/updates', - requirePermission('client:update'), + requirePermission('client:update-push'), validate({ body: pushClientUpdateSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts index 0b8b23f..b6cf357 100644 --- a/packages/server/src/modules/clients/clients.service.ts +++ b/packages/server/src/modules/clients/clients.service.ts @@ -1,3 +1,4 @@ +import { assertClassDevice } from '../../lib/authz/index.js'; import { CLIENT_COMMAND_EXPIRE_MS, CLIENT_COMMAND_LABELS, @@ -136,6 +137,11 @@ export async function heartbeat( input: ClientHeartbeatInput, ip: string, ): Promise { + 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 heartbeatAt = { classId: resolveClassId(user), @@ -149,11 +155,17 @@ export async function heartbeat( lastIp: ip, }; - const device = (await prisma.clientDevice.upsert({ - where: { deviceKey: input.deviceKey }, - create: { deviceKey: input.deviceKey, ...heartbeatAt }, - update: heartbeatAt, - })) as unknown as DeviceRow; + const device = (await prisma.clientDevice + .upsert({ + where: { deviceKey: input.deviceKey, classId: boundClassId }, + create: { deviceKey: input.deviceKey, ...heartbeatAt }, + 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(); if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) { @@ -204,7 +216,7 @@ export async function reportUpdateProgress( progress: ClientUpdateProgressDto, ) { const device = await prisma.clientDevice.findUnique({ where: { deviceKey } }); - // 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。 + assertClassDevice(user, device?.classId ?? ''); if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级'); // 已取消或更换目标的迟到进度不得覆盖当前任务。 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 - 客户端执行完远控命令后的回执 */ export async function ackCommand( + user: TokenPayload, commandId: string, input: ClientCommandAckRequest, ): Promise { const command = (await prisma.clientCommand.findUnique({ where: { id: commandId }, - include: { device: { select: { deviceKey: true, name: true } } }, - })) as (CommandRow & { device: { deviceKey: string; name: string } }) | null; + include: { device: { select: { deviceKey: true, name: true, classId: true } } }, + })) as (CommandRow & { device: { deviceKey: string; name: string; classId: string | null } }) | null; if (!command) throw ApiError.notFound('命令不存在'); + assertClassDevice(user, command.device.classId ?? ''); if (command.device.deviceKey !== input.deviceKey) { throw ApiError.forbidden('这条命令不是发给这台电脑的'); @@ -271,7 +285,7 @@ export async function listDevices( user: TokenPayload, options: { classId?: string } = {}, ): Promise { - const scope = await resolveClassScope(user, options.classId); + const scope = await resolveClassScope(user, options.classId, 'client:read'); const rows = (await prisma.clientDevice.findMany({ where: classScopeWhere(scope), include: { class: { select: { id: true, name: true } } }, @@ -394,7 +408,7 @@ export async function createCommands( /** 更新推送的目标集合:优先设备,其次班级,最后"全部" */ async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise { - 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 ?? [])]; if (deviceIds.length > 0) { const rows = (await prisma.clientDevice.findMany({ diff --git a/packages/server/src/modules/courses/courses.module.ts b/packages/server/src/modules/courses/courses.module.ts index 92c7349..6d8f132 100644 --- a/packages/server/src/modules/courses/courses.module.ts +++ b/packages/server/src/modules/courses/courses.module.ts @@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; @@ -24,7 +24,7 @@ router.use(authenticate()); /** GET /api/courses?classId= - 课程列表 */ router.get( '/', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: listCoursesQuerySchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/courses/courses.service.ts b/packages/server/src/modules/courses/courses.service.ts index 390ef89..37fa4c1 100644 --- a/packages/server/src/modules/courses/courses.service.ts +++ b/packages/server/src/modules/courses/courses.service.ts @@ -1,3 +1,4 @@ +import { refreshClassSubscriptions } from '../../realtime/bus.js'; import type { CourseDto } from '@classhelper/shared'; import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.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; export async function listCourses(user: TokenPayload, classId?: string): Promise { - const scope = await resolveClassScope(user, classId); + const scope = await resolveClassScope(user, classId, 'schedule:read'); const courses = await prisma.course.findMany({ where: classScopeWhere(scope), include: { teacher: teacherSelect }, @@ -26,7 +27,7 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput) * * 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师, * 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 —— - * 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设 + * 后面那道 `` 的科目门随即形同虚设 * (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师 * 能布置其他科作业"的入口。 * @@ -82,4 +83,5 @@ export async function deleteCourse(user: TokenPayload, courseId: string): Promis if (!course) throw ApiError.notFound('课程不存在'); await assertCanManageSchedule(user, course.classId); await prisma.course.delete({ where: { id: courseId } }); + refreshClassSubscriptions(); } diff --git a/packages/server/src/modules/courseware/courseware.module.ts b/packages/server/src/modules/courseware/courseware.module.ts index d30712d..2c2f730 100644 --- a/packages/server/src/modules/courseware/courseware.module.ts +++ b/packages/server/src/modules/courseware/courseware.module.ts @@ -6,7 +6,7 @@ import { getAuthUser, requireRole, requirePermission, - requireStaffPermission, + requirePermissionOrClassDevice, } from '../../middleware/auth.js'; import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; @@ -22,7 +22,7 @@ import * as service from './courseware.service.js'; const router = Router(); router.use(authenticate()); 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))), ); router.get('/classes', ...staff, async (req, res) => @@ -83,10 +83,14 @@ router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), asy ); router.get( '/:id/download', - requireStaffPermission('courseware:read'), + requirePermissionOrClassDevice('courseware:read'), validate({ params: idParamSchema }), 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('Content-Type', 'application/octet-stream'); 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( '/:id', - requireStaffPermission('courseware:read'), + requirePermissionOrClassDevice('courseware:read'), validate({ params: idParamSchema }), async (req, res) => sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)), diff --git a/packages/server/src/modules/courseware/courseware.service.ts b/packages/server/src/modules/courseware/courseware.service.ts index 27cfcc3..ae0e15b 100644 --- a/packages/server/src/modules/courseware/courseware.service.ts +++ b/packages/server/src/modules/courseware/courseware.service.ts @@ -1,3 +1,9 @@ +import { + assertContentWrite, + assertPermission, + assertPermissionOrClassDevice, + classWhere, +} from '../../lib/authz/index.js'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; @@ -17,7 +23,7 @@ import { import { env } from '../../config/env.js'; import { prisma } from '../../lib/db.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 { Prisma } from '../../generated/prisma/client.js'; import { emitToClass } from '../../realtime/bus.js'; @@ -47,13 +53,20 @@ function dto(row: RecordWithRelations): CoursewareDto { }; } export async function teachingClasses(user: TokenPayload) { + await assertPermission(user, 'courseware:write'); 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 }, orderBy: { name: 'asc' }, }); } async function validateClasses(user: TokenPayload, ids: string[]) { + await assertPermission(user, 'courseware:write'); const classIds = [...new Set(ids)]; const allowed = new Set((await teachingClasses(user)).map((item) => item.id)); 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 }, select: { id: true }, }); - await assertCanManageSubjectContent(user, classId, course?.id, '发布课件'); + await assertContentWrite(user, 'courseware:write', classId, course?.id, '发布课件'); } 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 }); if (!row) throw ApiError.notFound('课件不存在'); if (user.role === 'CLASS_DEVICE') { @@ -77,17 +91,19 @@ export async function readable(user: TokenPayload, id: string) { return row; } 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('只能修改自己的课件'); return row; } 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') item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user)); return item; } export async function listCourseware(user: TokenPayload) { + await assertPermissionOrClassDevice(user, 'courseware:read'); const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null; const rows = await prisma.courseware.findMany({ where: classId @@ -177,6 +193,7 @@ async function assertFolder(user: TokenPayload, folderPath: string) { throw ApiError.badRequest('请选择自己创建的文件夹'); } export async function listFolders(user: TokenPayload) { + await assertPermission(user, 'courseware:write'); const rows = await prisma.coursewareFolder.findMany({ where: { ownerId: user.sub }, orderBy: { path: 'asc' }, @@ -184,6 +201,7 @@ export async function listFolders(user: TokenPayload) { return rows.map((row) => ({ id: row.id, path: row.path })); } export async function createFolder(user: TokenPayload, folderPath: string) { + await assertPermission(user, 'courseware:write'); const row = await prisma.coursewareFolder.upsert({ where: { ownerId_path: { 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 push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } }); if (!push) throw ApiError.notFound('传输任务不存在'); - await readable(user, push.coursewareId); + await readable(user, push.coursewareId, 'courseware:read'); await prisma.coursewareReceipt.upsert({ where: { pushId_deviceId: { pushId, deviceId: device.id } }, create: { pushId, deviceId: device.id }, diff --git a/packages/server/src/modules/dashboard/dashboard.module.ts b/packages/server/src/modules/dashboard/dashboard.module.ts index ed77009..ff696b3 100644 --- a/packages/server/src/modules/dashboard/dashboard.module.ts +++ b/packages/server/src/modules/dashboard/dashboard.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendOk } from '../../lib/http.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 * as dashboardService from './dashboard.service.js'; @@ -9,7 +9,7 @@ const router = Router(); router.use(authenticate()); /** 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); sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功'); }); @@ -21,7 +21,7 @@ router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res * 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。 * 带 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 classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined; const maxWeek = await dashboardService.resolveTermWeeks(user, classId); diff --git a/packages/server/src/modules/dashboard/dashboard.service.ts b/packages/server/src/modules/dashboard/dashboard.service.ts index 9077481..7ac1bc6 100644 --- a/packages/server/src/modules/dashboard/dashboard.service.ts +++ b/packages/server/src/modules/dashboard/dashboard.service.ts @@ -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 type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared'; import { assertClassAccess, - classScopeIdFilter, - classScopeWhere, isClassDevice, resolveClassScope, requireClassDeviceClassId, @@ -25,7 +25,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co * - 都不传:全局默认(getMaxTermWeeks 兜底)。 */ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise { - if (classId) await assertClassAccess(user, classId); + if (classId) await assertClassAccess(user, classId, 'term:read'); const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined); if (!targetClassId) return getMaxTermWeeks(); 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 { 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 过滤 - const where = classScopeWhere(scope); - const classWhere = classScopeIdFilter(scope); + const allowed = scope.mode === 'in' ? scope.classIds : await classIdsFor(user, 'dashboard:read'); + 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 班级端以全班学生为范围 const personalIds = await resolvePersonalIds(user); const personalWhere = personalIdWhere(personalIds); @@ -64,27 +92,27 @@ export async function getDashboardSummary(user: TokenPayload): Promise { - sendOk(res, await databaseService.getDatabaseStatus()); +router.get('/status', async (req, res) => { + sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).getDatabaseStatus()); }); /** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */ router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => { - const result = await databaseService.testConnection(validatedBody(req)); + const result = await databaseService + .forAdministrator(getAuthUser(req)) + .testConnection(validatedBody(req)); sendOk(res, result, result.ok ? '连接成功' : '连接失败'); }); /** GET /api/database/backups - 备份列表 */ -router.get('/backups', (_req, res) => { - sendOk(res, databaseService.listBackups()); +router.get('/backups', (req, res) => { + sendOk(res, databaseService.forAdministrator(getAuthUser(req)).listBackups()); }); /** POST /api/database/backups - 立即创建一份手动备份 */ -router.post('/backups', async (_req, res) => { - sendCreated(res, await databaseService.createBackup('manual'), '备份已创建'); +router.post('/backups', async (req, res) => { + sendCreated( + res, + await databaseService.forAdministrator(getAuthUser(req)).createBackup('manual'), + '备份已创建', + ); }); /** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */ router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => { 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 - 删除备份 */ router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => { const { name } = validatedParams<{ name: string }>(req); - databaseService.deleteBackup(name); + databaseService.forAdministrator(getAuthUser(req)).deleteBackup(name); sendOk(res, { name }, '备份已删除'); }); /** GET /api/database/export - 下载当前库的 JSON 快照 */ -router.get('/export', async (_req, res) => { - const json = await databaseService.exportSnapshotJson(); +router.get('/export', async (req, res) => { + const json = await databaseService.forAdministrator(getAuthUser(req)).exportSnapshotJson(); const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); @@ -77,8 +84,8 @@ router.get('/export', async (_req, res) => { }); /** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */ -router.get('/sqlite-file', (_req, res) => { - const filePath = databaseService.sqliteFilePathForDownload(); +router.get('/sqlite-file', (req, res) => { + const filePath = databaseService.forAdministrator(getAuthUser(req)).sqliteFilePathForDownload(); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`); res.status(200).send(fs.readFileSync(filePath)); @@ -87,33 +94,41 @@ router.get('/sqlite-file', (_req, res) => { /** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */ router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => { const { data } = validatedBody(req); - sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)'); + sendOk( + res, + await databaseService.forAdministrator(getAuthUser(req)).importSnapshotBase64(data), + '快照已导入(原数据已被覆盖)', + ); }); /** GET /api/database/backup-schedule - 定时备份配置 */ -router.get('/backup-schedule', (_req, res) => { - sendOk(res, databaseService.getBackupSchedule()); +router.get('/backup-schedule', (req, res) => { + sendOk(res, databaseService.forAdministrator(getAuthUser(req)).getBackupSchedule()); }); /** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */ router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => { sendOk( res, - databaseService.saveSchedulePatch(validatedBody(req)), + databaseService + .forAdministrator(getAuthUser(req)) + .saveSchedulePatch(validatedBody(req)), '定时备份配置已保存', ); }); /** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */ router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => { - const job = databaseService.startSwitch(validatedBody(req)); + const job = databaseService + .forAdministrator(getAuthUser(req)) + .startSwitch(validatedBody(req)); sendCreated(res, { jobId: job.id }, '切换任务已启动'); }); /** GET /api/database/switch/jobs/:id - 查询切换任务进度 */ router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); - const job = databaseService.getSwitchJob(id); + const job = databaseService.forAdministrator(getAuthUser(req)).getSwitchJob(id); sendOk(res, job, job ? 'ok' : '任务不存在'); }); diff --git a/packages/server/src/modules/database/database.service.ts b/packages/server/src/modules/database/database.service.ts index 7d36b1b..ab5f5a7 100644 --- a/packages/server/src/modules/database/database.service.ts +++ b/packages/server/src/modules/database/database.service.ts @@ -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 fs from 'node:fs'; import path from 'node:path'; @@ -795,3 +797,23 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise 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, + }; +} diff --git a/packages/server/src/modules/grades/grades.module.ts b/packages/server/src/modules/grades/grades.module.ts index d1e01d9..e74f7c7 100644 --- a/packages/server/src/modules/grades/grades.module.ts +++ b/packages/server/src/modules/grades/grades.module.ts @@ -1,11 +1,11 @@ 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 { authenticate, getAuthUser, requirePermission, - requireStaffPermission, + requirePermissionOrClassDevice, } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; @@ -31,9 +31,10 @@ import * as gradeService from './grades.service.js'; const router = Router(); router.use(authenticate()); -router.get('/overview', async (req, res) => { +router.get('/overview', requirePermissionOrClassDevice('grade:read'), async (req, res) => { 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 } }); sendOk( res, @@ -42,14 +43,19 @@ router.get('/overview', async (req, res) => { : await gradeService.collectiveAnalysis(user, classId), ); }); -router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = validatedQuery<{ classId: string }>(req); - sendOk(res, await gradeService.collectiveAnalysis(user, classId)); -}); +router.get( + '/collective', + requirePermissionOrClassDevice('grade:read'), + 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 班级端查看本班成绩(教师/管理员返回空数组) */ -router.get('/my', async (req, res) => { +router.get('/my', requirePermissionOrClassDevice('grade:read'), async (req, res) => { const user = getAuthUser(req); sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功'); }); @@ -65,7 +71,7 @@ router.get('/my', async (req, res) => { */ router.get( '/student/:studentNo', - requireStaffPermission('student:grade-query'), + requirePermissionOrClassDevice('student:grade-query'), validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -93,11 +99,16 @@ router.get( * **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集, * 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。 */ -router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = validatedQuery<{ classId: string }>(req); - sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功'); -}); +router.get( + '/exams', + requirePermissionOrClassDevice('grade:read'), + 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 - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */ router.put( @@ -121,11 +132,16 @@ router.put( * 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用 * `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。 */ -router.get('/ranking', 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), '获取考试排名成功'); -}); +router.get( + '/ranking', + requirePermissionOrClassDevice('grade:read'), + 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= - 成绩列表(教师) */ router.get( diff --git a/packages/server/src/modules/grades/grades.service.ts b/packages/server/src/modules/grades/grades.service.ts index 1cb5860..8329723 100644 --- a/packages/server/src/modules/grades/grades.service.ts +++ b/packages/server/src/modules/grades/grades.service.ts @@ -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 { SOCKET_EVENTS, @@ -14,7 +16,6 @@ import { type StudentGradeDetailDto, } from '@classhelper/shared'; import { - assertCanManageSubjectContent, assertClassAccess, classScopeWhere, isClassDevice, @@ -57,16 +58,10 @@ const classSelect = { select: { name: true } } as const; const include = { course: courseSelect, student: studentSelect, class: classSelect } as const; async function assertDetailQuery(user: TokenPayload, classId: string): Promise { - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'grade:read'); const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } }); - if ( - !canQueryStudentGrades({ - classRole: await resolveUserClassRole(user, classId), - gradeQueryEnabled: cls.studentGradeQueryEnabled, - }) - ) { + if (isClassDevice(user) && !cls.studentGradeQueryEnabled) throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩'); - } } /** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */ @@ -74,7 +69,7 @@ export async function collectiveAnalysis( user: TokenPayload, classId: string, ): Promise { - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'grade:read'); const rows = await prisma.grade.findMany({ where: { classId, student: activeStudentWhere() }, select: { @@ -139,12 +134,17 @@ export interface ListGradeOptions { * 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。 */ export async function listMyGrades(user: TokenPayload): Promise { + await assertPermissionOrClassDevice(user, 'grade:read'); if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); const studentIds = await resolvePersonalIds(user); if (studentIds.length === 0) return []; const grades = await prisma.grade.findMany({ - where: { studentId: { in: studentIds }, student: activeStudentWhere() }, + where: { + classId: user.classId ?? user.sub, + studentId: { in: studentIds }, + student: activeStudentWhere(), + }, include, orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], take: 1000, @@ -154,7 +154,7 @@ export async function listMyGrades(user: TokenPayload): Promise { /** 教师 / 管理员查看成绩(按可访问班级过滤) */ export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise { - 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); const grades = await prisma.grade.findMany({ where: { @@ -220,11 +220,11 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption /** * 录入一条成绩。 * - * 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩, + * 权限走 ``:**科任老师只能录自己任教科目**的成绩, * 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。 */ export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise { - await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩'); + await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩'); await assertStudentInClass(input.studentId, 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 { - 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); const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date(); @@ -292,8 +292,11 @@ export async function updateGrade( 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); - await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩'); + await assertContentWrite(user, 'grade:write', current.classId, nextCourseId, '修改成绩'); if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId); const totalScore = input.totalScore ?? current.totalScore; @@ -331,7 +334,7 @@ export async function updateGrade( export async function deleteGrade(user: TokenPayload, gradeId: string): Promise { const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } }); 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 emitGradeUpdated(current.classId, { @@ -369,7 +372,7 @@ export async function updateGradeLevels( // 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样) for (const item of input.items) { 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( @@ -387,7 +390,7 @@ export async function updateGradeLevels( // 整批重算 const classId = input.classId!; - await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级'); + await assertContentWrite(user, 'grade:write', classId, input.courseId ?? null, '调整成绩等级'); const levelType = input.levelType!; if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级'); const records = await prisma.grade.findMany({ @@ -433,7 +436,7 @@ export async function getStudentGradeDetail( requestedClassId?: string, ): Promise { 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({ where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) }, @@ -458,7 +461,7 @@ export async function getStudentGradeDetail( } const grades = await prisma.grade.findMany({ - where: { studentId: student.id }, + where: { studentId: student.id, ...classScopeWhere(scope) }, include, orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], take: 500, @@ -488,7 +491,7 @@ export async function getStudentGradeDetailById( }); if (!student || ('status' in student && !isActiveStudent(student as { status: string }))) throw ApiError.notFound('学生不存在'); - await assertClassAccess(user, student.classId ?? ''); + await assertClassAccess(user, student.classId ?? '', 'student:grade-query'); return getStudentGradeDetail(user, student.studentNo); } @@ -579,7 +582,8 @@ async function assertCourseInClass(courseId: string, classId: string): Promise { - await assertClassAccess(user, classId); + if (isClassDevice(user)) await assertDetailQuery(user, classId); + await assertClassAccess(user, classId, 'grade:read'); const [groups, exams] = await Promise.all([ prisma.grade.groupBy({ by: ['examName'], @@ -619,7 +623,7 @@ export async function setExamCountInTotal( name: string, countInTotal: boolean, ): Promise { - await assertClassAccess(user, classId); + await assertManage(user, 'grade:write', classId); if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径'); const row = await prisma.exam.upsert({ @@ -666,7 +670,7 @@ export async function getExamRanking( classId: string, examName: string, ): Promise { - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'grade:read'); const classRecord = await prisma.class.findUnique({ where: { id: classId }, select: { name: true, studentGradeQueryEnabled: true }, diff --git a/packages/server/src/modules/homeworks/homeworks.module.ts b/packages/server/src/modules/homeworks/homeworks.module.ts index 322e81c..e6f0edd 100644 --- a/packages/server/src/modules/homeworks/homeworks.module.ts +++ b/packages/server/src/modules/homeworks/homeworks.module.ts @@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; @@ -32,7 +32,7 @@ router.use(authenticate()); /** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */ router.get( '/', - requireStaffPermission('homework:read'), + requirePermissionOrClassDevice('homework:read'), validate({ query: listHomeworksQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -54,7 +54,7 @@ router.get( */ router.get( '/days', - requireStaffPermission('homework:read'), + requirePermissionOrClassDevice('homework:read'), validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -65,7 +65,7 @@ router.get( router.get( '/:id', - requireStaffPermission('homework:read'), + requirePermissionOrClassDevice('homework:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -82,7 +82,7 @@ router.get( */ router.post( '/', - requireStaffPermission('homework:write'), + requirePermissionOrClassDevice('homework:write'), validate({ body: createHomeworkSchema }), async (req, res) => { const user = getAuthUser(req); @@ -99,7 +99,7 @@ router.post( * * 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`; * 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。 - * 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 —— + * 每个班的科目归属依然由 createHomework 里的 逐个判定 —— * 批量不是绕过科目门的后门(§5 第 5 条)。 */ router.post( @@ -145,7 +145,7 @@ router.delete( /** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */ router.patch( '/:id/status', - requireStaffPermission('homework:write'), + requirePermissionOrClassDevice('homework:write'), validate({ params: idParamSchema, body: updateHomeworkStatusSchema }), async (req, res) => { const user = getAuthUser(req); @@ -162,7 +162,7 @@ router.patch( /** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */ router.get( '/:id/submissions', - requireStaffPermission('homework:read'), + requirePermissionOrClassDevice('homework:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -175,7 +175,7 @@ router.get( /** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */ router.patch( '/:id/submissions', - requireStaffPermission('homework:write'), + requirePermissionOrClassDevice('homework:write'), validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index 2f1e4a5..1882f98 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -1,4 +1,5 @@ -import { assertStaffPermission } from '../../lib/permissions.js'; +import { assertContentWrite } from '../../lib/authz/index.js'; +import { assertPermissionOrClassDevice } from '../../lib/permissions.js'; import { SOCKET_EVENTS, dayKeyLocal, @@ -12,13 +13,7 @@ import { type HomeworkSubmissionDto, type HomeworkSubmissionsDto, } from '@classhelper/shared'; -import { - assertCanManageSubjectContent, - assertClassAccess, - classScopeWhere, - isClassDevice, - resolveClassScope, -} from '../../lib/access.js'; +import { assertClassAccess, classScopeWhere, isClassDevice, resolveClassScope } from '../../lib/access.js'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; @@ -56,8 +51,8 @@ export async function listHomeworks( user: TokenPayload, options: ListHomeworkOptions, ): Promise { - await assertStaffPermission(user, 'homework:read'); - const scope = await resolveClassScope(user, options.classId); + await assertPermissionOrClassDevice(user, 'homework:read'); + const scope = await resolveClassScope(user, options.classId, 'homework:read'); const device = isClassDevice(user); // ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态" const personalIds = device ? await resolvePersonalIds(user) : []; @@ -97,8 +92,8 @@ export async function listHomeworkDays( user: TokenPayload, options: { classId?: string; from?: string; to?: string; days?: number }, ): Promise { - await assertStaffPermission(user, 'homework:read'); - const scope = await resolveClassScope(user, options.classId); + await assertPermissionOrClassDevice(user, 'homework:read'); + const scope = await resolveClassScope(user, options.classId, 'homework:read'); const today = dayKeyLocal(new Date()); const to = options.to ?? today; 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 { - await assertStaffPermission(user, 'homework:read'); + await assertPermissionOrClassDevice(user, 'homework:read'); const device = isClassDevice(user); const personalIds = device ? await resolvePersonalIds(user) : []; const homework = await prisma.homework.findUnique({ @@ -136,7 +131,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi }, }); if (!homework) throw ApiError.notFound('作业不存在'); - await assertClassAccess(user, homework.classId); + await assertClassAccess(user, homework.classId, 'homework:read'); return toHomeworkDto(homework, { studentIds: device ? personalIds : [], @@ -149,15 +144,15 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi /** * 发布作业并实时推送到班级房间。 * - * 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`): + * 权限走 ``(`allowClassDevice: true`): * - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了); * - **没选科目** → 本班班主任或管理员; * - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求, * 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。 */ export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise { - await assertStaffPermission(user, 'homework:write'); - await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', { + await assertPermissionOrClassDevice(user, 'homework:write'); + await assertContentWrite(user, 'homework:write', input.classId, input.courseId ?? null, '布置作业', { allowClassDevice: true, }); if (input.courseId) await assertCourseInClass(input.courseId, input.classId); @@ -212,7 +207,7 @@ export async function bulkCreateHomeworks( user: TokenPayload, input: BulkCreateHomeworkRequest, ): Promise { - await assertStaffPermission(user, 'homework:write'); + await assertPermissionOrClassDevice(user, 'homework:write'); const classIds = normalizeBulkClassIds(input.classIds); const results: BulkPublishClassResult[] = []; @@ -261,13 +256,16 @@ export async function updateHomework( homeworkId: string, input: UpdateHomeworkInput, ): Promise { - await assertStaffPermission(user, 'homework:write'); + await assertPermissionOrClassDevice(user, 'homework:write'); const current = await prisma.homework.findUnique({ where: { id: homeworkId } }); 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); - await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', { + await assertContentWrite(user, 'homework:write', current.classId, nextCourseId, '修改作业', { allowClassDevice: true, }); if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId); @@ -294,13 +292,13 @@ export async function updateHomework( } export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise { - await assertStaffPermission(user, 'homework:write'); + await assertPermissionOrClassDevice(user, 'homework:write'); const current = await prisma.homework.findUnique({ where: { id: homeworkId }, include: { course: courseSelect, creator: creatorSelect }, }); if (!current) throw ApiError.notFound('作业不存在'); - await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', { + await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '删除作业', { allowClassDevice: true, }); @@ -323,11 +321,13 @@ export async function updateHomeworkStatus( homeworkId: string, input: UpdateHomeworkStatusInput, ): Promise { - await assertStaffPermission(user, 'homework:write'); + await assertPermissionOrClassDevice(user, 'homework:write'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId } }); if (!homework) throw ApiError.notFound('作业不存在'); - await assertClassAccess(user, homework.classId); + await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', { + allowClassDevice: true, + }); if (isClassDevice(user)) { const studentIds = await resolvePersonalIds(user); @@ -417,13 +417,15 @@ export async function listHomeworkSubmissions( user: TokenPayload, homeworkId: string, ): Promise { - await assertStaffPermission(user, 'homework:read'); + await assertPermissionOrClassDevice(user, 'homework:write'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId }, - select: { id: true, classId: true }, + select: { id: true, classId: true, courseId: true }, }); if (!homework) throw ApiError.notFound('作业不存在'); - await assertClassAccess(user, homework.classId); + await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', { + allowClassDevice: true, + }); assertCanManageSubmissions(user); return buildSubmissions(homework.classId, homeworkId); } @@ -437,13 +439,15 @@ export async function updateHomeworkSubmissions( homeworkId: string, input: UpdateHomeworkSubmissionsInput, ): Promise { - await assertStaffPermission(user, 'homework:write'); + await assertPermissionOrClassDevice(user, 'homework:write'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId }, - select: { id: true, classId: true }, + select: { id: true, classId: true, courseId: true }, }); if (!homework) throw ApiError.notFound('作业不存在'); - await assertClassAccess(user, homework.classId); + await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', { + allowClassDevice: true, + }); assertCanManageSubmissions(user); const students = await prisma.student.findMany({ diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts index 9da905c..d05d767 100644 --- a/packages/server/src/modules/imports/imports.module.ts +++ b/packages/server/src/modules/imports/imports.module.ts @@ -6,7 +6,7 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, requireAnyPermission, } from '../../middleware/auth.js'; @@ -115,7 +115,7 @@ router.post( const input = validatedBody(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('请先选择要导入的班级'); } const result = await commitTable(getAuthUser(req), input); @@ -147,7 +147,7 @@ router.post( /** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */ router.get( '/time-layout', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: timeLayoutQuerySchema }), async (req, res) => { const { classId } = validatedQuery<{ classId: string }>(req); @@ -161,7 +161,7 @@ router.get( */ router.post( '/time-layout', - requirePermission('timetable:manage', 'import:schedule'), + requirePermission('schedule:write', 'import:schedule'), validate({ body: timeLayoutImportSchema }), async (req, res) => { const result = await importTimeLayout(getAuthUser(req), validatedBody(req)); @@ -178,7 +178,7 @@ router.post( /** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */ router.delete( '/time-layout/:id', - requirePermission('timetable:manage'), + requirePermission('import:schedule', 'schedule:write'), validate({ params: idParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts index ea9e9f0..ab2b4e1 100644 --- a/packages/server/src/modules/imports/table-import.service.ts +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -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 { 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 { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared'; 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 { logger } from '../../lib/logger.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'; /** @@ -362,7 +361,6 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput): * 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。 * 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。 */ - const classRole = await resolveUserClassRole(user, classId); const errors: ImportRowError[] = []; const warnings: string[] = []; @@ -425,21 +423,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput): warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`); // 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师) - const canWriteCourse = canManageSubjectContent({ - classRole, - hasSubject: Boolean(course), - isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false, - allowClassDevice: false, - }); - if (!canWriteCourse) { - errors.push({ - row: rowNumber, - message: course - ? `「${course.name}」不是你任教的科目,不能导入该科目的成绩` - : '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入', - }); - continue; - } + await assertContentWrite(user, 'grade:import', classId, course?.id ?? null, '导入成绩'); // 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算 const levelType = levelText ? 'custom' : 'percent'; @@ -521,6 +505,15 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput 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 warnings: string[] = []; let inserted = 0; @@ -580,19 +573,68 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput skipped += 1; continue; } - if (existing.classId !== classId) - await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); - await updateStudent(user, existing.id, { + if (existing.classId !== classId) await assertCanManageRoster(user, existing.classId ?? undefined); + operations.push({ + classId, + studentNo, name, - ...(gender ? { gender } : {}), - ...(guardianPhone ? { guardianPhone } : {}), + gender, + guardianPhone, + existingId: existing.id, + sourceClassId: existing.classId, }); updated += 1; } else { - await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); + operations.push({ + classId, + studentNo, + name, + gender, + guardianPhone, + existingId: null, + sourceClassId: null, + }); 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 = { kind: 'students', @@ -618,73 +660,94 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput */ export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise { await assertImportAllowed(user, undefined, 'teachers'); - const buffer = decodeBase64(input.contentBase64); - const parsed = parseTable(buffer, input.fileName); + assertAdmin(user); + return prisma.$transaction( + async (tx) => { + const buffer = decodeBase64(input.contentBase64); + const parsed = parseTable(buffer, input.fileName); - const errors: ImportRowError[] = []; - const warnings: string[] = []; - let inserted = 0; - let updated = 0; - let skipped = 0; + const errors: ImportRowError[] = []; + const warnings: string[] = []; + let inserted = 0; + let updated = 0; + let skipped = 0; - for (const [index, row] of parsed.rows.entries()) { - const rowNumber = index + 2; - const username = cellValue(row, parsed.columns, input.mapping, 'username'); - const name = cellValue(row, parsed.columns, input.mapping, 'name'); - const phone = cellValue(row, parsed.columns, input.mapping, 'phone'); - const password = cellValue(row, parsed.columns, input.mapping, 'password'); - const roleCell = cellValue(row, parsed.columns, input.mapping, 'role'); + for (const [index, row] of parsed.rows.entries()) { + const rowNumber = index + 2; + const username = cellValue(row, parsed.columns, input.mapping, 'username'); + const name = cellValue(row, parsed.columns, input.mapping, 'name'); + const phone = cellValue(row, parsed.columns, input.mapping, 'phone'); + const password = cellValue(row, parsed.columns, input.mapping, 'password'); + const roleCell = cellValue(row, parsed.columns, input.mapping, 'role'); - if (!username || !name) { - errors.push({ row: rowNumber, message: '工号与姓名都必须填写' }); - continue; - } - if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) { - errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` }); - continue; - } + if (!username || !name) { + errors.push({ row: rowNumber, message: '工号与姓名都必须填写' }); + continue; + } + if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) { + errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` }); + continue; + } - const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } }); - if (existing) { - if (input.mode === 'append') { - skipped += 1; - continue; + const existing = await tx.user.findUnique({ where: { username }, select: { id: true, role: true } }); + if (existing?.role === 'ADMIN') { + errors.push({ row: rowNumber, message: '管理员账号请通过教师账号管理修改' }); + 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({ - where: { id: existing.id }, - data: { name, role: nextRole, ...(phone ? { phone } : {}) }, - }); - updated += 1; - } else { - const initialPassword = password || env.defaultTeacherPassword; - await prisma.user.create({ + + 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}`, + ); + await tx.permissionAudit.create({ data: { - username, - name, - phone, - role: parseTeacherRole(roleCell) ?? 'TEACHER', - passwordHash: await hashPassword(initialPassword), + actorId: user.sub, + actorName: user.name, + action: 'import-commitTeachers', + targetType: 'import', + targetId: '', + beforeJson: 'null', + afterJson: JSON.stringify(result), }, }); - inserted += 1; - } - } - - 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; + }, + { timeout: 30000 }, ); - return result; } /* ------------------------------------------------------------------ 班级任课老师 */ @@ -705,160 +768,184 @@ export async function commitClassTeachers( input: TableCommitInput, ): Promise { await assertImportAllowed(user, undefined, 'classTeachers'); - const buffer = decodeBase64(input.contentBase64); - const parsed = parseTable(buffer, input.fileName); + assertAdmin(user); + 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 warnings: string[] = []; - let inserted = 0; - let updated = 0; - let skipped = 0; + const errors: ImportRowError[] = []; + const warnings: string[] = []; + let inserted = 0; + let updated = 0; + let skipped = 0; - const classes = await prisma.class.findMany({ - where: { archivedYearId: null }, - select: { id: true, name: true, teacherId: true }, - }); - const classByName = new Map(); - 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(); - - 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 }, + const classes = await tx.class.findMany({ + where: { archivedYearId: null }, + select: { id: true, name: true, teacherId: true }, }); - if (byNo) { - teacherId = byNo.id; + const classByName = new Map(); + 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'] } }, - 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; + + // 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号) + const createdTeachers = new Map(); + + 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; + } + + 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) { - if (!useTeacherNo) { - errors.push({ - row: rowNumber, - message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`, + 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}`, + ); + await tx.permissionAudit.create({ + data: { + actorId: user.sub, + actorName: user.name, + action: 'import-commitClassTeachers', + targetType: 'import', + targetId: '', + beforeJson: 'null', + afterJson: JSON.stringify(result), + }, }); - continue; - } - await assertPermission(user, 'teacher:manage'); - await assertPermission(user, 'import:teachers'); - const created = await prisma.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 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; + return result; + }, + { timeout: 30000 }, + ) + .then((result) => { + refreshClassSubscriptions(); + return result; + }); } export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise { @@ -890,19 +977,15 @@ async function assertImportAllowed( } as const; await assertPermission(user, 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 === 'classTeachers') return assertCanAssignTeachers(user, classId); - const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE'; - if (canImport(user.role, classRole, kind)) return; - if (kind === 'grades') { - throw ApiError.forbidden( - classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE' - ? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员' - : '只能导入自己担任班主任的班级的数据', - ); - } - throw ApiError.forbidden('该导入仅限管理员使用'); + if (kind === 'grades' && classId) return; + if (kind === 'scheduleTimes' && classId) return assertManage(user, 'import:schedule', classId); + throw ApiError.forbidden('请指定目标班级'); } /** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */ diff --git a/packages/server/src/modules/imports/time-layout.service.ts b/packages/server/src/modules/imports/time-layout.service.ts index 91e29a9..50fe1ad 100644 --- a/packages/server/src/modules/imports/time-layout.service.ts +++ b/packages/server/src/modules/imports/time-layout.service.ts @@ -324,7 +324,7 @@ export async function importTimeLayout( user: TokenPayload, input: TimeLayoutImportInput, ): 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); if (parsed.errors.length > 0) { @@ -380,7 +380,7 @@ export async function importTimeLayout( 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} 个时间点`); return { layout: toDto(layout), diff --git a/packages/server/src/modules/notifications/notifications.module.ts b/packages/server/src/modules/notifications/notifications.module.ts index 2674c1e..353a876 100644 --- a/packages/server/src/modules/notifications/notifications.module.ts +++ b/packages/server/src/modules/notifications/notifications.module.ts @@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, } from '../../middleware/auth.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= - 通知列表 */ router.get( '/', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ query: listNotificationsQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -40,7 +40,7 @@ router.get( /** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */ router.get( '/days', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ query: notificationArchiveQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -52,7 +52,7 @@ router.get( /** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */ router.get( '/creators', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ query: notificationArchiveQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -64,7 +64,7 @@ router.get( /** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */ router.get( '/unread-count', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ query: notificationScopeQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -76,7 +76,7 @@ router.get( /** POST /api/notifications/read-all - 全部标为已读 */ router.post( '/read-all', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ body: notificationScopeQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -88,7 +88,7 @@ router.post( /** POST /api/notifications - 发布通知(发布后广播 notification:new) */ router.post( '/', - requireStaffPermission('notification:publish'), + requirePermissionOrClassDevice('notification:publish'), validate({ body: createNotificationSchema }), async (req, res) => { const user = getAuthUser(req); @@ -100,7 +100,7 @@ router.post( /** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */ router.post( '/bulk', - requireStaffPermission('notification:publish'), + requirePermissionOrClassDevice('notification:publish'), validate({ body: bulkCreateNotificationSchema }), async (req, res) => { const user = getAuthUser(req); @@ -115,7 +115,7 @@ router.post( /** POST /api/notifications/:id/read - 标记已读 */ router.post( '/:id/read', - requireStaffPermission('notification:read'), + requirePermissionOrClassDevice('notification:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 050fd32..eddfd46 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -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 { SOCKET_EVENTS, @@ -12,12 +14,7 @@ import { type NotificationDto, } from '@classhelper/shared'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; -import { - assertClassAccess, - assertCanPublishNotification, - classScopeWhere, - resolveClassScope, -} from '../../lib/access.js'; +import { assertClassAccess, classScopeWhere, resolveClassScope } from '../../lib/access.js'; import { activeStudentWhere } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; @@ -73,8 +70,8 @@ export async function listNotifications( user: TokenPayload, options: ListNotificationOptions, ): Promise { - await assertStaffPermission(user, 'notification:read'); - const scope = await resolveClassScope(user, options.classId); + await assertPermissionOrClassDevice(user, 'notification:read'); + const scope = await resolveClassScope(user, options.classId, 'notification:read'); const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER'; // 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己 const personalIds = await resolvePersonalIds(user); @@ -112,8 +109,8 @@ export async function listNotificationDays( user: TokenPayload, options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string }, ): Promise { - await assertStaffPermission(user, 'notification:read'); - const scope = await resolveClassScope(user, options.classId); + await assertPermissionOrClassDevice(user, 'notification:read'); + const scope = await resolveClassScope(user, options.classId, 'notification:read'); const grouped = await prisma.notification.groupBy({ by: ['assignDate'], where: { ...classScopeWhere(scope), ...archiveWhere(options) }, @@ -133,8 +130,8 @@ export async function listNotificationCreators( user: TokenPayload, options: { classId?: string; priority?: string; date?: string; from?: string; to?: string }, ): Promise { - await assertStaffPermission(user, 'notification:read'); - const scope = await resolveClassScope(user, options.classId); + await assertPermissionOrClassDevice(user, 'notification:read'); + const scope = await resolveClassScope(user, options.classId, 'notification:read'); const grouped = await prisma.notification.groupBy({ by: ['createdBy'], where: { ...classScopeWhere(scope), ...archiveWhere(options) }, @@ -171,7 +168,7 @@ export async function bulkCreateNotifications( user: TokenPayload, input: BulkCreateNotificationRequest, ): Promise { - await assertStaffPermission(user, 'notification:publish'); + await assertPermissionOrClassDevice(user, 'notification:publish'); const classIds = normalizeBulkClassIds(input.classIds); const results: BulkPublishClassResult[] = []; @@ -208,8 +205,8 @@ export async function createNotification( user: TokenPayload, input: CreateNotificationInput, ): Promise { - await assertStaffPermission(user, 'notification:publish'); - await assertCanPublishNotification(user, input.classId); + await assertPermissionOrClassDevice(user, 'notification:publish'); + await assertPublish(user, 'notification:publish', input.classId, { allowClassDevice: true }); const priority = input.priority ?? 'NORMAL'; if (priority === 'URGENT' && input.confirmDuringClass !== true) { @@ -251,7 +248,9 @@ export async function deleteNotification(user: TokenPayload, notificationId: str await assertPermission(user, 'notification:delete'); const current = await prisma.notification.findUnique({ where: { id: notificationId } }); 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 } }); emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, { id: notificationId, @@ -267,10 +266,10 @@ export async function markAsRead( user: TokenPayload, notificationId: string, ): 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 } }); if (!notification) throw ApiError.notFound('通知不存在'); - await assertClassAccess(user, notification.classId); + await assertClassAccess(user, notification.classId, 'notification:read'); const personalIds = await resolvePersonalIds(user); // 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates, @@ -293,8 +292,8 @@ export async function markAsRead( /** 全部标为已读(可按班级限定) */ export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> { - await assertStaffPermission(user, 'notification:read'); - const scope = await resolveClassScope(user, classId); + await assertPermissionOrClassDevice(user, 'notification:read'); + const scope = await resolveClassScope(user, classId, 'notification:read'); const personalIds = await resolvePersonalIds(user); 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 { - await assertStaffPermission(user, 'notification:read'); - const scope = await resolveClassScope(user, classId); + await assertPermissionOrClassDevice(user, 'notification:read'); + const scope = await resolveClassScope(user, classId, 'notification:read'); const personalIds = await resolvePersonalIds(user); return prisma.notification.count({ where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } }, diff --git a/packages/server/src/modules/permissions/permissions.module.ts b/packages/server/src/modules/permissions/permissions.module.ts index d467312..37d5b88 100644 --- a/packages/server/src/modules/permissions/permissions.module.ts +++ b/packages/server/src/modules/permissions/permissions.module.ts @@ -1,9 +1,14 @@ import { z } from 'zod'; 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 { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; -import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; +import { authenticate, getAuthUser, requirePermission, requireAdmin } from '../../middleware/auth.js'; +import { + validateBadRequest as validate, + validatedBody, + validatedParams, + validatedQuery, +} from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { assignUserGroupsSchema, @@ -29,17 +34,19 @@ router.get('/me', async (req, res) => { sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功'); }); +router.use(requireAdmin()); + /** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */ -router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => { - sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功'); +router.get('/catalog', requirePermission('permission:manage'), async (req, res) => { + sendOk(res, await permissionService.permissionCatalog(getAuthUser(req)), '获取权限目录成功'); }); /** GET /api/permissions/groups - 权限组列表 */ -router.get('/groups', requirePermission('permission:manage'), async (_req, res) => { - sendOk(res, await permissionService.listGroups(), '获取权限组成功'); +router.get('/groups', requirePermission('permission:manage'), async (req, res) => { + 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()), ); router.get( @@ -53,14 +60,15 @@ router.get( }), async (req, res) => { 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( '/groups/:id/members', requirePermission('permission:manage'), 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 - 新建权限组 */ @@ -99,10 +107,29 @@ router.delete( ); /** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */ -router.get('/users', requirePermission('permission:manage'), async (_req, res) => { - sendOk(res, await permissionService.listMembers(), '获取成员成功'); +router.get('/users', requirePermission('permission:manage'), async (req, res) => { + 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 - 整体替换某账号的权限组 */ router.put( '/users/:id/groups', diff --git a/packages/server/src/modules/permissions/permissions.schemas.ts b/packages/server/src/modules/permissions/permissions.schemas.ts index 72d0cc6..15f2a09 100644 --- a/packages/server/src/modules/permissions/permissions.schemas.ts +++ b/packages/server/src/modules/permissions/permissions.schemas.ts @@ -22,7 +22,7 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 export const createPermissionGroupSchema = z.object({ name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'), 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(), keys: keysSchema.optional(), }); @@ -36,15 +36,33 @@ export const createPermissionGroupSchema = z.object({ export const updatePermissionGroupSchema = z.object({ name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').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(), keys: keysSchema.optional(), }); /** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */ -export const assignUserGroupsSchema = z.object({ - groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'), -}); +export const assignUserGroupsSchema = z + .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; export type UpdatePermissionGroupInput = z.infer; diff --git a/packages/server/src/modules/permissions/permissions.service.ts b/packages/server/src/modules/permissions/permissions.service.ts index b314bd6..2e7599a 100644 --- a/packages/server/src/modules/permissions/permissions.service.ts +++ b/packages/server/src/modules/permissions/permissions.service.ts @@ -1,165 +1,72 @@ +import { disconnectUserSessions } from '../../realtime/bus.js'; import { - PERMISSION_CATEGORIES, PERMISSION_KEYS, + PERMISSION_META, PERMISSION_LABELS, + PERMISSION_CATEGORIES, ROLE_DEFAULT_PERMISSIONS, + parsePermissionScope, + defaultPromotionPlan, + type PermissionKey, + type PermissionScope, + type PermissionGroupDto, + type PermissionMemberDto, + type PermissionCatalogDto, + type EffectivePermissionsDto, type AssignUserGroupsRequest, type CreatePermissionGroupRequest, - type MyPermissionsDto, - type PermissionCatalogDto, - type PermissionGroupDto, - type PermissionKey, - type PermissionMemberDto, type UpdatePermissionGroupRequest, - type UserRole, + type PromotionPlan, } from '@classhelper/shared'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; +import { ensureDefaultGroups } from '../../lib/permissions.js'; import { - parsePermissionScope, - defaultPromotionPlan, - type PermissionScope, - type PromotionPlan, -} from '@classhelper/shared'; -import { assertPermission } from '../../lib/permissions.js'; + assertAdmin, + invalidateAuthPrincipals, + loadAuthPrincipal, + principalForAssignments, + classIdsFor, + manageClassIdsFor, + actionScopesFor, + type AssignmentRow, +} from '../../lib/authz/index.js'; import { getSetting } from '../../lib/settings.js'; -import { - ADMIN_UNREMOVABLE_PERMISSIONS, - ensureDefaultGroups, - loadEffectivePermissions, -} from '../../lib/permissions.js'; -/** - * 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。 - * - * 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions` - * (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。 - */ - -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(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(); - 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 }) { +const include = { items: true, _count: { select: { users: true } } } as const; +type Group = Awaited>>[number]; +const known = (key: string): key is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(key); +const keysFor = (group: { items: { key: string }[] }) => group.items.map((i) => i.key).filter(known); +const activeKeys = (group: { items: { key: string }[] }) => + keysFor(group).filter((key) => PERMISSION_META[key].kind !== 'admin'); +function label(scope: string, scopeValue: string) { try { - return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel; + return parsePermissionScope(scope as PermissionScope, scopeValue).scopeLabel; } catch { return '未配置范围(无班级访问权)'; } } - -export async function scopeOptions() { - 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)); +function dto(group: Group): PermissionGroupDto { + const inactiveKeys = keysFor(group).filter((k) => PERMISSION_META[k].kind === 'admin'); return { - grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(), - planGrades, - divisions: plan.tracks.map((track) => ({ - name: track.name, - grades: track.stages.map((stage) => stage.grade), - })), + ...group, + keys: activeKeys(group), + scope: group.scope as PermissionScope, + scopeLabel: label(group.scope, group.scopeValue), + 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(), }; } - -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( +export function auditData( actor: TokenPayload, action: string, targetType: string, @@ -177,17 +84,34 @@ function auditData( afterJson: JSON.stringify(after), }; } - -export async function createGroup( - actor: TokenPayload, - input: CreatePermissionGroupRequest, -): Promise { - const scope = await validateScope(input); - const keys = normalizeKeys(input.keys); - await assertGrant(actor, { ...scope, keys }); - if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } })) - throw ApiError.conflict('权限组名称已存在'); - const created = await prisma.$transaction(async (tx) => { +export async function scopeOptions() { + 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((t) => t.stages.map((s) => s.grade)); + return { + grades: [...new Set([...classes.map((c) => c.grade), ...planGrades])].sort(), + planGrades, + divisions: plan.tracks.map((t) => ({ name: t.name, grades: t.stages.map((s) => s.grade) })), + }; +} +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({ data: { name: input.name.trim(), @@ -196,35 +120,34 @@ export async function createGroup( scopeValue: scope.scopeValue, items: { create: keys.map((key) => ({ key })) }, }, - select: GROUP_SELECT, + include, }); - await tx.permissionAudit.create({ - data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)), - }); - return row; + await tx.permissionAudit.create({ data: auditData(actor, 'create', 'group', row.id, null, dto(row)) }); + invalidateAuthPrincipals(); + return dto(row); }); - return toGroupDto(created); } - -export async function updateGroup( - actor: TokenPayload, - groupId: string, - input: UpdatePermissionGroupRequest, -): Promise { - const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT }); - if (!current) throw ApiError.notFound('权限组不存在'); - const before = toGroupDto(current); - await assertGrant(actor, { ...current, keys: before.keys }); - const scope = await validateScope({ - scope: input.scope ?? before.scope, - scopeValue: input.scopeValue ?? before.scopeValue, - }); - const keys = normalizeKeys(input.keys ?? before.keys); - await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys }); - const updated = await prisma.$transaction(async (tx) => { - await tx.permissionGroupItem.deleteMany({ where: { groupId } }); +export async function updateGroup(actor: TokenPayload, id: string, input: UpdatePermissionGroupRequest) { + assertAdmin(actor); + const current = await prisma.permissionGroup.findUnique({ where: { id }, include }); + if (!current || current.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在'); + const scope = await validateScope( + input.scope ?? (current.scope as PermissionScope), + input.scopeValue ?? current.scopeValue, + ); + if (current.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING') + throw ApiError.badRequest('教师默认模板固定使用任教班级范围'); + // 旧管理员键仅停用;编辑可分配能力时保留它们,不能通过写入口重新授予。 + const inactiveKeys = keysFor(current).filter((key) => PERMISSION_META[key].kind === 'admin'); + const keys = + input.keys === undefined + ? current.items.map((i) => i.key) + : [...new Set([...validateKeys(input.keys), ...inactiveKeys])]; + let affected: string[] = []; + const result = await prisma.$transaction(async (tx) => { + await tx.permissionGroupItem.deleteMany({ where: { groupId: id } }); const row = await tx.permissionGroup.update({ - where: { id: groupId }, + where: { id }, data: { name: input.name?.trim(), description: input.description?.trim(), @@ -232,120 +155,204 @@ export async function updateGroup( scopeValue: scope.scopeValue, 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 { + 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 { + 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 { + 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({ data: auditData( actor, - current.defaultRole ? 'update-default' : 'update', - 'group', - groupId, - before, - toGroupDto(row), + 'assign', + 'user', + userId, + before.assignments, + 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 deleteGroup(actor: TokenPayload, groupId: string) { - 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 { - 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 { - 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) { +export async function listAudits(actor: TokenPayload, page = 1, pageSize = 30) { + assertAdmin(actor); const [items, total] = await Promise.all([ prisma.permissionAudit.findMany({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], @@ -356,71 +363,68 @@ export async function listAudits(page = 1, pageSize = 30) { ]); return { items, total, page, pageSize }; } - -/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */ -export async function permissionCatalog(): Promise { +export async function permissionCatalog(actor: TokenPayload): Promise { + assertAdmin(actor); await ensureDefaultGroups(); - const defaults = await prisma.permissionGroup.findMany({ - where: { defaultRole: { not: null } }, + const group = await prisma.permissionGroup.findUniqueOrThrow({ + where: { defaultRole: 'TEACHER' }, 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 { keys: [...PERMISSION_KEYS], - labels: { ...PERMISSION_LABELS }, - categories: PERMISSION_CATEGORIES.map((category) => ({ - title: category.title, - keys: [...category.keys], - })), + meta: PERMISSION_META, + labels: PERMISSION_LABELS, + categories: PERMISSION_CATEGORIES.map((c) => ({ title: c.title, keys: [...c.keys] })), defaultRoles: Object.fromEntries( - PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]), - ) as Record, - roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record< - UserRole, - PermissionKey[] - >, - unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS], + PERMISSION_KEYS.map((k) => [k, [...PERMISSION_META[k].defaultRoles]]), + ) as PermissionCatalogDto['defaultRoles'], + roleDefaults: { + ...ROLE_DEFAULT_PERMISSIONS, + ADMIN: [...PERMISSION_KEYS], + TEACHER: activeKeys(group), + CLASS_DEVICE: [], + }, + unremovable: [...PERMISSION_KEYS], }; } - -/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */ -export async function listGroups(): Promise { +export async function listGroups(actor: TokenPayload): Promise { + assertAdmin(actor); await ensureDefaultGroups(); - const rows = await prisma.permissionGroup.findMany({ - select: GROUP_SELECT, - orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }], - }); - const members = await listMembers(); - return (rows as GroupRow[]).map((row) => ({ - ...toGroupDto(row), - memberCount: members.filter((m) => - m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role, + const members = await listMembers(actor); + return ( + await prisma.permissionGroup.findMany({ + where: { OR: [{ defaultRole: null }, { defaultRole: 'TEACHER' }] }, + include, + orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }], + }) + ).map((group) => ({ + ...dto(group), + memberCount: members.filter( + (m) => + !m.fixed && + (m.groupIds.includes(group.id) || (group.defaultRole === 'TEACHER' && !m.assignments.length)), ).length, })); } - -/** - * GET /api/permissions/me - 当前账号的生效权限。 - * - * 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。 - * 班级端会话恒为空集:它的能力由班级维度断言决定。 - */ -export async function myPermissions(user: TokenPayload): Promise { - const permissions = await loadEffectivePermissions(user); - const fromGroups = - user.classSession || user.role === 'CLASS_DEVICE' - ? false - : (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0; - +export async function myPermissions(user: TokenPayload) { + const auth = await loadAuthPrincipal(user); + 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([ + Promise.all(classKeys.map(async (key) => [key, await classIdsFor(user, key, auth)] as const)), + Promise.all(managementKeys.map(async (key) => [key, await manageClassIdsFor(user, key, auth)] as const)), + ]); return { role: user.role, - permissions: [...permissions].sort(byCatalogOrder), - fromGroups, + permissions: [...auth.permissions], + 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), }; } diff --git a/packages/server/src/modules/schedules/schedules.facade.ts b/packages/server/src/modules/schedules/schedules.facade.ts index 9f4fe70..3fc328d 100644 --- a/packages/server/src/modules/schedules/schedules.facade.ts +++ b/packages/server/src/modules/schedules/schedules.facade.ts @@ -40,8 +40,8 @@ export async function listSchedules( user: TokenPayload, options: ListScheduleOptions, ): Promise { - if (options.classId) await assertClassAccess(user, options.classId); - const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user); + if (options.classId) await assertClassAccess(user, options.classId, 'schedule:read'); + const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user, 'schedule:read'); const result: ScheduleDto[] = []; for (const classId of classIds) { if (options.week) { @@ -98,7 +98,7 @@ export async function getActiveScheduleWeek( ) { const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; if (!classId) throw ApiError.badRequest('请指定班级'); - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'schedule:read'); const context = await loadTermContext(classId), week = options.week ?? resolveCurrentWeek(new Date(), context); 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 } = {}) { const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; if (!classId) throw ApiError.badRequest('请指定班级'); - await assertClassAccess(user, classId); + await assertClassAccess(user, classId, 'schedule:read'); 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) ); } -async function assertCourse(courseId: string, classId: string) { - if (!(await prisma.course.findFirst({ where: { id: courseId, classId } }))) +async function assertCourse(courseId: string, classId: string, db: Tx = prisma) { + if (!(await db.course.findFirst({ where: { id: courseId, classId } }))) throw ApiError.badRequest('课程不属于本班'); } export async function createSchedule( @@ -161,10 +161,8 @@ export async function createSchedule( transaction?: Tx, identity?: string, ): Promise { - if (!transaction) { - await assertCanManageSchedule(user, input.classId); - await assertCourse(input.courseId, input.classId); - } + await assertCanManageSchedule(user, input.classId, transaction); + await assertCourse(input.courseId, input.classId, transaction); const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }), max = transaction ? normalizeMaxWeeks( @@ -282,6 +280,8 @@ export async function updateSchedule( const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored ? 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; await assertCanManageSchedule(user, classId); if (input.courseId) await assertCourse(input.courseId, classId); diff --git a/packages/server/src/modules/schedules/schedules.module.ts b/packages/server/src/modules/schedules/schedules.module.ts index a30d38b..f60be3d 100644 --- a/packages/server/src/modules/schedules/schedules.module.ts +++ b/packages/server/src/modules/schedules/schedules.module.ts @@ -10,7 +10,7 @@ import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, - requireStaffPermission, + requirePermissionOrClassDevice, requirePermission, } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; @@ -30,18 +30,18 @@ const router = Router(); router.use(authenticate()); router.get( '/active-week', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: gridQuerySchema }), async (req, res) => sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))), ); router.get( '/scheduled', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { const { classId } = validatedQuery<{ classId: string }>(req); - await assertClassAccess(getAuthUser(req), classId); + await assertClassAccess(getAuthUser(req), classId, 'schedule:read'); sendOk( res, await prisma.scheduledClassPlan.findMany({ @@ -109,7 +109,7 @@ router.post( /** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */ router.get( '/', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: listSchedulesQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -121,7 +121,7 @@ router.get( /** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */ router.get( '/grid', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: gridQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -145,7 +145,7 @@ router.get( */ router.get( '/current', - requireStaffPermission('schedule:read'), + requirePermissionOrClassDevice('schedule:read'), validate({ query: classStatusQuerySchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/students/students.service.ts b/packages/server/src/modules/students/students.service.ts index 759c211..f105b85 100644 --- a/packages/server/src/modules/students/students.service.ts +++ b/packages/server/src/modules/students/students.service.ts @@ -8,11 +8,11 @@ import { getAccessibleClassIds, resolveClassScope, } 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 { activeStudentWhere } from '../../lib/students.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 type { TokenPayload } from '../../lib/jwt.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 { - await assertStaffPermission(user, 'roster:read'); + await assertPermissionOrClassDevice(user, 'roster:read'); const scope = await resolveClassScope(user, options.classId, 'roster:read'); const students = await prisma.student.findMany({ @@ -219,16 +219,16 @@ export async function listTransfers( options: ListTransfersQuery, ): Promise> { 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) { const student = await prisma.student.findUnique({ where: { id: options.studentId }, select: { classId: true }, }); 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('无权查看未分班学生的调班历史'); } diff --git a/packages/server/src/modules/system/system.module.ts b/packages/server/src/modules/system/system.module.ts index 30d8e9b..ebf2111 100644 --- a/packages/server/src/modules/system/system.module.ts +++ b/packages/server/src/modules/system/system.module.ts @@ -1,3 +1,4 @@ +import { requireAdmin } from '../../middleware/auth.js'; import { Router } from 'express'; import { API_PATHS } from '@classhelper/shared'; import { sendOk } from '../../lib/http.js'; @@ -13,7 +14,7 @@ import { import * as systemService from './system.service.js'; const router = Router(); -router.use(authenticate()); +router.use(authenticate(), requireAdmin()); /** * 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。 @@ -46,7 +47,7 @@ router.put( validate({ body: updateAutostartSchema }), async (req, res) => { const input = validatedBody(req); - sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效'); + sendOk(res, await systemService.updateAutostart(getAuthUser(req), input), '开机自启设置已生效'); }, ); diff --git a/packages/server/src/modules/system/system.service.ts b/packages/server/src/modules/system/system.service.ts index 5b6db69..b9d2492 100644 --- a/packages/server/src/modules/system/system.service.ts +++ b/packages/server/src/modules/system/system.service.ts @@ -1,3 +1,5 @@ +import { assertAdmin } from '../../lib/authz/index.js'; +import type { TokenPayload } from '../../lib/jwt.js'; import { createAutostartController } from './autostart.js'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -10,7 +12,6 @@ import { } from '@classhelper/shared'; import { env } from '../../config/env.js'; import { prisma } from '../../lib/db.js'; -import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js'; import { APP_VERSION } from '../../lib/version.js'; @@ -39,6 +40,7 @@ export async function updateSystemSettings( user: TokenPayload, input: UpdateSystemSettingsInput, ): Promise { + assertAdmin(user); if (input.developerMode !== undefined) { await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode); logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`); @@ -55,7 +57,11 @@ const autostart = createAutostartController({ export async function getAutostartStatus(): Promise { return autostart.read(); } -export async function updateAutostart(input: UpdateAutostartInput): Promise { +export async function updateAutostart( + actor: TokenPayload, + input: UpdateAutostartInput, +): Promise { + assertAdmin(actor); return autostart.write(input.enabled); } diff --git a/packages/server/src/modules/teachers/teachers.module.ts b/packages/server/src/modules/teachers/teachers.module.ts index fb96f7f..bb8f2b1 100644 --- a/packages/server/src/modules/teachers/teachers.module.ts +++ b/packages/server/src/modules/teachers/teachers.module.ts @@ -1,7 +1,13 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.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 { defineModule } from '../module.types.js'; import { @@ -27,39 +33,50 @@ router.get( validate({ query: listTeachersQuerySchema }), async (req, res) => { 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) */ router.post( '/', + requireAdmin(), requirePermission('teacher:manage'), validate({ body: createTeacherSchema }), 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 - 编辑教师(姓名 / 用户名 / 角色) */ router.patch( '/:id', + requireAdmin(), requirePermission('teacher:manage'), validate({ params: idParamSchema, body: updateTeacherSchema }), async (req, res) => { 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 - 重置教师密码 */ router.post( '/:id/reset-password', + requireAdmin(), requirePermission('teacher:manage'), validate({ params: idParamSchema, body: resetTeacherPasswordSchema }), async (req, res) => { 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 }, '密码已重置'); }, ); @@ -67,11 +84,12 @@ router.post( /** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */ router.delete( '/:id', + requireAdmin(), requirePermission('teacher:manage'), validate({ params: idParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); - await teacherService.deleteTeacher(id); + await teacherService.deleteTeacher(getAuthUser(req), id); sendOk(res, { id }, '教师账号已删除'); }, ); diff --git a/packages/server/src/modules/teachers/teachers.service.ts b/packages/server/src/modules/teachers/teachers.service.ts index 40a897d..189bea0 100644 --- a/packages/server/src/modules/teachers/teachers.service.ts +++ b/packages/server/src/modules/teachers/teachers.service.ts @@ -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 { env } from '../../config/env.js'; import { prisma } from '../../lib/db.js'; @@ -17,7 +22,8 @@ import type { */ /** 教师列表(教师 + 管理员账号) */ -export async function listTeachers(keyword?: string): Promise { +export async function listTeachers(actor: TokenPayload, keyword?: string): Promise { + await assertPermission(actor, 'teacher:read'); const teachers = await prisma.user.findMany({ where: { role: { in: ['TEACHER', 'ADMIN'] }, @@ -29,7 +35,8 @@ export async function listTeachers(keyword?: string): Promise { return teachers.map(toUserDto); } -export async function createTeacher(input: CreateTeacherInput): Promise { +export async function createTeacher(actor: TokenPayload, input: CreateTeacherInput): Promise { + assertAdmin(actor); const created = await prisma.user.create({ data: { username: input.username, @@ -38,6 +45,12 @@ export async function createTeacher(input: CreateTeacherInput): Promise 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); } @@ -53,29 +66,50 @@ async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role return teacher; } -export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise { +export async function updateTeacher( + actor: TokenPayload, + teacherId: string, + input: UpdateTeacherInput, +): Promise { + assertAdmin(actor); const teacher = await findTeacherOrThrow(teacherId); - const updated = await prisma.user.update({ - where: { id: teacher.id }, - data: { - ...(input.name ? { name: input.name } : {}), - ...(input.username ? { username: input.username } : {}), - ...(input.role ? { role: input.role } : {}), - }, + const updated = await prisma.$transaction(async (tx) => { + if (teacher.role === 'ADMIN' && input.role && input.role !== 'ADMIN') await protectLastAdmin(tx); + const result = await tx.user.update({ + where: { id: teacher.id }, + data: { + ...(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); } /** 重置教师密码(未指定时用默认初始密码) */ export async function resetTeacherPassword( + actor: TokenPayload, teacherId: string, input: ResetTeacherPasswordInput, ): Promise { + assertAdmin(actor); const teacher = await findTeacherOrThrow(teacherId); await prisma.user.update({ where: { id: teacher.id }, 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 让管理员先转移/清理。 */ -export async function deleteTeacher(userId: string): Promise { +export async function deleteTeacher(actor: TokenPayload, userId: string): Promise { + assertAdmin(actor); 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([ prisma.class.count({ where: { teacherId: teacher.id } }), @@ -128,7 +165,21 @@ export async function deleteTeacher(userId: string): Promise { ); } - 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('不能删除或降级最后一个管理员'); } diff --git a/packages/server/src/modules/term/promotion.service.ts b/packages/server/src/modules/term/promotion.service.ts index 283538d..97a32f2 100644 --- a/packages/server/src/modules/term/promotion.service.ts +++ b/packages/server/src/modules/term/promotion.service.ts @@ -1,3 +1,4 @@ +import { refreshClassSubscriptions } from '../../realtime/bus.js'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -441,6 +442,7 @@ async function executePromotion(year: number, options: RunOptions) { for (const track of preview.tracks) for (const cls of track.classes) emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' }); + refreshClassSubscriptions(); await notifyScheduleChanged(); return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false }; } diff --git a/packages/server/src/modules/term/term.module.ts b/packages/server/src/modules/term/term.module.ts index cd600ab..4866055 100644 --- a/packages/server/src/modules/term/term.module.ts +++ b/packages/server/src/modules/term/term.module.ts @@ -9,7 +9,13 @@ import { } from './promotion.service.js'; import { Router } from 'express'; 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 { defineModule } from '../module.types.js'; import { @@ -74,16 +80,21 @@ router.post( * - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位; * - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。 */ -router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = validatedQuery<{ classId?: string }>(req); - sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功'); -}); +router.get( + '/', + requirePermissionOrClassDevice('term:read'), + 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 - 保存逐周区间(整表覆盖,仅管理员) */ router.put( '/', - requirePermission('term:write'), + requireAnyPermission('term:write', 'class:settings'), validate({ body: updateTermWeeksSchema }), async (req, res) => { const user = getAuthUser(req); @@ -102,7 +113,7 @@ router.put( */ router.post( '/auto', - requirePermission('term:write'), + requireAnyPermission('term:write', 'class:settings'), validate({ body: autoTermWeeksSchema }), async (req, res) => { const user = getAuthUser(req); @@ -121,7 +132,7 @@ router.post( */ router.get( '/holidays', - requirePermission('term:write'), + requireAnyPermission('term:write', 'class:settings'), validate({ query: holidayQuerySchema }), async (req, res) => { const { year } = validatedQuery<{ year?: number }>(req); diff --git a/packages/server/src/modules/term/term.service.ts b/packages/server/src/modules/term/term.service.ts index 099145e..3b89c06 100644 --- a/packages/server/src/modules/term/term.service.ts +++ b/packages/server/src/modules/term/term.service.ts @@ -1,3 +1,4 @@ +import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js'; import { buildAutoTermWeeks, validateTermWeeks, @@ -38,9 +39,15 @@ function scopeOf(classId?: string | null): string { /** 读取某个范围的周次配置 */ export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise { - const scope = scopeOf(classId); - if (scope) await assertClassAccess(user, scope); + const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId); + 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 { const [weeks] = await Promise.all([ prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }), scope @@ -75,8 +82,9 @@ export async function updateTermWeeks( user: TokenPayload, input: UpdateTermWeeksInput, ): Promise { - await assertPermission(user, 'term:write'); const scope = scopeOf(input.classId); + if (scope) await assertHeadOnly(user, 'class:settings', scope); + else assertAdmin(user); const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks()); if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改'); @@ -132,7 +140,7 @@ export async function updateTermWeeks( logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`); if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum)); 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 { - await assertPermission(user, 'term:write'); const scope = scopeOf(input.classId); + if (scope) await assertHeadOnly(user, 'class:settings', scope); + else assertAdmin(user); const context = await loadTermContext(scope || null); const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate; const maxWeek = input.maxWeek ?? context.maxWeek; diff --git a/packages/server/src/modules/update/client-releases.service.ts b/packages/server/src/modules/update/client-releases.service.ts index 42b865f..8535cec 100644 --- a/packages/server/src/modules/update/client-releases.service.ts +++ b/packages/server/src/modules/update/client-releases.service.ts @@ -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 { Transform } from 'node:stream'; import crypto from 'node:crypto'; @@ -54,10 +56,14 @@ function releaseRoot(): 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); } 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); } @@ -128,9 +134,11 @@ export async function resolveReleaseFile( /** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */ export async function registerRelease( - userId: string, + actor: TokenPayload, input: RegisterClientReleaseInput, ): Promise { + assertAdmin(actor); + const userId = actor.sub; const version = input.version; // 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致) const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null; @@ -171,11 +179,12 @@ export async function registerRelease( /** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */ export async function fetchReleaseFromGithub( - userId: string, + actor: TokenPayload, input: FetchClientReleaseInput, ): Promise { if (['downloading', 'verifying'].includes(getReleaseProgress().stage)) throw ApiError.conflict('已有安装包正在下载'); + assertAdmin(actor); const version = input.version; setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' }); const fileName = clientReleaseFileName(version); @@ -221,13 +230,14 @@ export async function fetchReleaseFromGithub( // 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算 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 }); 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; if (!row) throw ApiError.notFound('服务端没有这个版本的安装包'); await prisma.clientRelease.delete({ where: { version } }); diff --git a/packages/server/src/modules/update/update.module.ts b/packages/server/src/modules/update/update.module.ts index c2b9f1e..22b5260 100644 --- a/packages/server/src/modules/update/update.module.ts +++ b/packages/server/src/modules/update/update.module.ts @@ -1,3 +1,4 @@ +import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js'; import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; import { Router } from 'express'; import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; @@ -7,9 +8,10 @@ import { hasAuthPermission, requireAnyPermission, requirePermission, - requireStaffPermission, + requireAdmin, + requirePermissionOrClassDevice, } 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 * as clientReleases from './client-releases.service.js'; import * as updateService from './update.service.js'; @@ -39,7 +41,7 @@ router.get('/client/progress', requirePermission('client:update'), async (_req, * 权限:任何已登录用户都能查(信息本身来自公开仓库)。`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'); sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成'); }); @@ -68,13 +70,14 @@ router.get( */ router.post( '/client/fetch', + requireAdmin(), requirePermission('client:update'), validate({ body: fetchClientReleaseSchema }), async (req, res) => { const user = getAuthUser(req); const input = validatedBody(req); try { - sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); + sendCreated(res, await clientReleases.fetchReleaseFromGithub(user, input), '安装包已下载并登记'); } catch (error) { if ( !(error instanceof ApiError && error.status === 409) && @@ -97,23 +100,25 @@ router.post( */ router.post( '/client/releases', + requireAdmin(), requirePermission('client:update'), validate({ body: registerClientReleaseSchema }), async (req, res) => { const user = getAuthUser(req); const input = validatedBody(req); - sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记'); + sendCreated(res, await clientReleases.registerRelease(user, input), '安装包已登记'); }, ); /** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */ router.delete( '/client/releases/:version', + requireAdmin(), requirePermission('client:update'), validate({ params: clientReleaseVersionParamSchema }), async (req, res) => { 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', validate({ params: clientReleaseVersionParamSchema }), 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 found = await clientReleases.resolveReleaseFile(version); if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包'); diff --git a/packages/server/src/modules/update/update.schemas.ts b/packages/server/src/modules/update/update.schemas.ts index 2455123..740c426 100644 --- a/packages/server/src/modules/update/update.schemas.ts +++ b/packages/server/src/modules/update/update.schemas.ts @@ -13,7 +13,7 @@ export const clientReleaseVersionSchema = z .trim() .min(1, '请输入版本号') .max(40, '版本号最多 40 个字符') - .refine((value) => SAFE_SEGMENT.test(value), { + .refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), { message: '版本号只能包含字母、数字、点、下划线与短横线', }); @@ -30,7 +30,9 @@ export const registerClientReleaseSchema = z.object({ .trim() .min(1) .max(120) - .refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' }) + .refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), { + message: '文件名只能包含字母、数字、点、下划线与短横线', + }) .optional(), notes: z.string().trim().max(500).optional(), }); diff --git a/packages/server/src/realtime/bus.ts b/packages/server/src/realtime/bus.ts index 0e62240..ecee0e9 100644 --- a/packages/server/src/realtime/bus.ts +++ b/packages/server/src/realtime/bus.ts @@ -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}) */ 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); } /** diff --git a/packages/server/src/realtime/socket.ts b/packages/server/src/realtime/socket.ts index 768dd31..36686db 100644 --- a/packages/server/src/realtime/socket.ts +++ b/packages/server/src/realtime/socket.ts @@ -3,36 +3,42 @@ import { Server } from 'socket.io'; import { SOCKET_EVENTS, SOCKET_ROOMS, + SOCKET_CHANNELS, + type SocketChannel, + type PermissionKey, type ClientToServerEvents, type ServerToClientEvents, type SocketAuthPayload, - type UserRole, } from '@classhelper/shared'; import { env } from '../config/env.js'; -import { getAccessibleClassIds } from '../lib/access.js'; -import { verifyToken } from '../lib/jwt.js'; +import { verifyToken, type TokenPayload } 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 { registerEmitter, type RealtimeEmitter } from './bus.js'; +import { registerEmitter, registerSessionInvalidator } from './bus.js'; interface SocketData { - user: { - id: string; - username: string; - name: string; - role: UserRole; - classId: string | null; - classSession: boolean; - }; + user: TokenPayload; + token: string; } - export type RealtimeServer = Server< ClientToServerEvents, ServerToClientEvents, Record, SocketData >; - -/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */ +const channelKeys: Record = { + 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> { + 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 { const io: RealtimeServer = new Server(httpServer, { cors: { origin: env.corsOrigins, credentials: true }, @@ -40,110 +46,92 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer { pingInterval: 25000, pingTimeout: 20000, }); - - // 握手鉴权:客户端通过 handshake.auth.token 传入 JWT io.use((socket, next) => { - try { - const auth = (socket.handshake.auth ?? {}) as Partial; - const queryToken = - typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null; - const token = auth.token ?? queryToken; - if (!token) { - next(new Error('缺少认证 token')); - return; + void (async () => { + try { + const auth = socket.handshake.auth as Partial; + const token = + auth.token ?? + (typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : ''); + const user = await loadSessionUser(verifyToken(token)); + 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) => { const user = socket.data.user; - void (async () => { - const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)]; - - try { - const classIds = await getAccessibleClassIds({ - sub: user.id, - username: user.username, - name: user.name, - role: user.role, - classId: user.classId, - classSession: user.classSession, - }); - for (const classId of classIds) { - await socket.join(SOCKET_ROOMS.class(classId)); - rooms.push(SOCKET_ROOMS.class(classId)); - } - } catch (error) { - logger.warn(`加入班级房间失败(user=${user.id})`, error); - } - - // ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers - 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) => { + // 手动订阅每次回查账号与权限;不沿用握手时的权限快照。 + const ready = (async () => { + const base = [ + SOCKET_ROOMS.session(user.sub), + SOCKET_ROOMS.role(user.role), + user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers, + ]; + await socket.join(base); + for (const channel of SOCKET_CHANNELS) + for (const id of await channelClasses(user, channel)) + await socket.join(SOCKET_ROOMS.classChannel(id, channel)); + socket.emit(SOCKET_EVENTS.connected, { + sessionId: user.sub, + role: user.role, + rooms: [...socket.rooms].filter((r) => r !== socket.id), + }); + })().catch((error) => { + logger.warn('实时订阅失败', error); + socket.disconnect(true); + }); + socket.on('class:join', (request, ack) => { void (async () => { + await ready; try { - const allowed = await getAccessibleClassIds({ - sub: user.id, - username: user.username, - name: user.name, - role: user.role, - classId: user.classId, - classSession: user.classSession, - }); - if (!allowed.includes(classId)) { - ack?.({ ok: false, message: '无权订阅该班级' }); + const current = await loadSessionUser(verifyToken(socket.data.token)); + const classId = typeof request === 'string' ? request : request.classId; + const requested = + typeof request === 'string' ? SOCKET_CHANNELS : (request.channels ?? SOCKET_CHANNELS); + if (!requested.length || requested.some((c) => !SOCKET_CHANNELS.includes(c))) { + ack?.({ ok: false, message: '未知事件通道' }); 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 }); } catch { ack?.({ ok: false, message: '订阅失败' }); } })(); }); - - socket.on('class:leave', (classId) => { - void socket.leave(SOCKET_ROOMS.class(classId)); - }); - - socket.on('ping', (ack) => { - ack?.(); - }); - - socket.on('disconnect', (reason) => { - logger.debug(`实时连接断开:${user.name} (${reason})`); + socket.on('class:leave', (request) => { + void (async () => { + await ready; + const classId = typeof request === 'string' ? request : request.classId; + for (const channel of typeof request === 'string' + ? SOCKET_CHANNELS + : (request.channels ?? SOCKET_CHANNELS)) + await socket.leave(SOCKET_ROOMS.classChannel(classId, channel)); + })(); }); + 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; } diff --git a/packages/shared/src/class-plan.ts b/packages/shared/src/class-plan.ts index abdadec..71419a4 100644 --- a/packages/shared/src/class-plan.ts +++ b/packages/shared/src/class-plan.ts @@ -89,7 +89,7 @@ export function getMaxTermWeeks(configured?: unknown): number { 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 { version: string; stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed'; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 49e9472..8d04602 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -493,6 +493,22 @@ export const SOCKET_EVENTS = { connected: 'connected', } 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 = { + '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 一致) */ export const CLASSISLAND_TIME_TYPES = { class: 0, @@ -560,6 +576,8 @@ export function buildCallTitle(studentName: string, teacherName: string): string /** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */ export const SOCKET_ROOMS = { + classChannel: (classId: string, channel: SocketChannel): string => `class:${classId}:${channel}`, + /** @deprecated 仅供旧协议测试识别,生产不再订阅。 */ class: (classId: string): string => `class:${classId}`, /** * 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。 diff --git a/packages/shared/src/lifecycle.ts b/packages/shared/src/lifecycle.ts index 2544e1f..a7c95ae 100644 --- a/packages/shared/src/lifecycle.ts +++ b/packages/shared/src/lifecycle.ts @@ -2,14 +2,13 @@ import type { PermissionScope } from './class-plan.js'; /** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */ export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) { - if (scope === 'ALL') return { scope, scopeValue: '', grades: [], scopeLabel: '全校' }; - if (scope === 'SELF_CLASS') { - if (value !== '' && value !== 'HEAD') throw new Error('关联班级范围只允许全部关联班级或仅班主任班'); + if (scope === 'ALL' || scope === 'TEACHING' || scope === 'HEAD') { + if (value !== '') throw new Error('关联班级或全校范围不接受范围值'); return { scope, - scopeValue: value, + scopeValue: '', grades: [], - scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级', + scopeLabel: scope === 'ALL' ? '全校' : scope === 'HEAD' ? '仅班主任班' : '班主任与任课班级', }; } if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围'); @@ -50,3 +49,10 @@ export function defaultPromotionPlan(classCount = 1): PromotionPlan { resetTermWeeks: true, }; } + +/** 仅迁移和旧快照恢复使用;运行时不接受旧枚举。 */ +export function migratePermissionScope(scope: string, scopeValue = '') { + return scope === 'SELF_CLASS' + ? { scope: scopeValue === 'HEAD' ? 'HEAD' : 'TEACHING', scopeValue: '' } + : { scope, scopeValue }; +} diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index d596cdf..348a577 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -1,4 +1,5 @@ import type { UserRole } from './types.js'; +import type { PermissionScope } from './class-plan.js'; /** * 班级内的角色(相对"某一个班级"而言)。 @@ -168,6 +169,8 @@ export const PERMISSION_KEYS = [ // 班级与人员 'class:read', 'class:manage', + 'class:account', + 'class:settings', 'roster:read', 'roster:manage', 'teacher:read', @@ -243,11 +246,22 @@ export const BUILTIN_TEACHER_GROUPS = [ { id: 'builtin-head', name: '班主任', - scope: 'SELF_CLASS', - scopeValue: 'HEAD', - keys: [...TEACHING_KEYS, 'client:read', 'client:manage'], + scope: 'HEAD', + scopeValue: '', + 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-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS }, ] as const; @@ -257,7 +271,9 @@ export const PERMISSION_LABELS: Record = { 'courseware:read': '查看与下载课件', 'courseware:write': '上传、设置可见范围与推送自己的课件', 'class:read': '查看班级与班级列表', - 'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)', + 'class:manage': '创建 / 修改 / 删除班级', + 'class:account': '管理本班班级码与密码(仅班主任)', + 'class:settings': '管理本班学期设置(仅班主任)', 'roster:read': '查看学生名单', 'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)', 'teacher:read': '查看教师名单', @@ -305,6 +321,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per keys: [ 'class:read', 'class:manage', + 'class:account', + 'class:settings', 'roster:read', 'roster:manage', 'teacher:read', @@ -376,6 +394,8 @@ export const PERMISSION_DEFAULT_ROLES: Record = { ADMIN: PERMISSION_KEYS, @@ -460,7 +480,7 @@ export const PERMISSION_MATRIX: readonly { subject: 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: true, subject: false }, @@ -474,3 +494,68 @@ export const PERMISSION_MATRIX: readonly { { action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: 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 = 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; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6d8fa0e..199e87f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -12,7 +12,8 @@ import type { // 只导入类型(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 班级端。 @@ -495,14 +496,12 @@ export interface CreateClassRequest { } export interface UpdateClassRequest { - /** 本班学期开始日期(YYYY-MM-DD,第 1 教学周的周一);留空串表示回落到全局配置 */ - termStartDate?: string | null; - /** 入学年份(有名无实的旧数据需要补填;填了会重算 name) */ enrollmentYear?: number; - /** 班号(同上) */ classIndex?: number; - /** 本学期教学周数(1~30,仅管理员可调) */ +} +export interface UpdateClassSettingsRequest { termWeeks?: number; + termStartDate?: string | null; } /** 班级端成绩查询开关 */ @@ -1205,6 +1204,10 @@ export interface IslandState { /* ------------------------------------------------------------------ 仪表盘 */ export interface DashboardSummary { + sections: Record< + 'classes' | 'students' | 'courses' | 'schedules' | 'homeworks' | 'notifications' | 'grades', + boolean + >; classCount: number; studentCount: number; courseCount: number; @@ -1325,8 +1328,11 @@ export interface TimeLayoutImportResult { /** 客户端 -> 服务端事件名 */ export interface ClientToServerEvents { - 'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void; - 'class:leave': (classId: string) => void; + 'class:join': ( + 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; } @@ -1465,6 +1471,12 @@ export interface UpdateInfo { * **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。 */ export interface PermissionGroupDto { + defaultScope: ContractPermissionScope; + defaultScopeValue: string; + defaultScopeLabel: string; + kind: 'builtin' | 'custom' | 'default'; + inactiveKeys: PermissionKey[]; + warnings: string[]; scope?: ContractPermissionScope; scopeValue?: 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 { + fixed: boolean; + assignments: (PermissionAssignment & { groupName: string; scopeLabel: string })[]; id: string; username: string; name: string; role: UserRole; - /** 所属权限组;空数组 = 走角色默认模板 */ + /** 所属模板的派生 id;空数组 = 教师默认模板 */ groupIds: string[]; - /** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */ + /** 生效能力数量;管理员固定为全集 */ permissionCount: number; permissions: PermissionKey[]; scopeLabels: string[]; @@ -1496,6 +1526,7 @@ export interface PermissionMemberDto { /** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */ export interface PermissionCatalogDto { + meta: Record; keys: PermissionKey[]; labels: Record; categories: { title: string; keys: PermissionKey[] }[]; @@ -1503,7 +1534,7 @@ export interface PermissionCatalogDto { defaultRoles: Record; /** 每个角色的默认模板("没有分组时会是什么") */ roleDefaults: Record; - /** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */ + /** 兼容字段:管理员固定拥有的全部目录键 */ unremovable: PermissionKey[]; } @@ -1525,7 +1556,8 @@ export interface UpdatePermissionGroupRequest { /** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */ export interface AssignUserGroupsRequest { - groupIds: string[]; + groupIds?: string[]; + assignments?: PermissionAssignment[]; } /** @@ -1535,9 +1567,15 @@ export interface AssignUserGroupsRequest { * 真正的拦截始终在服务端。 */ export interface MyPermissionsDto { + /** 各能力实际班级集合,供界面选择器与按钮使用;服务端仍须再授权。 */ + classScopes?: Partial>; + manageScopes?: Partial>; + /** 结合班主任、任课及发布者关系后的动作范围,用于维护页面入口。 */ + actionScopes?: Partial>; + allScopeKeys?: PermissionKey[]; role: UserRole; permissions: PermissionKey[]; - /** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */ + /** true = 教师有显式分配;管理员和班级端恒为 false */ fromGroups: boolean; } diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 8fa186f..4ffb81c 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -105,6 +105,10 @@ export const classApi = { create: (payload: CreateClassRequest): Promise => api.post(API_PATHS.classes, payload), update: (id: string, payload: UpdateClassRequest): Promise => api.patch(`${API_PATHS.classes}/${id}`, payload), + updateSettings: ( + id: string, + payload: { termWeeks?: number; termStartDate?: string | null }, + ): Promise => api.patch(`${API_PATHS.classes}/${id}/settings`, payload), remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`), students: (id: string): Promise => api.get(`${API_PATHS.classes}/${id}/students`), addStudent: ( diff --git a/packages/web-admin/src/components/TimeLayoutEditor.vue b/packages/web-admin/src/components/TimeLayoutEditor.vue index 35e41b3..a46b7e0 100644 --- a/packages/web-admin/src/components/TimeLayoutEditor.vue +++ b/packages/web-admin/src/components/TimeLayoutEditor.vue @@ -21,6 +21,7 @@ const props = defineProps<{ layouts: TimeLayoutDetailDto[]; courses: CourseDto[]; canEdit: boolean; + canCreate: boolean; saving: boolean; /** 引用当前时间表的日课表数量,显示在「时间表信息」里。 */ planCount: number; @@ -321,7 +322,7 @@ onUnmounted(() => {