diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml index ee9956e..47ffcde 100644 --- a/deploy/docker-compose.sqlite.yml +++ b/deploy/docker-compose.sqlite.yml @@ -23,7 +23,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.4.1} + image: classhelper-server:${APP_VERSION:-1.4.2} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4aaaed0..893812d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -60,7 +60,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.4.1} + image: classhelper-server:${APP_VERSION:-1.4.2} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index 13e4a30..693b93d 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -1,6 +1,6 @@ { "name": "classhelper-server-runtime", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)", diff --git a/docs/app/classes.md b/docs/app/classes.md index bc85bbd..c001b49 100644 --- a/docs/app/classes.md +++ b/docs/app/classes.md @@ -19,8 +19,8 @@ description: 名单、班级会话、任课关系与年度升级。 Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。 学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。 -管理员或本班班主任在具备名单管理权限后管理学生,教师也可按授权范围只读名单。 -名单表格导入仍要求管理员及相应导入权限;学号查重在 Student 表完成,不当成 User 用户名。 +具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。 +名单表格导入要求 import:students、roster:manage 和对应班级范围;学号查重在 Student 表完成,不当成 User 用户名。 调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。 ## 教师与科目 @@ -39,5 +39,26 @@ Student 存学号、姓名、班级、性别、家长电话和状态,没有密 范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。 学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。 学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。 -高三归档、高一/高二升一级,新高一不复制任课关系。归档页查看届别历史与转出记录, -不再通过手动创建归档来代替年度升级。撤回转出等操作仍受归档与管理员业务检查约束。 +默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录, +不再通过手动创建归档来代替年度升级。撤回转出由 roster:manage 控制,原班归档后可指定其他在读班。 + +## 学生生命周期 + +资料编辑仅修改学号、姓名、性别与家长电话,不接受状态或班级字段;旧客户端发送这两项会收到 400 和专用接口提示。 +默认名单只显示未归档的 active;状态筛选可查看 inactive、transferred、graduated,“包含归档”另行控制归档标记。 + +调班、转出、转入、复学、撤回与移出班级均在一个事务内修改学生并写历史,包含批次、学年、原新班级与年级、来源去向学校、操作人和备注。 +调班需同时覆盖源班与目标班;跨年级须管理员勾选允许跨年级并填写备注。目标班必须在读。 +转出保留原班及业务历史,只接受在读或停用学生。转入支持新建学号或选择转出/停用学生,毕业或归档学生不能通过名单入口复活。 +撤回转出可选择另一个在读班,原历史保留。默认删除动作改为停用;硬删除另须管理员确认且无成绩、作业、已读或历史。 + +## 升级预览与执行 + +“学年升级”页面独立要求 promotion:manage,全校范围。先保存轨道、有序年级、起始班数、执行时间、开学日和周次重置策略,再预览逐班影响。 +预览包含在读/停用/转出人数、新班码、周次删除行数、阻断和警告、计划哈希。未知年级、缺失届别、重复班号、已有届别档案或新班码冲突会阻断执行。 +执行需确认学年和预览哈希;业务或配置变化后必须重新预览。未来学年禁止执行,过去学年仅 ADMIN 明确补跑。 + +毕业班所有学生挂届别:在读转毕业,转出和停用保留状态;非毕业班只升到下一学段并清本班开学日覆盖。 +新班只有骨架,开学日取计划值(未配置时为执行学年 9 月 1 日)。保留周次时,旧逐周区间会继续覆盖新学年,页面会警告。 +同年执行幂等,执行记录显示操作人、快照路径、SHA256、明细与错误。快照失败不升级,失败事务回滚后可修正重试。 +撤销归档只清标记并将毕业生恢复在读,转出/停用不变;不回滚年级、不删除新班、不恢复已清空周次,整学年恢复需管理员手工使用快照。 diff --git a/docs/app/schedules.md b/docs/app/schedules.md index 7890ac4..50047ef 100644 --- a/docs/app/schedules.md +++ b/docs/app/schedules.md @@ -8,9 +8,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。 Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。 上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。 -编辑工作区参照 [CsesWebEditor](https://github.com/SmartTeachCN/CsesWebEditor) 的资源列表、设置卡片、课时列表与快速选科布局实现。左侧集中切换课程表、时间表、科目、群组与预定、整周预览和文件预览。旧管理页地址 `/schedules` 自动进入同一工作区。 +编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的布局与交互实现,使用独立的深色全屏工作区。顶部切换课表、时间表、科目和调课;默认以周网格展示课程,右侧提供科目简称面板,底部可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一工作区,点击「返回管理」回到管理端。 -点击课时后可用右侧科目按钮快速填充;拖动课时交换课程,拖动时间点调整时间表顺序,完成后保存。时间表支持任意分钟输入;设置的课程/课间分钟数会用于新建时间点的默认起止时间。完整档案可在页头导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。 +点击每天列头的「新课表」选择时间表并创建日课表。点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在「详细信息」中维护。 + +列表视图保留批量排课、复制粘贴、拖动交换课程等操作;时间表页支持拖动时间点排序、任意分钟输入,以及上课、课间、分割线、行动四种时间点。设置的课程/课间分钟数用于新建时间点的默认起止时间。编辑完成点击「保存」或按 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。 + +完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。 ## 日课表与时间表 diff --git a/docs/dev/api.md b/docs/dev/api.md index 0adeafb..5408042 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -12,66 +12,96 @@ 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: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 | 班级凭据/班主任维护 | +| 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 | 当前有效权限、目录、组与账号分组 | ## 教学与文件 -| 路径/动作 | 行为及边界 | -| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| /courses | 班级科目与任课关系 | -| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 | -| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule | -| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 | -| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage | -| /class-plans;PUT /class-plans/:id/entries;PATCH /class-plans/:id/enabled | 日课表 CRUD、整表保存、启用,写入 schedule:write + 班主任/管理员 | -| POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入 | -| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 | -| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 | -| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 | -| POST /calls | 叫人,call:create + 范围检查 | -| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 | -| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 | -| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 | -| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage | -| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 | -| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 | -| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 | -| GET/POST /courseware/folders;PATCH /courseware/:id/folder | 员工 courseware:write;目录与课件均限本人 | -| GET /courseware/transfers;POST /courseware/transfers/:id/ack | 班级端补领、逐设备回执 | +| 路径/动作 | 行为及边界 | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| /courses | 班级科目与任课关系 | +| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 | +| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule | +| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 | +| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage | +| /class-plans;PUT /class-plans/:id/entries;PATCH /class-plans/:id/enabled | 日课表 CRUD、整表保存、启用,写入 schedule:write + 班主任/管理员 | +| POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入;导入另需 import:schedule | +| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 | +| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 | +| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 | +| POST /calls | 叫人,call:create + 范围检查 | +| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 | +| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查对应导入键、管理键、班级和科目 | +| /imports/class-plan/preview、/imports/class-plan | 预览与提交均需 import:schedule + schedule:write;提交走关系化导入 | +| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,导入需 import:schedule + timetable:manage,删除需 timetable:manage | +| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 | +| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 | +| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 | +| GET/POST /courseware/folders;PATCH /courseware/:id/folder | 员工 courseware:write;目录与课件均限本人 | +| GET /courseware/transfers;POST /courseware/transfers/:id/ack | 班级端补领、逐设备回执 | ## 设备、系统与运维 -| 路径/动作 | 行为及边界 | -| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 | -| 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 | -| 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 | 已登录下载,不挂员工权限键,供班级端更新 | -| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write | -| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划、预览和执行,system:manage,执行还做业务校验 | -| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档与撤回转出,archive:manage;不提供创建归档代替年度升级的入口 | -| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 | -| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 | -| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 | -| /dashboard/summary、/dashboard/term | 按范围读取看板与周次 | +| 路径/动作 | 行为及边界 | +| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 | +| 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 | +| 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 | 已登录下载,不挂员工权限键,供班级端更新 | +| /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;不提供手动创建归档 | +| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 | +| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 | +| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 | +| /dashboard/summary、/dashboard/term | 按范围读取看板与周次 | 探针 /healthz 和 /readyz 不带 API 前缀;/api/health 提供版本与模块详情。 课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。 心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。 更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。 + +## 权限治理与学生年度流程 + +| 方法与路径(省略 /api) | 参数与权限边界 | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| 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 整体替换;默认组角色须匹配,空数组恢复角色默认模板;禁止越键越范围 | +| GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 | +| POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 | +| PATCH /students/:id | roster:manage;仅资料字段;classId/status 明确返回 400 | +| POST /students/transfer | roster:manage;studentIds、toClassId(null 表示移出)、note;跨年级需 ADMIN、allowGradeChange=true 与备注 | +| POST /students/transfer-out | roster:manage;studentIds、note、toSchool;仅 active/inactive,毕业/归档/重复转出 409 | +| POST /students/transfer-in | roster:manage;指定 toClassId,新建用 studentNo/name,重入用 studentId;可传 fromSchool/note | +| POST /students/:id/restore | roster:manage;可传 toClassId/note;转出或停用恢复,原班归档时应另选在读班 | +| POST /archives/transferred/:id/restore | 兼容同上,权限改为 roster:manage | +| DELETE /students/:id | roster:manage;默认停用;hard=true&confirm=true 仅 ADMIN,存在业务或历史则 409 | +| GET /students/transfers;GET /students/:id/transfers | roster:read;不排除转出状态;支持 studentId/classId/batchId/academicYear 与分页 | +| GET/PUT /term/promotion | promotion:manage 全校范围;读取计划、执行时间、应执行学年与记录;保存兼容旧 month/day/hour/classCount,也接受 plan | +| POST /term/promotion/preview | promotion:manage 全校范围;year;只读返回逐轨道逐班统计、阻断项、警告、新班码、周次影响、planHash | +| POST /term/promotion/run | promotion:manage 全校范围;year、planHash、confirm:true;过去学年还须 ADMIN + catchUp:true;未来年份 400;阻断或哈希变化 409 带 details | +| POST /classes/promote | promotion:manage 与各目标范围;仅 classIds,不收自由 grade;从计划推导下一学段,毕业学段 409 | +| PATCH /classes/:id | 不再接受 grade,旧请求返回 400;改年级须走升级入口 | +| DELETE /archives/:id | archive:manage 全校范围;仅撤销归档标记,保留转出与停用状态,不回滚年级、新班及周次 | + +生命周期历史以 kind 为事实源,mode 为派生兼容字段;新响应另含 batchId、academicYear、fromGrade/toGrade、fromSchool/toSchool。 +归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。 +所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 roster:read 且在对应范围内的员工才能获得学生明细。 diff --git a/docs/dev/data-model.md b/docs/dev/data-model.md index 76abaf9..17ff418 100644 --- a/docs/dev/data-model.md +++ b/docs/dev/data-model.md @@ -10,42 +10,43 @@ description: 现行模型职责、迁移与快照边界。 ## 模型职责 -| 模型 | 职责 | -| ---------------------- | -------------------------------------------------- | -| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class | -| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 | -| `StudentClassTransfer` | 调班与转出历史,保留班级/姓名快照 | -| `ArchivedYear` | 毕业届别与统计快照 | -| `TermWeek` | 逐周日期区间,classId 空串为全校默认 | -| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 | -| `Course` | 班级+科目+教师任课关系,含简称和室外标记 | -| `Schedule` | 旧课表迁移审计来源,现行接口不再以它为业务真值 | -| `Homework` | 作业与本地 assignDate;dueAt 为遗留列 | -| `HomeworkStatus` | 按 studentId 记录作业完成状态 | -| `Notification` | 通知/叫人记录、优先级与本地 assignDate | -| `NotificationRead` | 按 studentId 记录已读 | -| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 | -| `Grade` | studentId、课程、考试、分数和等级 | -| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue | -| `PermissionGroupItem` | 组内权限键 | -| `UserPermissionGroup` | 账号与组的多对多分配 | -| `AppSetting` | 周次、升级、AI 和系统设置 | -| `ClientDevice` | deviceKey、心跳、更新目标与状态 | -| `ClientCommand` | 白名单远控、有效期与执行回执 | -| `ClientRelease` | 服务端缓存安装包、SHA256 与大小 | -| `AiAudit` | 助手解析审计,不保存 API Key | -| `Exam` | 考试名称与是否计入总分 | -| `TimeLayoutItem` | 有序时间点及类型,被课程显式引用 | -| `ClassPlanGroup` | 班级课表群组、全局/默认标记 | -| `ClassPlan` | 日课表、时间表、轮换、启用与临时层规则 | -| `ClassPlanEntry` | 日课表与时间点、课程的外键关联、空课/停用/换课标记 | -| `ScheduledClassPlan` | 按本地日期预定日课表 | -| `PromotionRun` | 年度升级幂等与审计 | -| `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 | -| `CoursewareFolder` | 教师自建分类目录,按上传者隔离 | -| `CoursewareVisibility` | 课件可见班级 | -| `CoursewarePush` | 按班级的推送任务 | -| `CoursewareReceipt` | 按设备的课件接收回执 | +| 模型 | 职责 | +| ---------------------- | --------------------------------------------------- | +| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class | +| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 | +| `StudentClassTransfer` | 生命周期历史,kind/batchId/学年/班级年级及学校快照 | +| `ArchivedYear` | 毕业届别与统计快照 | +| `TermWeek` | 逐周日期区间,classId 空串为全校默认 | +| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 | +| `Course` | 班级+科目+教师任课关系,含简称和室外标记 | +| `Schedule` | 旧课表迁移审计来源,现行接口不再以它为业务真值 | +| `Homework` | 作业与本地 assignDate;dueAt 为遗留列 | +| `HomeworkStatus` | 按 studentId 记录作业完成状态 | +| `Notification` | 通知/叫人记录、优先级与本地 assignDate | +| `NotificationRead` | 按 studentId 记录已读 | +| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 | +| `Grade` | studentId、课程、考试、分数和等级 | +| `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 | +| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue | +| `PermissionGroupItem` | 组内权限键 | +| `UserPermissionGroup` | 账号与组的多对多分配 | +| `AppSetting` | 周次、升级、AI 和系统设置 | +| `ClientDevice` | deviceKey、心跳、更新目标与状态 | +| `ClientCommand` | 白名单远控、有效期与执行回执 | +| `ClientRelease` | 服务端缓存安装包、SHA256 与大小 | +| `AiAudit` | 助手解析审计,不保存 API Key | +| `Exam` | 考试名称与是否计入总分 | +| `TimeLayoutItem` | 有序时间点及类型,被课程显式引用 | +| `ClassPlanGroup` | 班级课表群组、全局/默认标记 | +| `ClassPlan` | 日课表、时间表、轮换、启用与临时层规则 | +| `ClassPlanEntry` | 日课表与时间点、课程的外键关联、空课/停用/换课标记 | +| `ScheduledClassPlan` | 按本地日期预定日课表 | +| `PromotionRun` | 年度升级幂等与审计 | +| `Courseware` | 文件元数据、上传教师、存储路径与 SHA256 | +| `CoursewareFolder` | 教师自建分类目录,按上传者隔离 | +| `CoursewareVisibility` | 课件可见班级 | +| `CoursewarePush` | 按班级的推送任务 | +| `CoursewareReceipt` | 按设备的课件接收回执 | 学生和账号分离:Student 没有密码/角色;教师/管理员存 User,CLASS_DEVICE 只是 Class 登录形成的会话。 任课关系只在 Course;班主任关系在 Class.teacherId,可以与任课关系指向同一账号。 @@ -73,3 +74,14 @@ User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好 2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。 3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。 4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。 + +## 权限与生命周期迁移 + +`20261023000000_promotion_permission` 为已初始化的 ADMIN 默认组补 promotion:manage;默认组尚未创建时允许不插入,由运行时模板创建。 +`20261023010000_student_lifecycle_audit` 增加 PermissionAudit、StudentClassTransfer 的批次/学年/班级年级/学校快照、ArchivedYear.inactiveCount、PromotionRun 的操作人/明细/摘要/错误。 +旧 mode 按 single/batch → class-change、transfer-out → transfer-out、restore-transfer → restore 迁移后删除该列,避免双事实源。 +旧行的新字符串为空,学年为空,统计默认零;不臆造无法恢复的历史年级或批次。 + +SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind; +没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。 +升级计划存 AppSetting.term.promotionPlan,时间存 term.promotionAt,无需新增学校或学生账号模型。 diff --git a/docs/dev/implementation.md b/docs/dev/implementation.md index 64d39ca..6ccf4a3 100644 --- a/docs/dev/implementation.md +++ b/docs/dev/implementation.md @@ -21,7 +21,7 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入 默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。 全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。 个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。 -年度升级由 `term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。 +年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。 毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。 ## 岛与本机配置 @@ -38,3 +38,15 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入 更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。 课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。 JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。 + +## 学生与权限写入契约 + +学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。 +资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。 +权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。 +范围解析在 shared,默认组或分组均按当前功能键过滤,不能借另一功能的范围扩大当前操作。 +权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。 + +年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算; +先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。 +周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。 diff --git a/docs/dev/permissions.md b/docs/dev/permissions.md index dd1babb..35f98d3 100644 --- a/docs/dev/permissions.md +++ b/docs/dev/permissions.md @@ -35,22 +35,23 @@ HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.rol | 部长 | 配置的一组年级,scopeValue 用逗号分隔 | | 班级端 | 登录绑定班级 | -空年级/部配置不放行。设备写操作只合并含对应功能键的分组范围,不能借只读组扩权。 +空年级/部配置不放行。范围按当前功能键独立合并,不能借其他只读组扩大名单、班级或设备写权限。 科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。 班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。 -| 业务 | 主要功能键与额外边界 | -| ---------------- | ---------------------------------------------------------------------------- | -| 名单读取/管理 | roster:read / roster:manage;管理仍须管理员或本班班主任断言 | -| 教师列表/维护 | teacher:read / 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 不再作为写入口要求 | -| 数据库/系统/权限 | database:manage / system:manage / permission:manage | +| 业务 | 主要功能键与额外边界 | +| ---------------- | ----------------------------------------------------------------------------------------------------- | +| 名单读取/管理 | roster:read / roster:manage;管理需名单键与目标班范围;关联班级仍须班主任关系,全校名单范围可跨班维护 | +| 教师列表/维护 | teacher:read / teacher:manage;导入教师另检查 import:teachers 与 teacher:manage | +| 日课表写入 | schedule:write + 管理员/本班班主任 | +| 全校时间表写入 | timetable:manage;默认给 ADMIN | +| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 | +| 通知/叫人 | notification:publish / call:create,再检查班级范围 | +| 课件 | courseware:read / courseware:write,上传和推送自己的文件还受本人授课班级约束 | +| 客户端维护/远控 | client:read、client:manage、remote:control,写操作按对应键范围 | +| 版本与安装包写入 | client:update;旧 client:update-push 不再作为写入口要求 | +| 学年升级 | promotion:manage;计划、预览与执行独立授权,全校范围,默认给 ADMIN | +| 数据库/系统/权限 | database:manage / system:manage / permission:manage | 完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。 @@ -67,3 +68,22 @@ Web 菜单属性来自路由,`meta.roles` 粗筛、`meta.permission` 细筛; `/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。 远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256, 按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。 + +## 范围配置与权限治理 + +范围由 shared 的 `parsePermissionScope` 统一解析。关联班级只接受空值(班主任与任课班)或 HEAD(仅班主任班); +GRADE 必选一个年级,DIVISION 必选多个年级组成的集合;全角逗号、顿号和空白统一分隔、去重排序。 +ALL 存空范围值。年级选项来自数据库已有年级和升级计划,空范围或未知年级返回 400;旧库未配置的岗位组不放行。 +`GET /permissions/scopes` 提供选择器选项,组 DTO 的 scopeLabel 供页面展示。 + +默认组只能分配给同角色;非 ADMIN 的权限管理员只能管理关联班级范围的自定义组, +组原有键和新键均不得超过自己当前权限。角色默认组只有 ADMIN 可改;删除组可能触发默认回落,仅 ADMIN 可执行。 +分配空数组恢复数据库角色默认组,分配任何组则仅取所选组并集;需要保留默认时显式同时选择同角色默认组。 + +权限页保存前展示成员与权限差异,支持复制组、查看生效成员和分页审计。 +PermissionAudit 在组或分配事务中保存操作人、目标、前后 JSON;`GET /permissions/audit` 受 permission:manage 控制。 +成员权限由一次预取组和账号后计算,不逐账号重复加载。 + +新增迁移为旧库已有 ADMIN 默认组补 promotion:manage;新库由默认模板创建。 +自定义组和已分配其他组的管理员不会自动获得该键,需要在权限页显式授予。 +班级端权限仍为空,本班名单、课表、通知、叫人和作业代录走显式员工守卫例外;班级端发布内容归属本班班主任。 diff --git a/docs/dev/testing.md b/docs/dev/testing.md index a554af4..9c1d86b 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -10,23 +10,24 @@ description: 隔离 API、隐藏 UI、后台客户端与显式视觉验收。 ## 命令与前置条件 -| 命令 | 覆盖/前置条件 | -| ------------------------------------------------ | ----------------------------------------------------------------- | -| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared | -| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx | -| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 | -| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 | -| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 | -| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 | -| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 | -| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 | -| pnpm verify:client-migrations | 临时旧库验证迁移、联动退役与默认组持久化 | -| pnpm verify:review-client | 无窗口逻辑回归 | -| pnpm verify:review-api / pnpm verify:clients-api | 需明确准备的隔离测试服务,使用 VERIFY_BASE_URL 等脚本参数 | -| pnpm verify:desktop | 先 pnpm build:desktop;隐藏快速检查,独立 userData | -| pnpm verify:desktop:online | 显式联网,需要测试管理员并创建临时教师/班级 | -| pnpm verify:desktop:visual | 显式使用桌面,完整岛几何/动画/透明度/截图检查 | -| pnpm verify:packaged --exe <客户端EXE> | 默认快速离线,两次启动、packaged=true;--visual 才使用桌面 | +| 命令 | 覆盖/前置条件 | +| ------------------------------------------------ | ---------------------------------------------------------------------------- | +| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared | +| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx | +| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 | +| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 | +| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 | +| pnpm verify:lifecycle | 临时 SQLite 验证权限治理、事务故障回滚、状态机、历史、多轨道、计划哈希和快照 | +| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 | +| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 | +| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 | +| pnpm verify:client-migrations | 临时旧库验证迁移、联动退役与默认组持久化 | +| pnpm verify:review-client | 无窗口逻辑回归 | +| pnpm verify:review-api / pnpm verify:clients-api | 需明确准备的隔离测试服务,使用 VERIFY_BASE_URL 等脚本参数 | +| pnpm verify:desktop | 先 pnpm build:desktop;隐藏快速检查,独立 userData | +| pnpm verify:desktop:online | 显式联网,需要测试管理员并创建临时教师/班级 | +| pnpm verify:desktop:visual | 显式使用桌面,完整岛几何/动画/透明度/截图检查 | +| pnpm verify:packaged --exe <客户端EXE> | 默认快速离线,两次启动、packaged=true;--visual 才使用桌面 | 联网 packaged 使用 --online;调用方须预先准备隔离服务、临时教师和班级并显式注入凭据,该启动器不会自动创建它们。 开发版 verify:desktop:online 则通过 provisionSmokeClass 创建和清理临时身份。--offline 保持离线。 @@ -42,6 +43,8 @@ pnpm check:icons pnpm docs:check pnpm verify:e2e pnpm verify:v14 +pnpm verify:lifecycle +pnpm verify:client-migrations pnpm verify:courseware pnpm build:desktop pnpm verify:desktop @@ -67,3 +70,7 @@ docs/images 为 README/文档展示图;docs/screenshots 为回归留档;webs Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。 岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。 日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。 + +权限与年度流程回归入口为 `scripts/verify-lifecycle.mjs`,为子进程指定临时库与测试密钥,不使用业务 .env 的登录凭据。 +旧库升级脚本动态读取最新迁移,另建重构前夹具校验 ADMIN 默认组补键、历史 kind 映射、空组保留及重复启动。 +事务回滚用 SQLite 触发器模拟历史写入失败,不能通过删除断言或放宽状态规则通过测试。 diff --git a/docs/get-started/web-admin.md b/docs/get-started/web-admin.md index 9caf689..10597b3 100644 --- a/docs/get-started/web-admin.md +++ b/docs/get-started/web-admin.md @@ -12,16 +12,16 @@ description: 登录、菜单分组、权限与移动布局。 侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。 -| 分组 | 主要页面 | -| ---------- | -------------------------------------------------- | -| 工作台 | 仪表盘、AI 助手 | -| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 | -| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、毕业归档 | -| 设备运维 | 客户端在线状态、命令、更新与安装策略 | -| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 | +| 分组 | 主要页面 | +| ---------- | ------------------------------------------------------------ | +| 工作台 | 仪表盘、AI 助手 | +| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 | +| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、学年升级、毕业归档 | +| 设备运维 | 客户端在线状态、命令、更新与安装策略 | +| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 | -班级/学生/教师管理、学期周次、归档和数据库页面还有管理员角色门槛; -教师只读名单、教学页与设备页按权限键和数据范围开放。 +班级、学生、学期周次、学年升级和归档页面按各自生效权限开放;教师管理和数据库页面仍保留角色门槛。 +名单、教学页与设备页还按对应数据范围开放,写按钮需额外管理键。 每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。 顶栏提供本人密码、使用引导、检查更新与退出等入口。 @@ -32,3 +32,7 @@ description: 登录、菜单分组、权限与移动布局。 同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。 下一步:[建班级、排课表](setup-class.md)。 + +权限页选组会替代角色默认模板,保存前可查看新增/移除权限与影响成员;保留默认能力时同时选择同角色默认组。 +名单页默认在读,历史状态与包含归档分开筛选;资料编辑、调班、转出、转入/复学各有专用入口。 +学年升级先保存计划、预览阻断项和周次影响,再输入学年确认执行;执行记录保留快照与 SHA256。 diff --git a/docs/screenshots/plan-editor-empty.png b/docs/screenshots/plan-editor-empty.png new file mode 100644 index 0000000..b2f4717 Binary files /dev/null and b/docs/screenshots/plan-editor-empty.png differ diff --git a/docs/screenshots/plan-editor-live-api.png b/docs/screenshots/plan-editor-live-api.png new file mode 100644 index 0000000..1c2c6cc Binary files /dev/null and b/docs/screenshots/plan-editor-live-api.png differ diff --git a/docs/screenshots/plan-editor-mobile.png b/docs/screenshots/plan-editor-mobile.png new file mode 100644 index 0000000..c04371a Binary files /dev/null and b/docs/screenshots/plan-editor-mobile.png differ diff --git a/docs/screenshots/plan-editor-reference.png b/docs/screenshots/plan-editor-reference.png new file mode 100644 index 0000000..009622a Binary files /dev/null and b/docs/screenshots/plan-editor-reference.png differ diff --git a/docs/screenshots/plan-editor-time-layout.png b/docs/screenshots/plan-editor-time-layout.png new file mode 100644 index 0000000..027a935 Binary files /dev/null and b/docs/screenshots/plan-editor-time-layout.png differ diff --git a/docs/screenshots/plan-editor-workspace.png b/docs/screenshots/plan-editor-workspace.png index d169aad..8b530f9 100644 Binary files a/docs/screenshots/plan-editor-workspace.png and b/docs/screenshots/plan-editor-workspace.png differ diff --git a/package.json b/package.json index 417039b..95f170b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "class-helper", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "packageManager": "pnpm@11.8.0", @@ -31,6 +31,7 @@ "verify:e2e": "node scripts/verify-e2e-isolated.mjs", "verify:courseware": "node scripts/verify-courseware.mjs", "verify:v14": "node scripts/verify-v14.mjs", + "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", diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 7d677c0..b93e3cd 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/desktop-client", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "author": "Class Helper", diff --git a/packages/server/package.json b/packages/server/package.json index 73b2e28..151e631 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/server", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "type": "module", diff --git a/packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql b/packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql new file mode 100644 index 0000000..d2fb160 --- /dev/null +++ b/packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql @@ -0,0 +1,6 @@ +-- 已存在的管理员默认组补升级键;默认组尚未初始化时由运行时模板创建。 +INSERT INTO "PermissionGroupItem" ("id", "groupId", "key") +SELECT 'promotion-key-' || "id", "id", 'promotion:manage' +FROM "PermissionGroup" +WHERE "defaultRole" = 'ADMIN' +AND NOT EXISTS (SELECT 1 FROM "PermissionGroupItem" i WHERE i."groupId" = "PermissionGroup"."id" AND i."key" = 'promotion:manage'); diff --git a/packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql b/packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql new file mode 100644 index 0000000..9a66b6b --- /dev/null +++ b/packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql @@ -0,0 +1,23 @@ +ALTER TABLE "StudentClassTransfer" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'class-change'; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "batchId" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "academicYear" INTEGER; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromGrade" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "toGrade" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromSchool" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "StudentClassTransfer" ADD COLUMN "toSchool" TEXT NOT NULL DEFAULT ''; +UPDATE "StudentClassTransfer" SET "kind" = CASE "mode" WHEN 'transfer-out' THEN 'transfer-out' WHEN 'restore-transfer' THEN 'restore' ELSE 'class-change' END; +ALTER TABLE "StudentClassTransfer" DROP COLUMN "mode"; +CREATE INDEX "StudentClassTransfer_batchId_idx" ON "StudentClassTransfer"("batchId"); +ALTER TABLE "ArchivedYear" ADD COLUMN "inactiveCount" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "PromotionRun" ADD COLUMN "operatorId" TEXT; +ALTER TABLE "PromotionRun" ADD COLUMN "operatorName" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "PromotionRun" ADD COLUMN "detailsJson" TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE "PromotionRun" ADD COLUMN "snapshotSha256" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "PromotionRun" ADD COLUMN "error" TEXT NOT NULL DEFAULT ''; +CREATE TABLE "PermissionAudit" ( + "id" TEXT NOT NULL PRIMARY KEY, "actorId" TEXT NOT NULL, "actorName" TEXT NOT NULL, + "action" TEXT NOT NULL, "targetType" TEXT NOT NULL, "targetId" TEXT NOT NULL, + "beforeJson" TEXT NOT NULL DEFAULT '{}', "afterJson" TEXT NOT NULL DEFAULT '{}', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX "PermissionAudit_createdAt_idx" ON "PermissionAudit"("createdAt"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index c422a47..2a763b1 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -121,14 +121,21 @@ model StudentClassTransfer { /// 操作人(User.id);账号被删后留空,姓名快照保留 operatorId String? operatorName String @default("") - /// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校) - mode String @default("single") + /// 生命周期类型;旧 mode 仅在 DTO 中派生兼容 + kind String @default("class-change") + batchId String @default("") + academicYear Int? + fromGrade String @default("") + toGrade String @default("") + fromSchool String @default("") + toSchool String @default("") note String @default("") createdAt DateTime @default(now()) student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) @@index([studentId, createdAt]) + @@index([batchId]) @@index([createdAt]) } @@ -157,6 +164,7 @@ model ArchivedYear { classCount Int @default(0) studentCount Int @default(0) transferredCount Int @default(0) + inactiveCount Int @default(0) homeworkCount Int @default(0) notificationCount Int @default(0) archivedAt DateTime @default(now()) @@ -717,12 +725,32 @@ model PromotionRun { year Int @id status String snapshotPath String @default("") + operatorId String? + operatorName String @default("") + detailsJson String @default("{}") + snapshotSha256 String @default("") + error String @default("") message String @default("") classCount Int @default(0) studentCount Int @default(0) createdAt DateTime @default(now()) finishedAt DateTime? } + +/// 权限变更与人工升级审计;不建账号外键,删除账号后仍保留操作人快照。 +model PermissionAudit { + id String @id @default(cuid()) + actorId String + actorName String + action String + targetType String + targetId String + beforeJson String @default("{}") + afterJson String @default("{}") + createdAt DateTime @default(now()) + + @@index([createdAt]) +} /// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。 model Courseware { id String @id @default(cuid()) diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts index 0fd7b53..9812627 100644 --- a/packages/server/prisma/seed.ts +++ b/packages/server/prisma/seed.ts @@ -465,7 +465,7 @@ async function main(): Promise { toClassName: classes[1]!.name, operatorId: admin.id, operatorName: admin.name, - mode: 'batch', + kind: 'class-change', note: '军训后按分班考试成绩重新均衡', createdAt: new Date(now - 30 * day), }, @@ -492,7 +492,7 @@ async function main(): Promise { toClassName: '转出本校', operatorId: admin.id, operatorName: admin.name, - mode: 'transfer-out', + kind: 'transfer-out', note: '随家长工作调动转学至外地', createdAt: new Date(now - 12 * day), }, diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 48d107f..c7587e8 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs'; /** * 端到端联调验证(对应验收标准): * 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到 @@ -1056,9 +1057,13 @@ async function main() { const studentCalls = await api('/calls', { method: 'POST', token: studentToken, - body: { classId, studentId: targetStudent?.id, quickPhrase: '学生不能叫人' }, + body: { classId, studentId: targetStudent?.id, quickPhrase: '班级端代叫人' }, }); - record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`); + record( + '班级端可为本班叫人(201)', + studentCalls.status === 201 && Boolean(studentCalls.payload?.data?.id), + `status=${studentCalls.status}`, + ); const studentListAfterCall = await api('/notifications', { token: studentToken }); const receivedCall = (studentListAfterCall.payload?.data ?? []).find( @@ -1566,7 +1571,11 @@ async function main() { }); record( '管理员可以分配权限组(PUT /users/:id/groups → 200)', - Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false, + 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 ?? ''}`, ); @@ -2035,9 +2044,13 @@ async function main() { const studentPublish = await api('/notifications', { method: 'POST', token: studentToken, - body: { classId, title: '学生越权发布', content: '不应成功' }, + body: { classId, title: '班级端本班通知', content: '允许本班发布' }, }); - record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`); + record( + '班级端可发布本班通知(201)', + studentPublish.status === 201 && Boolean(studentPublish.payload?.data?.id), + `status=${studentPublish.status}`, + ); const anonymous = await api('/classes'); record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`); @@ -2098,8 +2111,8 @@ async function main() { token: teacherToken, }); record( - '班主任下载名单模板被拒绝(403,名单仅管理员)', - teacherRosterTemplate.status === 403, + '有导入键的班主任可下载名单模板(200)', + teacherRosterTemplate.status === 200 && Boolean(teacherRosterTemplate.payload?.data?.content), `status=${teacherRosterTemplate.status}`, ); @@ -2581,11 +2594,12 @@ async function main() { }, }); record( - '班主任预览学生名单模板被拒绝(403,名单仅管理员)', - teacherRosterPreview.status === 403, + '有导入键的班主任可预览名单模板(200)', + teacherRosterPreview.status === 200 && Boolean(teacherRosterPreview.payload?.data), `status=${teacherRosterPreview.status}`, ); + const teacherImportedNo = `T${Date.now()}`; const studentsByTeacher = await api('/imports/table/commit', { method: 'POST', token: teacherToken, @@ -2593,17 +2607,43 @@ async function main() { kind: 'students', classId, fileName: 'students.csv', - contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'), + contentBase64: Buffer.from( + `\ufeff学号,姓名${NL}${teacherImportedNo},班主任导入学生${NL}`, + 'utf8', + ).toString('base64'), mapping: { studentNo: '学号', name: '姓名' }, mode: 'append', }, }); record( - '教师导入学生名单被拒绝(403)', - studentsByTeacher.status === 403, + '有导入键的班主任按范围导入本班名单(200)', + studentsByTeacher.status === 200 && + studentsByTeacher.payload?.data?.failed === 0 && + studentsByTeacher.payload?.data?.inserted === 1, `status=${studentsByTeacher.status}`, ); + const teacherImportedStudents = await api(`/students?classId=${classId}&keyword=${teacherImportedNo}`, { + token: adminToken, + }); + const teacherImportedStudent = teacherImportedStudents.payload?.data?.find( + (item) => item.studentNo === teacherImportedNo, + ); + if (teacherImportedStudent) + await api(`/students/${teacherImportedStudent.id}`, { method: 'DELETE', token: adminToken }); + const teacherImportAfter = await api( + `/students?classId=${classId}&status=inactive&keyword=${teacherImportedNo}`, + { token: adminToken }, + ); + record( + '班主任导入夹具收尾:停用并保留历史,恢复原在读名单', + Boolean( + teacherImportAfter.payload?.data?.some( + (item) => item.studentNo === teacherImportedNo && item.status === 'inactive', + ), + ), + ); + // 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证 const importedGrades = await api( `/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`, @@ -2725,9 +2765,13 @@ async function main() { const classPublish = await api('/notifications', { method: 'POST', token: classToken, - body: { classId, title: '班级账号越权发布', content: '不应成功' }, + body: { classId, title: '班级账号本班发布', content: '允许本班发布' }, }); - record('班级账号发布通知被拒绝(403)', classPublish.status === 403, `status=${classPublish.status}`); + record( + '班级账号发布本班通知成功(201)', + classPublish.status === 201 && Boolean(classPublish.payload?.data?.id), + `status=${classPublish.status}`, + ); const classGradesWrite = await api('/grades', { method: 'POST', @@ -3201,44 +3245,11 @@ async function main() { // ---------------------------------------------------------------- 数据库管理(仅管理员) // 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。 // 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。 - // 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。 - // **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。 - const REQUIRED_DB_TABLES = [ - 'User', - 'ArchivedYear', - 'Class', - 'Student', - 'Course', - 'StudentClassTransfer', - 'Schedule', - 'TimeLayout', - 'Homework', - 'HomeworkStatus', - 'Notification', - 'NotificationRead', - 'Grade', - 'PermissionGroup', - 'PermissionGroupItem', - 'UserPermissionGroup', - 'AppSetting', - 'ClientDevice', - 'Courseware', - 'CoursewareFolder', - 'CoursewareVisibility', - 'CoursewarePush', - 'CoursewareReceipt', - 'ClientCommand', - 'ClientRelease', - 'AiAudit', - 'TimeLayoutItem', - 'ClassPlanGroup', - 'ClassPlan', - 'ClassPlanEntry', - 'ScheduledClassPlan', - 'PromotionRun', - 'TermWeek', - 'Exam', - ]; + // 直接读取快照注册表,避免新增模型后测试清单遗漏。 + const snapshotSource = readFileSync(new URL('../src/lib/snapshot.ts', import.meta.url), 'utf8'); + const registry = snapshotSource.match(/export const SNAPSHOT_TABLES = \[([\s\S]*?)\] as const/); + if (!registry) throw new Error('无法解析快照表注册表'); + const REQUIRED_DB_TABLES = [...registry[1].matchAll(/'([^']+)'/g)].map((match) => match[1]); const dbStatus = await api('/database/status', { token: adminToken }); const dbStatusData = dbStatus.payload?.data ?? {}; const dbStatusTableNames = Array.isArray(dbStatusData.tables) @@ -3278,14 +3289,14 @@ async function main() { `status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`, ); - // 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支) + // 连接测试:当前库必须可达(SQLite 取服务端当前临时库地址,MySQL 用一个不可达地址测失败分支) let testOkResult; let testOkName; if (dbStatusData.provider === 'sqlite') { testOkResult = await api('/database/test-connection', { method: 'POST', token: adminToken, - body: { provider: 'sqlite', url: 'file:./prisma/dev.db' }, + body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked }, }); testOkName = '连接测试(当前 SQLite 库可达)'; } else { diff --git a/packages/server/scripts/verify-lifecycle.ts b/packages/server/scripts/verify-lifecycle.ts new file mode 100644 index 0000000..b421fcc --- /dev/null +++ b/packages/server/scripts/verify-lifecycle.ts @@ -0,0 +1,416 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { PERMISSION_KEYS, defaultPromotionPlan, 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, loadEffectivePermissions } 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'; +import * as permissions from '../src/modules/permissions/permissions.service.js'; +import { + configurePromotion, + previewPromotion, + runPromotion, + promotionStatus, +} from '../src/modules/term/promotion.service.js'; +import { deleteArchive, getArchivedYearDetail } from '../src/modules/archives/archives.service.js'; +import { updateStudentSchema } from '../src/modules/students/students.schemas.js'; +import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js'; + +if (!process.env.DATABASE_URL?.includes('lifecycle-verification')) + throw new Error('只能对生命周期专用临时数据库运行'); +let count = 0; +function check(name: string, fn: () => unknown) { + fn(); + console.log(`通过 ${++count}:${name}`); +} +async function denied(action: Promise, status: number) { + await assert.rejects(action, (error: unknown) => (error as { status?: number }).status === status); +} +const account = async (name: string, role: string) => { + const row = await prisma.user.create({ + data: { username: name, name, role, passwordHash: '隔离测试,不登录' }, + }); + return { sub: row.id, username: row.username, name, role, classId: null } as TokenPayload; +}; +const admin = await account('临时管理员', 'ADMIN'), + teacher = await account('临时班主任', 'TEACHER'), + manager = await account('临时权限协助', 'TEACHER'); +await ensureDefaultGroups(); +const cls = async ( + name: string, + grade: string, + enrollmentYear: number, + classIndex: number, + teacherId = teacher.sub, +) => prisma.class.create({ data: { name, grade, enrollmentYear, classIndex, code: name, teacherId } }); +const a = await cls('TESTA', '高一', 2025, 1), + b = await cls('TESTB', '高一', 2025, 2), + 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 }); +}); +const server = createServer(createApp()); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const base = `http://127.0.0.1:${(server.address() as { port: number }).port}/api`; +async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) { + const response = await fetch(base + url, { + method, + headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const data = await response.json(); + assert.equal(response.status, expected, `${method} ${url}: ${JSON.stringify(data)}`); + return data.data; +} +try { + check('资料 schema 拒绝班级和状态字段', () => { + assert.equal(updateStudentSchema.safeParse({ status: 'graduated' }).success, false); + assert.equal(updateStudentSchema.safeParse({ classId: b.id }).success, false); + }); + const empty = await permissions.createGroup(admin, { name: '空组', keys: [] }); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [empty.id] }); + for (const url of [ + '/classes', + `/classes/${a.id}`, + `/classes/${a.id}/students`, + `/schedules?classId=${a.id}`, + `/homeworks?classId=${a.id}`, + `/notifications?classId=${a.id}`, + '/students', + '/term', + ]) + await request(url, teacher, 'GET', undefined, 403); + await request('/homeworks', teacher, 'POST', {}, 403); + check('取消读写键立即拒绝所有对应入口', () => + 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 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('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {}); + const allRoster = await permissions.createGroup(admin, { + name: '全校名单', + scope: 'ALL', + keys: ['roster:read', 'roster:manage'], + }); + await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [allRoster.id] }); + const unassigned = await students.createStudent(delegate, { + studentNo: 'UNASSIGNED', + name: '未分班测试学生', + }); + assert( + (await students.listStudents(delegate, { includeArchived: false })).some( + (student) => student.id === unassigned.id, + ), + ); + assert((await students.listTransfers(delegate, { studentId: unassigned.id })).items.length > 0); + check('全校名单授权包含未分班学生及其历史', () => {}); + + const readAll = await permissions.createGroup(admin, { + name: '全校班级只读', + scope: 'ALL', + keys: ['class:read'], + }); + const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:manage'] }); + 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); + check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {}); + const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } }); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] }); + assert((await loadEffectivePermissions(teacher)).has('roster:manage')); + const additional = await permissions.createGroup(admin, { + name: '追加升级权限', + scope: 'ALL', + keys: ['promotion:manage'], + }); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] }); + check('无组回落、显式默认组与多组并集', () => {}); + assert((await loadEffectivePermissions(teacher)).has('promotion:manage')); + await denied( + permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }), + 403, + ); + 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('跨角色默认组、越键、越范围和默认组编辑均拒绝', () => {}); + 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(); + check('成员含真实权限与范围,组成员包含默认回落账号', () => + assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('promotion:manage')), + ); + const audits = await permissions.listAudits(); + check('审计记录操作人与变更前后快照', () => + assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)), + ); + let queryCount = 0; + const restoreMethods: (() => void)[] = []; + for (const model of [prisma.permissionGroup, prisma.userPermissionGroup, prisma.user]) { + for (const method of ['findMany', 'findUnique', 'upsert']) { + const original = Reflect.get(model, method); + Object.defineProperty(model, method, { + configurable: true, + value: (...args: unknown[]) => { + queryCount++; + return Reflect.apply(original, model, args); + }, + }); + restoreMethods.push(() => + Object.defineProperty(model, method, { configurable: true, value: original }), + ); + } + } + try { + await permissions.listMembers(); + const small = queryCount; + queryCount = 0; + await prisma.user.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + username: `批量账号${i}`, + name: `成员${i}`, + role: 'TEACHER', + passwordHash: '临时', + })), + }); + await permissions.listMembers(); + check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small)); + } finally { + restoreMethods.forEach((restore) => restore()); + } + const scheduleOnly = await permissions.createGroup(admin, { name: '仅课表编辑', keys: ['schedule:write'] }); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [scheduleOnly.id] }); + const layoutPreview = { + classId: a.id, + payload: { TimeLayouts: [{ Name: '第一节', StartTime: '08:00', EndTime: '08:45', TimeType: 0 }] }, + }; + await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview, 403); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] }); + const parsedLayout = await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview); + check('课表导入键取消后真实预览入口 403,有键可解析', () => assert.equal(parsedLayout.items.length, 1)); + const student = await students.createStudent(admin, { studentNo: 'S1', name: '学生一', classId: a.id }); + await request(`/students/${student.id}`, admin, 'PATCH', { status: 'graduated' }, 400); + await request(`/students/${student.id}`, admin, 'PATCH', { classId: b.id }, 400); + check('HTTP 旧编辑请求明确返回 400', () => {}); + await prisma.$executeRawUnsafe( + `CREATE TRIGGER lifecycle_fail BEFORE INSERT ON StudentClassTransfer BEGIN SELECT RAISE(ABORT, '历史写入故障'); END`, + ); + await assert.rejects(students.transferStudents(admin, { studentIds: [student.id], toClassId: b.id })); + assert.equal((await prisma.student.findUniqueOrThrow({ where: { id: student.id } })).classId, a.id); + await prisma.$executeRawUnsafe('DROP TRIGGER lifecycle_fail'); + check('注入历史写入故障后学生班级事务回滚', () => {}); + const moved = await students.transferStudents(teacher, { + studentIds: [student.id], + toClassId: b.id, + note: '同年级调班', + }); + check('班主任调班按名单键授权、批次精确返回、两班广播', () => { + assert.equal(moved.transfers[0].fromGrade, '高一'); + assert(moved.batchId); + assert(events.some((e) => e.classId === a.id) && events.some((e) => e.classId === b.id)); + }); + await denied(students.transferStudents(teacher, { studentIds: [student.id], toClassId: c.id }), 403); + await denied(students.transferStudents(admin, { studentIds: [student.id], toClassId: c.id }), 409); + check('源目标范围与跨年级调班限制生效', () => {}); + await students.transferOutStudents(admin, { studentIds: [student.id], note: '迁居', toSchool: '另一学校' }); + const history = await students.listTransfers(admin, { studentId: student.id }); + check('转出状态筛选与转出历史均可见', () => { + assert(history.items.some((h) => h.kind === 'transfer-out' && h.toSchool === '另一学校')); + }); + assert.equal( + (await students.listStudents(admin, { status: 'transferred', includeArchived: false })).length, + 1, + ); + await request(`/students/${student.id}/transfers`, teacher); + await denied(students.transferOutStudents(admin, { studentIds: [student.id] }), 409); + await students.transferInStudent(admin, { studentId: student.id, toClassId: a.id, fromSchool: '另一学校' }); + await students.transferInStudent(admin, { + studentNo: 'S2', + name: '学生二', + toClassId: a.id, + fromSchool: '其他学校', + }); + check('重复转出拒绝,新建转入与历史学生重入有完整记录', () => {}); + await students.deleteStudent(admin, student.id); + assert.equal( + (await students.listStudents(admin, { status: 'inactive', includeArchived: false })).length, + 1, + ); + await students.restoreStudent(admin, student.id, { toClassId: a.id }); + await denied(students.deleteStudent(admin, student.id, { hard: true, confirm: true }), 409); + check('停用、复学和有历史学生的硬删除保护', () => {}); + const device: TokenPayload = { + sub: a.id, + role: 'CLASS_DEVICE', + classId: a.id, + name: a.name, + username: a.code, + classSession: true, + }; + for (const url of [ + `/classes/${a.id}/students`, + `/schedules?classId=${a.id}`, + `/notifications?classId=${a.id}`, + ]) + await request(url, device); + await request( + '/homeworks', + device, + 'POST', + { classId: a.id, title: '临时作业', content: '完成练习', assignDate: '2026-10-23' }, + 201, + ); + await request( + '/notifications', + device, + 'POST', + { classId: a.id, title: '临时通知', content: '隔离验收' }, + 201, + ); + await request('/grades', device, 'POST', {}, 403); + await request( + '/calls', + device, + 'POST', + { classId: a.id, studentId: student.id, quickPhrase: '请到办公室' }, + 201, + ); + check('班级端空权限保留名单课表通知和代录作业,禁止写成绩', () => {}); + const g = await cls('TESTG', '高三', 2023, 1); + for (const [i, status] of ['active', 'transferred', 'inactive'].entries()) + await prisma.student.create({ + data: { studentNo: `G${i}`, name: `毕业班学生${i}`, classId: g.id, status }, + }); + await prisma.termWeek.create({ + data: { classId: a.id, weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' }, + }); + const plan = defaultPromotionPlan(1); + plan.resetTermWeeks = false; + plan.termStartDate = '2026-09-08'; + await configurePromotion({ month: 8, day: 1, hour: 0, plan }, admin); + const before = await prisma.promotionRun.count(); + const preview = await previewPromotion(2026); + check('预览逐班逐状态、不写库、保留周次有警告', () => { + assert.equal(preview.blockers.length, 0); + assert.equal(preview.termWeekDeleteCount, 0); + assert(preview.tracks[0].classes.find((c) => c.id === g.id)?.transferred === 1); + }); + assert.equal(await prisma.promotionRun.count(), before); + await denied(runPromotion(2026, { actor: admin, planHash: '0'.repeat(64), confirm: true }), 409); + const due = (await promotionStatus()).year; + await denied(runPromotion(due + 1, { actor: admin, planHash: preview.planHash, confirm: true }), 400); + await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] }); + await denied(runPromotion(due - 1, { actor: teacher, planHash: preview.planHash, confirm: true }), 403); + check('过期哈希、未来年份与教师补跑过去学年均拒绝', () => {}); + const result = await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true }); + assert.equal(result.status, 'done'); + if (!('snapshotPath' in result)) throw new Error('执行未返回快照'); + const awaitedContent = await fs.readFile(result.snapshotPath); + check('快照 SHA256 与磁盘内容一致、执行记录有操作人明细', () => + assert.equal(result.snapshotSha256, createHash('sha256').update(awaitedContent).digest('hex')), + ); + const archived = await prisma.archivedYear.findUniqueOrThrow({ where: { enrollmentYear: 2023 } }); + const detail = await getArchivedYearDetail(admin, archived.id); + check('毕业转出停用全部挂届别、状态保持且统计完整', () => { + assert.equal(detail.graduates.length, 1); + assert.equal(detail.transferred.length, 1); + assert.equal(detail.inactive.length, 1); + assert.equal(detail.inactiveCount, 1); + }); + assert.equal(await prisma.termWeek.count(), 1); + const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2026, grade: '高一' } }); + assert.equal(skeleton.teacherId, null); + assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0); + assert.equal( + (await prisma.appSetting.findUniqueOrThrow({ where: { key: 'term.startDate' } })).value, + '2026-09-08', + ); + await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true }); + assert.equal(await prisma.archivedYear.count(), 1); + check('升级日期按计划、周次保留、新班无任课、重复运行幂等', () => {}); + await denied(classes.addStudent(admin, a.id, { studentNo: 'G0', name: '不能复活' }), 409); + await denied(students.transferOutStudents(admin, { studentIds: [detail.graduates[0].id] }), 409); + await students.restoreStudent(admin, detail.transferred[0].id, { toClassId: a.id }); + check('归档学生不可复活或转出,归档转出学生可指定在读班撤回', () => {}); + await deleteArchive(admin, archived.id); + assert.equal((await prisma.student.findUniqueOrThrow({ where: { studentNo: 'G2' } })).status, 'inactive'); + check('撤销归档保留停用状态,不回滚升级周次', () => assert.equal(detail.inactive[0].status, 'inactive')); + const unknown = await cls('TESTUNKNOWN', '未知学段', 2022, 10); + assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'UNKNOWN_GRADE')); + await prisma.class.delete({ where: { id: unknown.id } }); + await prisma.class.create({ + data: { name: 'CODECONFLICT', code: 'N202701', grade: '高一', enrollmentYear: 2027, classIndex: 2 }, + }); + check('未知年级与新班码冲突在预览阶段阻断', () => {}); + assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'CODE_CONFLICT')); + await prisma.class.deleteMany({ where: { name: 'CODECONFLICT' } }); + const duplicate = await cls('DUPLICATE', '高二', 2025, 1); + assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'DUPLICATE_INDEX')); + await prisma.class.delete({ where: { id: duplicate.id } }); + check('同轨道同届重复班号被阻断', () => {}); + await request(`/classes/${a.id}`, admin, 'PATCH', { grade: '任意年级' }, 400); + await denied(classes.promoteClasses(admin, { classIds: [c.id] }), 409); + await classes.promoteClasses(admin, { classIds: [a.id] }); + assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: a.id } })).grade, '高三'); + check('手动升级只到下一学段,毕业班与直接修改年级被拒绝', () => {}); + const junior = await cls('JUNIOR', '初一', 2025, 1); + const multi = defaultPromotionPlan(0); + multi.tracks.push({ + id: 'junior', + name: '初中', + entryClassCount: 0, + stages: [{ grade: '初一' }, { grade: '初二' }, { grade: '初三', terminal: true }], + }); + await configurePromotion({ month: 8, day: 1, hour: 0, plan: multi }, admin); + const multiPreview = await previewPromotion(2099); + assert.equal(multiPreview.blockers.length, 0); + assert.equal(multiPreview.termWeekDeleteCount, 1); + await runPromotion(2099); + check('多轨道分别升级,确认重置后仅清除预览中的周次', () => {}); + assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: junior.id } })).grade, '初二'); + assert.equal(await prisma.termWeek.count(), 0); + const snapshot = await exportSnapshot(prisma, 'sqlite'); + const auditCount = snapshot.counts.PermissionAudit; + await restoreSnapshot(prisma, 'sqlite', snapshot); + check('全库快照恢复保留审计、生命周期和执行明细', () => assert(auditCount > 0)); + assert.equal(await prisma.permissionAudit.count(), auditCount); + console.log(`=== 权限与生命周期:${count}/${count} 项通过 ===`); +} finally { + await new Promise((resolve) => server.close(() => resolve())); + await prisma.$disconnect(); +} diff --git a/packages/server/scripts/verify-v14.ts b/packages/server/scripts/verify-v14.ts index f48704d..ac4a0e1 100644 --- a/packages/server/scripts/verify-v14.ts +++ b/packages/server/scripts/verify-v14.ts @@ -476,6 +476,8 @@ try { assert.equal(value.supported, false); assert(value.detail.length > 10); }); + // 范围测试的初中班不属于默认高中计划,结束该测试夹具后再升级高中;未知年级阻断另由 lifecycle 验收。 + await prisma.class.delete({ where: { id: third.id } }); // 此文件开头已强制临时库,实际升级不会触及演示或业务数据。 await prisma.class.create({ data: { diff --git a/packages/server/src/lib/access.ts b/packages/server/src/lib/access.ts index 40fd118..f03c995 100644 --- a/packages/server/src/lib/access.ts +++ b/packages/server/src/lib/access.ts @@ -1,7 +1,5 @@ import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared'; import { - canAssignTeachers, - canManageClasses, canManageGrades, canManageRoster, canManageSchedule, @@ -13,6 +11,8 @@ import { import { teacherScopeWhere } from './permission-scope.js'; 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'; /** @@ -55,6 +55,14 @@ export function isClassDevice(user: TokenPayload): boolean { return user.role === 'CLASS_DEVICE'; } +/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */ +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('请先为班级配置班主任,再在班级端发布内容'); + return record.teacherId; +} + /** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */ export function classDeviceClassId(user: TokenPayload): string | null { return isClassDevice(user) ? (user.classId ?? user.sub) : null; @@ -88,11 +96,15 @@ export async function getAccessibleClassIds( return classId ? [classId] : []; } -export async function canAccessClass(user: TokenPayload, classId: string): Promise { +export async function canAccessClass( + user: TokenPayload, + classId: string, + permission?: ContractPermissionKey, +): Promise { if (isAdmin(user)) return true; if (isTeacher(user)) { const count = await prisma.class.count({ - where: { AND: [{ id: classId }, await teacherScopeWhere(user)] }, + where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] }, }); return count > 0; } @@ -100,10 +112,14 @@ export async function canAccessClass(user: TokenPayload, classId: string): Promi } /** 读权限断言 */ -export async function assertClassAccess(user: TokenPayload, classId: string): Promise { +export async function assertClassAccess( + user: TokenPayload, + classId: string, + permission?: ContractPermissionKey, +): Promise { const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); if (!exists) throw ApiError.notFound('班级不存在'); - if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据'); + if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据'); } /** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */ @@ -135,17 +151,38 @@ export async function resolveUserClassRole(user: TokenPayload, classId: string): } /** 班级本身(增删改)与人员分配:仅管理员 */ -export function assertCanManageClasses(user: TokenPayload): void { - if (!canManageClasses(user.role)) { - throw ApiError.forbidden('只有管理员可以创建、修改或删除班级'); - } +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 assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) { + await assertPermission(user, key); + if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围'); } /** 班主任 / 各科科任老师的分配:仅管理员 */ -export function assertCanAssignTeachers(user: TokenPayload): void { - if (!canAssignTeachers(user.role)) { - 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: ContractPermissionKey) { + await assertPermission(user, key); + if (isAdmin(user)) return; + const count = await prisma.class.count({ + where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] }, + }); + if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内'); +} +export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) { + for (const id of new Set(ids)) await assertClassInScope(user, id, key); } /** @@ -154,11 +191,14 @@ export function assertCanAssignTeachers(user: TokenPayload): void { * @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员 */ export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise { + await assertPermission(user, 'roster:manage'); if (!classId) { - if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行'); + if (!(await hasAllScope(user, 'roster:manage'))) + throw ApiError.forbidden('未分班学生操作需要全校名单范围'); return; } - await assertClassAccess(user, classId); + 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( @@ -182,6 +222,17 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin } } +/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */ +export async function assertCanImportSchedule( + user: TokenPayload, + classId: string, + writeKey: ContractPermissionKey = 'schedule:write', +): Promise { + await assertClassInScope(user, classId, 'import:schedule'); + await assertClassInScope(user, classId, writeKey); + await assertCanManageSchedule(user, classId); +} + /** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */ export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise { await assertClassAccess(user, classId); @@ -298,10 +349,13 @@ export async function resolveClassScope( permission?: ContractPermissionKey, ): Promise { if (requestedClassId) { - await assertClassAccess(user, requestedClassId); + await assertClassAccess(user, requestedClassId, permission); 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); // 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错 diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index 9b0d52f..4b440fd 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -458,7 +458,13 @@ export interface StudentTransferLike { toClassName: string; operatorId: string | null; operatorName: string; - mode: string; + kind: string; + batchId: string; + academicYear: number | null; + fromGrade: string; + toGrade: string; + fromSchool: string; + toSchool: string; note?: string | null; createdAt: Date; } @@ -475,7 +481,15 @@ export function toStudentTransferDto(item: StudentTransferLike): StudentTransfer toClassName: item.toClassName, operatorId: item.operatorId, operatorName: item.operatorName, - mode: item.mode as StudentTransferDto['mode'], + kind: item.kind as StudentTransferDto['kind'], + batchId: item.batchId, + academicYear: item.academicYear, + fromGrade: item.fromGrade, + toGrade: item.toGrade, + fromSchool: item.fromSchool, + toSchool: item.toSchool, + mode: + item.kind === 'transfer-out' ? 'transfer-out' : item.kind === 'restore' ? 'restore-transfer' : 'single', note: item.note ?? '', createdAt: toIso(item.createdAt), }; @@ -492,6 +506,7 @@ export interface ArchivedYearLike { classCount: number; studentCount: number; transferredCount: number; + inactiveCount: number; homeworkCount: number; notificationCount: number; archivedAt: Date; @@ -509,6 +524,7 @@ export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto { classCount: item.classCount, studentCount: item.studentCount, transferredCount: item.transferredCount, + inactiveCount: item.inactiveCount, homeworkCount: item.homeworkCount, notificationCount: item.notificationCount, archivedAt: toIso(item.archivedAt), diff --git a/packages/server/src/lib/permission-scope.ts b/packages/server/src/lib/permission-scope.ts index e75a68e..f3c7845 100644 --- a/packages/server/src/lib/permission-scope.ts +++ b/packages/server/src/lib/permission-scope.ts @@ -1,32 +1,52 @@ import { prisma } from './db.js'; import type { TokenPayload } from './jwt.js'; import type { Prisma } from '../generated/prisma/client.js'; -import type { PermissionKey } from '@classhelper/shared'; +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 } } }] }; - if (!assignments.length) return own; + 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 assignments) { + for (const group of groups) { if (permission && !group.items.some((item) => item.key === permission)) continue; - if (group.scope === 'ALL') return {}; - if (group.scope === 'SELF_CLASS') - alternatives.push(group.scopeValue === 'HEAD' ? { teacherId: user.sub } : own); - if (group.scope === 'GRADE' || group.scope === 'DIVISION') { - const grades = group.scopeValue - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (grades.length) alternatives.push({ grade: { in: grades } }); + 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 f0d8c1f..5ac2406 100644 --- a/packages/server/src/lib/permissions.ts +++ b/packages/server/src/lib/permissions.ts @@ -8,6 +8,37 @@ import { } 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); +} /** * 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。 diff --git a/packages/server/src/lib/settings.ts b/packages/server/src/lib/settings.ts index e80299e..eca8575 100644 --- a/packages/server/src/lib/settings.ts +++ b/packages/server/src/lib/settings.ts @@ -30,6 +30,7 @@ export const SETTING_KEYS = { termStartDate: 'term.startDate', termMaxWeeks: 'term.maxWeeks', promotionAt: 'term.promotionAt', + promotionPlan: 'term.promotionPlan', } as const; export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts index da2d5ad..4c5adf1 100644 --- a/packages/server/src/lib/snapshot.ts +++ b/packages/server/src/lib/snapshot.ts @@ -34,6 +34,7 @@ export const SNAPSHOT_VERSION = 1; */ export const SNAPSHOT_TABLES = [ 'User', + 'PermissionAudit', 'PermissionGroup', 'PermissionGroupItem', 'UserPermissionGroup', @@ -138,7 +139,7 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn for (const table of SNAPSHOT_TABLES) { const rows = (snapshot.data as Record)[table]; // 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。 - if (rows === undefined && table.startsWith('Courseware')) continue; + if (rows === undefined && (table.startsWith('Courseware') || table === 'PermissionAudit')) continue; if (!Array.isArray(rows)) { throw new Error(`快照缺少表 ${table} 的数据`); } @@ -182,6 +183,19 @@ export async function restoreSnapshot( } for (const table of SNAPSHOT_TABLES) { const rows = (snapshot.data[table] ?? []).map((row) => { + if (table === 'StudentClassTransfer') { + const { mode, ...rest } = row; + return { + ...rest, + kind: + rest.kind ?? + (mode === 'transfer-out' + ? 'transfer-out' + : mode === 'restore-transfer' + ? 'restore' + : 'class-change'), + }; + } if (table !== 'Class' || typeof row !== 'object' || row === null) return row; const copy = { ...(row as Record) }; delete copy.notificationChannel; diff --git a/packages/server/src/lib/student-lifecycle.ts b/packages/server/src/lib/student-lifecycle.ts new file mode 100644 index 0000000..1466515 --- /dev/null +++ b/packages/server/src/lib/student-lifecycle.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'node:crypto'; +import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared'; +import { prisma } from './db.js'; +import { ApiError } from './http.js'; +import { assertCanManageRoster } from './access.js'; +import type { TokenPayload } from './jwt.js'; +import { toStudentTransferDto } from './mappers.js'; +import { emitToClass } from '../realtime/bus.js'; + +export function notifyStudentClasses(ids: (string | null)[]) { + for (const classId of new Set(ids.filter((id): id is string => Boolean(id)))) + emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); +} + +/** 生命周期写入只有此入口;数据、班级快照与历史在同一事务提交。 */ +export async function changeStudentLifecycle( + user: TokenPayload, + input: { + studentIds: string[]; + kind: StudentLifecycleKind; + toClassId?: string | null; + note?: string; + fromSchool?: string; + toSchool?: string; + allowGradeChange?: boolean; + newStudent?: { + studentNo: string; + name: string; + gender?: string; + guardianPhone?: string; + status?: 'active' | 'inactive'; + }; + }, +) { + const batchId = randomUUID(); + const ids = [...new Set(input.studentIds)]; + const existing = ids.length ? await 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); + const changedClasses: (string | null)[] = []; + const result = await prisma.$transaction(async (tx) => { + 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('目标班级已归档,不能调入'); + if (['transfer-in', 'restore'].includes(input.kind) && !target) + throw ApiError.badRequest('请指定在读班级'); + const students = ids.length + ? await tx.student.findMany({ where: { id: { in: ids } }, include: { class: true } }) + : []; + if (input.newStudent) { + if (await tx.student.findUnique({ where: { studentNo: input.newStudent.studentNo } })) + throw ApiError.conflict('学号已被占用,请使用转入或复学入口'); + const created = await tx.student.create({ + data: { ...input.newStudent, classId: target?.id ?? null }, + include: { class: true }, + }); + students.push(created); + } + if (!students.length) throw ApiError.badRequest('请至少选择一名学生'); + const records = []; + for (const student of students) { + const isNew = Boolean(input.newStudent); + const prior = existing.find((item) => item.id === student.id); + if ( + prior && + (prior.classId !== student.classId || + prior.status !== student.status || + prior.archivedYearId !== student.archivedYearId) + ) + throw ApiError.conflict('学生状态已变化,请刷新后重试'); + // 撤回转出是唯一允许将归档转出记录迁入其他在读班的路径。 + if ( + student.status === 'graduated' || + (!isNew && + (student.archivedYearId || student.class?.archivedYearId) && + !(student.status === 'transferred' && input.kind === 'restore')) + ) + throw ApiError.conflict('该生已毕业或已归档,请先在归档页撤销归档'); + if (!isNew) { + const allowed: Record = { + 'class-change': ['active'], + unassign: ['active'], + 'transfer-out': ['active', 'inactive'], + 'transfer-in': ['transferred', 'inactive'], + restore: ['transferred', 'inactive'], + deactivate: ['active'], + }; + if (!allowed[input.kind].includes(student.status)) + throw ApiError.conflict('当前学生状态不允许此操作,请刷新名单并使用对应生命周期入口'); + } + const from = isNew ? null : student.class; + if (!isNew && input.kind === 'class-change' && student.classId === target?.id) continue; + if ( + input.kind === 'class-change' && + from && + target && + from.grade !== target.grade && + !(user.role === 'ADMIN' && input.allowGradeChange && input.note?.trim()) + ) + throw ApiError.conflict('跨年级调班需要管理员勾选允许跨年级并填写备注'); + const keepClass = ['transfer-out', 'deactivate'].includes(input.kind); + const nextClassId = keepClass ? student.classId : (target?.id ?? null); + const nextStatus = + input.kind === 'transfer-out' + ? 'transferred' + : input.kind === 'deactivate' + ? 'inactive' + : isNew + ? student.status + : 'active'; + await tx.student.update({ + where: { id: student.id }, + data: { + classId: nextClassId, + status: nextStatus, + ...(input.kind === 'transfer-out' + ? { transferredAt: new Date(), transferNote: input.note ?? '' } + : {}), + ...(['transfer-in', 'restore'].includes(input.kind) + ? { transferredAt: null, archivedYearId: null, archivedAt: null } + : {}), + }, + }); + records.push( + await tx.studentClassTransfer.create({ + data: { + studentId: student.id, + studentNo: student.studentNo, + studentName: student.name, + kind: input.kind, + batchId, + academicYear: currentAcademicYearStart(), + fromClassId: from?.id ?? null, + fromClassName: from?.name ?? '', + fromGrade: from?.grade ?? '', + toClassId: keepClass ? null : (target?.id ?? null), + toClassName: + input.kind === 'transfer-out' + ? '转出本校' + : input.kind === 'deactivate' + ? '停用' + : (target?.name ?? '未分班'), + toGrade: keepClass ? '' : (target?.grade ?? ''), + fromSchool: input.fromSchool ?? '', + toSchool: input.toSchool ?? '', + operatorId: user.sub, + operatorName: user.name, + note: input.note ?? '', + }, + }), + ); + changedClasses.push(from?.id ?? null, nextClassId); + } + return { + batchId, + count: records.length, + transfers: records.map(toStudentTransferDto), + studentId: students[0].id, + }; + }); + notifyStudentClasses(changedClasses); + return result; +} diff --git a/packages/server/src/lib/students.ts b/packages/server/src/lib/students.ts index f1a6fea..1d83faa 100644 --- a/packages/server/src/lib/students.ts +++ b/packages/server/src/lib/students.ts @@ -1,7 +1,7 @@ /** 所有在读入口共用;归档/转出专页显式读取历史状态。 */ export function activeStudentWhere() { - return { status: { notIn: ['transferred', 'inactive'] } }; + return { status: 'active', archivedYearId: null }; } -export function isActiveStudent(student: { status: string }) { - return !activeStudentWhere().status.notIn.includes(student.status); +export function isActiveStudent(student: { status: string; archivedYearId?: string | null }) { + return student.status === 'active' && !student.archivedYearId; } diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 7ef5528..3af5fd1 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -3,7 +3,11 @@ 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 { loadEffectivePermissions } from '../lib/permissions.js'; +import { + bindRequestPermission, + bindRequestPermissions, + loadEffectivePermissions, +} from '../lib/permissions.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; /** @@ -58,6 +62,7 @@ export function authenticate(): RequestHandler { classId: null, }; req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) }; + bindRequestPermissions(authUser, req.auth.permissions!); next(); } catch (error) { next(error); @@ -100,6 +105,7 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler { next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`)); return; } + if (keys[0]) bindRequestPermission(req.auth.user, keys[0]); next(); }; } @@ -133,6 +139,8 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler { ); return; } + const matched = keys.find((key) => permissions.has(key)); + if (matched) bindRequestPermission(req.auth.user, matched); next(); }; } diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts index 0c7e14c..6f691e3 100644 --- a/packages/server/src/modules/archives/archives.module.ts +++ b/packages/server/src/modules/archives/archives.module.ts @@ -1,3 +1,4 @@ +import { restoreStudentSchema } from '../students/students.schemas.js'; import { listTransferred, restoreTransferred } from './transferred.service.js'; import { Router } from 'express'; import { sendOk } from '../../lib/http.js'; @@ -20,13 +21,14 @@ router.get('/transferred', requirePermission('archive:manage'), async (req, res) ); router.post( '/transferred/:id/restore', - requirePermission('archive:manage'), - validate({ params: idParamSchema }), - async (req, res) => sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id))), + requirePermission('roster:manage'), + validate({ params: idParamSchema, body: restoreStudentSchema }), + async (req, res) => + sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id), req.body)), ); /** - * 毕业归档(**全部接口仅管理员**)。 + * 毕业归档(**归档操作按 archive:manage 与全校范围校验**)。 * * 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里, * 通过 `GET /:id/classes/:classId/content` 只读查看。 @@ -49,7 +51,7 @@ router.get('/summary', requirePermission('archive:manage'), async (req, res) => sendOk( res, { - ...(await archiveService.getArchiveSummary()), + ...(await archiveService.getArchiveSummary(getAuthUser(req))), suggestedEnrollmentYears: suggestedEnrollmentYears(), }, '获取归档汇总成功', diff --git a/packages/server/src/modules/archives/archives.schemas.ts b/packages/server/src/modules/archives/archives.schemas.ts index f486017..ce17817 100644 --- a/packages/server/src/modules/archives/archives.schemas.ts +++ b/packages/server/src/modules/archives/archives.schemas.ts @@ -2,14 +2,6 @@ import { z } from 'zod'; import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared'; import { enrollmentYearSchema } from '../../lib/schemas.js'; -/** 执行毕业归档:按**届别**(入学年份)把整届班级与学生归档 */ -export const createArchiveSchema = z.object({ - enrollmentYear: enrollmentYearSchema, - /** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */ - graduationYear: z.coerce.number().int().min(2000).max(2200).optional(), - note: z.string().trim().max(200).optional(), -}); - /** 届别列表的查询参数 */ export const listArchivesQuerySchema = z.object({ /** 只看某一届 */ diff --git a/packages/server/src/modules/archives/archives.service.ts b/packages/server/src/modules/archives/archives.service.ts index 4b0bbc0..3945d9e 100644 --- a/packages/server/src/modules/archives/archives.service.ts +++ b/packages/server/src/modules/archives/archives.service.ts @@ -1,12 +1,10 @@ import { SOCKET_EVENTS, - formatYearLabel, - graduationYearOf, type ArchivedClassContentDto, type ArchivedYearDetailDto, type ArchivedYearDto, } from '@classhelper/shared'; -import { assertCanManageClasses } from '../../lib/access.js'; +import { assertGlobalPermission } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -19,10 +17,6 @@ import { } from '../../lib/mappers.js'; import { emitToClass } from '../../realtime/bus.js'; import { logger } from '../../lib/logger.js'; -import type { createArchiveSchema } from './archives.schemas.js'; -import type { z } from 'zod'; - -type CreateArchiveInput = z.infer; /** * 毕业归档。 @@ -36,12 +30,12 @@ type CreateArchiveInput = z.infer; * **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二), * 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。 * - * 全部接口**仅管理员**。 + * 全部接口受 archive:manage 与全校范围保护。 */ /** 届别列表(按入学年份倒序) */ export async function listArchivedYears(user: TokenPayload): Promise { - assertCanManageClasses(user); + await assertGlobalPermission(user, 'archive:manage'); const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } }); return records.map(toArchivedYearDto); } @@ -51,7 +45,7 @@ export async function getArchivedYearDetail( user: TokenPayload, archiveId: string, ): Promise { - assertCanManageClasses(user); + await assertGlobalPermission(user, 'archive:manage'); const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } }); if (!record) throw ApiError.notFound('该届别档案不存在'); @@ -72,11 +66,18 @@ export async function getArchivedYearDetail( ]); const dtos = students.map(toStudentDto); + const transferredIn = await prisma.studentClassTransfer.findMany({ + where: { kind: 'transfer-in', student: { archivedYearId: archiveId } }, + distinct: ['studentId'], + select: { studentId: true }, + }); return { ...toArchivedYearDto(record), + transferInCount: transferredIn.length, classes: classes.map(toArchivedClassDto), graduates: dtos.filter((item) => item.status === 'graduated'), transferred: dtos.filter((item) => item.status === 'transferred'), + inactive: dtos.filter((item) => item.status === 'inactive'), }; } @@ -92,7 +93,7 @@ export async function getArchivedClassContent( classId: string, limit = 200, ): Promise { - assertCanManageClasses(user); + await assertGlobalPermission(user, 'archive:manage'); const record = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, name: true, archivedYearId: true }, @@ -132,99 +133,6 @@ export async function getArchivedClassContent( }; } -/** - * 执行归档:把某一届的全部班级与学生标记为已归档。 - * - * 步骤: - * 1. 找到该届**尚未归档**的班级(`enrollmentYear` 匹配且 `archivedYearId` 为空); - * 2. 把这些班的学生标记为 `graduated`;把其中已经 `transferred` 的保持原状态; - * 3. 统计班级数 / 学生数 / 转出数 / 作业数 / 通知数写进档案快照; - * 4. 全部在**一个事务**里完成,中途失败不会留下"班归档了、学生没归档"的半截状态。 - */ -export async function createArchive( - user: TokenPayload, - input: CreateArchiveInput, -): Promise { - assertCanManageClasses(user); - - const existing = await prisma.archivedYear.findUnique({ - where: { enrollmentYear: input.enrollmentYear }, - select: { id: true }, - }); - if (existing) throw ApiError.conflict(`${formatYearLabel(input.enrollmentYear)} 已经归档过了`); - - const classes = await prisma.class.findMany({ - where: { enrollmentYear: input.enrollmentYear, archivedYearId: null }, - select: { id: true }, - }); - if (classes.length === 0) { - throw ApiError.badRequest( - `没有找到 ${formatYearLabel(input.enrollmentYear)} 的在读班级(可能已归档,或入学年份没填对)`, - ); - } - - const classIds = classes.map((item) => item.id); - const [students, homeworkCount, notificationCount] = await Promise.all([ - prisma.student.findMany({ - where: { classId: { in: classIds }, archivedYearId: null }, - select: { id: true, status: true }, - }), - prisma.homework.count({ where: { classId: { in: classIds } } }), - prisma.notification.count({ where: { classId: { in: classIds } } }), - ]); - - const transferredCount = students.filter((item) => item.status === 'transferred').length; - const graduateCount = students.length - transferredCount; - - const created = await prisma.$transaction(async (tx) => { - const archive = await tx.archivedYear.create({ - data: { - enrollmentYear: input.enrollmentYear, - graduationYear: input.graduationYear ?? graduationYearOf(input.enrollmentYear), - name: formatYearLabel(input.enrollmentYear), - note: input.note ?? '', - operatorId: user.sub, - operatorName: user.name, - classCount: classes.length, - studentCount: graduateCount, - transferredCount, - homeworkCount, - notificationCount, - }, - select: { id: true }, - }); - - const now = new Date(); - await tx.class.updateMany({ - where: { id: { in: classIds } }, - data: { archivedYearId: archive.id, archivedAt: now }, - }); - - // 在读的 → 已毕业;已经转出的保持 transferred(他们的归档记录也一并挂到这一届) - if (students.length > 0) { - await tx.student.updateMany({ - where: { id: { in: students.filter((s) => s.status !== 'transferred').map((s) => s.id) } }, - data: { status: 'graduated', archivedYearId: archive.id, archivedAt: now }, - }); - await tx.student.updateMany({ - where: { id: { in: students.filter((s) => s.status === 'transferred').map((s) => s.id) } }, - data: { archivedYearId: archive.id, archivedAt: now }, - }); - } - - return archive; - }); - - for (const classId of classIds) { - emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' }); - } - logger.info( - `毕业归档:${formatYearLabel(input.enrollmentYear)} 班级=${classes.length} 毕业生=${graduateCount} 转出=${transferredCount} 由 ${user.name} 执行`, - ); - - return getArchivedYearDetail(user, created.id); -} - /** * 撤销归档(管理员误操作的退路)。 * @@ -232,7 +140,7 @@ export async function createArchive( * **不会删除任何作业或通知** —— 它们本来就没被删过。 */ export async function deleteArchive(user: TokenPayload, archiveId: string): Promise { - assertCanManageClasses(user); + await assertGlobalPermission(user, 'archive:manage'); const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } }); if (!record) throw ApiError.notFound('该届别档案不存在'); @@ -265,12 +173,13 @@ export async function deleteArchive(user: TokenPayload, archiveId: string): Prom } /** 归档页顶部的汇总(一届一行之外的全局数字) */ -export async function getArchiveSummary(): Promise<{ +export async function getArchiveSummary(user: TokenPayload): Promise<{ archiveCount: number; classCount: number; graduateCount: number; transferredCount: number; }> { + await assertGlobalPermission(user, 'archive:manage'); const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([ prisma.archivedYear.count(), prisma.class.count({ where: { NOT: { archivedYearId: null } } }), diff --git a/packages/server/src/modules/archives/transferred.service.ts b/packages/server/src/modules/archives/transferred.service.ts index c22571e..e6f2168 100644 --- a/packages/server/src/modules/archives/transferred.service.ts +++ b/packages/server/src/modules/archives/transferred.service.ts @@ -1,10 +1,9 @@ import { prisma } from '../../lib/db.js'; -import { assertCanManageClasses } from '../../lib/access.js'; +import { assertGlobalPermission } from '../../lib/access.js'; +import { restoreStudent } from '../students/students.service.js'; import type { TokenPayload } from '../../lib/jwt.js'; -import { ApiError } from '../../lib/http.js'; -import { notifyScheduleChanged } from '../class-plans/class-plans.service.js'; export async function listTransferred(user: TokenPayload) { - assertCanManageClasses(user); + await assertGlobalPermission(user, 'archive:manage'); return prisma.student.findMany({ where: { status: 'transferred' }, select: { @@ -19,31 +18,11 @@ export async function listTransferred(user: TokenPayload) { orderBy: { transferredAt: 'desc' }, }); } -export async function restoreTransferred(user: TokenPayload, id: string) { - assertCanManageClasses(user); - const student = await prisma.student.findUnique({ where: { id }, include: { class: true } }); - if (student?.status !== 'transferred' || !student.classId || student.class?.archivedYearId) - throw ApiError.badRequest('只能撤回原班仍在读的转出记录'); - await prisma.$transaction([ - prisma.student.update({ - where: { id }, - data: { status: 'active', transferredAt: null, archivedAt: null, archivedYearId: null }, - }), - prisma.studentClassTransfer.create({ - data: { - studentId: id, - studentNo: student.studentNo, - studentName: student.name, - fromClassId: null, - toClassId: student.classId, - toClassName: student.class?.name ?? '', - operatorId: user.sub, - operatorName: user.name, - mode: 'restore-transfer', - note: '撤回转出,保留原转出记录', - }, - }), - ]); - await notifyScheduleChanged(student.classId); +export async function restoreTransferred( + user: TokenPayload, + id: string, + input: { toClassId?: string; note?: string } = {}, +) { + await restoreStudent(user, id, input); return { id, restored: true }; } diff --git a/packages/server/src/modules/calls/calls.module.ts b/packages/server/src/modules/calls/calls.module.ts index 6366373..9cb57f5 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, requirePermission } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js'; import { validate } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; @@ -13,10 +13,15 @@ router.use(authenticate()); * POST /api/calls - 叫人(老师点名让学生过来) * ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。 */ -router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => { - const user = getAuthUser(req); - sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生'); -}); +router.post( + '/', + requireStaffPermission('call:create'), + validate({ body: createCallSchema }), + async (req, res) => { + const user = getAuthUser(req); + sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生'); + }, +); export const callsModule = defineModule({ name: 'calls', diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index 03838c3..3135346 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -1,3 +1,5 @@ +import { assertStaffPermission } 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'; @@ -27,6 +29,7 @@ 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); const student = await prisma.student.findUnique({ @@ -54,7 +57,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr // 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH // (不会触发上课时段 409 拦截,但客户端按普通通知排队处理) priority: input.urgent ? 'URGENT' : 'HIGH', - createdBy: user.sub, + createdBy: await resolveContentCreator(user, input.classId), assignDate: dayKeyLocal(new Date()), }, include: { creator: creatorSelect, reads: true }, 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 25c74a5..4a39961 100644 --- a/packages/server/src/modules/class-plans/class-plans.module.ts +++ b/packages/server/src/modules/class-plans/class-plans.module.ts @@ -6,7 +6,12 @@ import { prisma } from '../../lib/db.js'; import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js'; import { sendOk, sendCreated, ApiError } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import * as schemas from './class-plans.schemas.js'; @@ -14,8 +19,15 @@ import * as service from './class-plans.service.js'; const layouts = Router(); layouts.use(authenticate()); -layouts.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => - sendOk(res, await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId)), +layouts.get( + '/', + requireStaffPermission('schedule:read'), + validate({ query: schemas.scopeQuery }), + async (req, res) => + sendOk( + res, + await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId), + ), ); layouts.post( '/', @@ -83,12 +95,16 @@ layouts.delete( const plans = Router(); plans.use(authenticate()); -plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => - sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)), +plans.get( + '/profile', + requireStaffPermission('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)), ); plans.post( '/import/profile', - requirePermission('schedule:write'), + requirePermission('schedule:write', 'import:schedule'), validate({ body: z.object({ classId: z.string().min(1), @@ -102,10 +118,15 @@ plans.post( await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode), ), ); -plans.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => { - const q = validatedQuery<{ classId?: string; week?: number }>(req); - sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week)); -}); +plans.get( + '/', + requireStaffPermission('schedule:read'), + validate({ query: schemas.scopeQuery }), + async (req, res) => { + const q = validatedQuery<{ classId?: string; week?: number }>(req); + sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week)); + }, +); plans.post( '/', requirePermission('schedule:write'), @@ -145,13 +166,18 @@ plans.post( async (req, res) => sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)), ); -plans.get('/:id/export', validate({ params: idParamSchema }), async (req, res) => { - const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); - sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout }); -}); +plans.get( + '/:id/export', + requireStaffPermission('schedule:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); + sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout }); + }, +); plans.post( '/import', - requirePermission('schedule:write'), + requirePermission('schedule:write', 'import:schedule'), validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }), async (req, res) => { const p = await service.savePlan(getAuthUser(req), req.body.plan); @@ -172,17 +198,27 @@ plans.delete( const groups = Router(); groups.use(authenticate()); -groups.get('/selection', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { - const { classId } = validatedQuery<{ classId: string }>(req); - await assertClassAccess(getAuthUser(req), classId); - const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }); - sendOk(res, value ? JSON.parse(value.value) : {}); -}); -groups.get('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { - const { classId } = validatedQuery<{ classId: string }>(req); - await assertClassAccess(getAuthUser(req), classId); - sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } })); -}); +groups.get( + '/selection', + requireStaffPermission('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); + const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }); + sendOk(res, value ? JSON.parse(value.value) : {}); + }, +); +groups.get( + '/', + requireStaffPermission('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); + sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } })); + }, +); groups.post( '/', requirePermission('schedule:write'), diff --git a/packages/server/src/modules/class-plans/profile-import.service.ts b/packages/server/src/modules/class-plans/profile-import.service.ts index c558946..0cf2aa0 100644 --- a/packages/server/src/modules/class-plans/profile-import.service.ts +++ b/packages/server/src/modules/class-plans/profile-import.service.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; import { isDayKey, dayKeyLocal } from '@classhelper/shared'; import { prisma } from '../../lib/db.js'; -import { assertCanManageSchedule } from '../../lib/access.js'; +import { assertCanImportSchedule } from '../../lib/access.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { notifyScheduleChanged } from './class-plans.service.js'; @@ -21,7 +21,7 @@ function time(value: unknown) { } /** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */ export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') { - await assertCanManageSchedule(user, classId); + await assertCanImportSchedule(user, classId); let parsed = payload; if (typeof payload === 'string') { try { diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 35e3e03..c774914 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -1,7 +1,12 @@ 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 { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -35,35 +40,45 @@ router.use(authenticate()); * 只列**在读**班级(毕业归档的班级属于「归档管理」页); * 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。 */ -router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { keyword } = validatedQuery<{ keyword?: string }>(req); - sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功'); -}); +router.get( + '/', + requireStaffPermission('class:read'), + validate({ query: listClassesQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { keyword } = validatedQuery<{ keyword?: string }>(req); + sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功'); + }, +); /** - * POST /api/classes/promote - 学年升级(批量改年级,仅管理员) + * POST /api/classes/promote - 学年升级(批量改年级,按功能权限及范围校验) * * **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、 - * 作业与成绩全部原样。归档只在毕业时由「归档管理」页显式执行。 + * 作业与成绩全部原样。毕业归档只由年度升级执行。 */ router.post( '/promote', - requirePermission('class:manage'), + requirePermission('promotion:manage'), validate({ body: promoteClassesSchema }), async (req, res) => { const user = getAuthUser(req); - const body = validatedBody<{ classIds: string[]; grade: string }>(req); + const body = validatedBody<{ classIds: string[] }>(req); sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用'); }, ); /** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */ -router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功'); -}); +router.get( + '/:id', + requireStaffPermission('class:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功'); + }, +); /** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */ router.post( @@ -76,10 +91,14 @@ router.post( }, ); -/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */ +/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */ router.patch( '/:id', requirePermission('class:manage'), + (req, _res, next) => { + if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改')); + next(); + }, validate({ params: idParamSchema, body: updateClassSchema }), async (req, res) => { const user = getAuthUser(req); @@ -103,7 +122,7 @@ router.delete( /** * PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码) - * 仅管理员:教室机器「ClassHelper 班级端」登录用的就是这个班级码 + * 按功能权限及范围校验:教室机器「ClassHelper 班级端」登录用的就是这个班级码 */ router.patch( '/:id/class-account', @@ -122,7 +141,7 @@ router.patch( /** * PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人) - * 仅管理员:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限 + * 按功能权限及范围校验:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限 */ router.patch( '/:id/head-teacher', @@ -139,7 +158,7 @@ router.patch( /** * PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师) * - * 仅管理员。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动, + * 按功能权限及范围校验。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动, * 查的就是这里写下的 `Course.teacherId`。 * 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。 */ @@ -162,14 +181,20 @@ router.put( * GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细 * PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读 */ -router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功'); -}); +router.get( + '/:id/student-grade-query', + requireStaffPermission('class:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功'); + }, +); router.patch( '/:id/student-grade-query', + requirePermission('grade:write'), validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -183,11 +208,16 @@ router.patch( ); /** GET /api/classes/:id/students - 班级学生名单 */ -router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功'); -}); +router.get( + '/:id/students', + requireStaffPermission('roster:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功'); + }, +); /** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */ router.post( diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index 7c18be0..f5a1920 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -18,7 +18,7 @@ export const createClassSchema = z.object({ enrollmentYear: enrollmentYearSchema, /** 班号(同一届内的序号) */ classIndex: classIndexSchema, - /** 仅管理员可指定班主任,教师创建时固定为自己 */ + /** 班主任可空;具备班级管理权限的员工显式指定 */ teacherId: z.string().min(1).optional(), /** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */ code: z @@ -37,11 +37,10 @@ export const createClassSchema = z.object({ /** * 编辑班级。 * - * `grade` 是**学年升级**用的:高一 → 高二 只改它,班级记录与学生数据全部沿用,**不归档**。 + * 年级只能通过升级计划修改,本接口维护届别、班号和作息覆盖。 * `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。 */ -export const updateClassSchema = z.object({ - grade: z.string().trim().min(1).max(32).optional(), +export const updateClassSchema = z.strictObject({ /** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */ termStartDate: z .union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')]) @@ -52,9 +51,8 @@ export const updateClassSchema = z.object({ }); /** 学年升级(批量改年级,不归档) */ -export const promoteClassesSchema = z.object({ +export const promoteClassesSchema = z.strictObject({ classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200), - grade: z.string().trim().min(1, '请输入升级后的年级').max(32), }); /** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index d9781de..55174e7 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -4,7 +4,6 @@ import { env } from '../../config/env.js'; import { assertCanAssignTeachers, assertCanManageClasses, - assertCanManageRoster, assertClassAccess, classScopeIdFilter, isStaff, @@ -26,7 +25,11 @@ import type { UpdateClassInput, UpdateStudentGradeQueryInput, } from './classes.schemas.js'; -import { activeStudentWhere, isActiveStudent } from '../../lib/students.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 { activeStudentWhere } from '../../lib/students.js'; import { getMaxTermWeeks } from '../../lib/term.js'; import { logger } from '../../lib/logger.js'; @@ -76,7 +79,8 @@ function toClassDtoWithAccount( * 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。 */ export async function listClasses(user: TokenPayload, keyword?: string): Promise { - const scope = await resolveClassScope(user); + await assertStaffPermission(user, 'class:read'); + const scope = await resolveClassScope(user, undefined, 'class:read'); // Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表) const where = { ...classScopeIdFilter(scope), @@ -98,6 +102,7 @@ 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); const item = await prisma.class.findUnique({ @@ -117,7 +122,12 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi const base = toClassDtoWithAccount(user, item); return { ...base, - students: item.students.map(toStudentDto), + students: + user.role === 'CLASS_DEVICE' || + ((await hasEffectivePermission(user, 'roster:read')) && + (await canAccessClass(user, classId, 'roster:read'))) + ? item.students.map(toStudentDto) + : [], courses: item.courses.map(toCourseDto), headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null, subjectTeachers: item.courses.map((course) => ({ @@ -159,7 +169,7 @@ export async function listSubjectTeachers( /** 创建班级 */ export async function createClass(user: TokenPayload, input: CreateClassInput): Promise { - assertCanManageClasses(user); + await assertCanManageClasses(user); if ( 'termWeeks' in input && typeof input.termWeeks === 'number' && @@ -167,8 +177,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput): ) throw ApiError.badRequest('班级周数不能超过全校上限'); - let teacherId = user.sub; - if (input.teacherId && user.role === 'ADMIN') { + let teacherId: string | null = null; + if (input.teacherId) { teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师'); } @@ -200,7 +210,7 @@ export async function createClass(user: TokenPayload, input: CreateClassInput): } /** - * 设置 / 更改班主任(仅管理员)。 + * 设置 / 更改班主任(按班级管理权限与范围)。 * * 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。 * 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录; @@ -211,7 +221,7 @@ export async function updateHeadTeacher( classId: string, teacherId: string, ): Promise { - assertCanAssignTeachers(user); + await assertCanAssignTeachers(user, classId); const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师'); const updated = await prisma.class.update({ @@ -228,7 +238,7 @@ export async function updateHeadTeacher( } /** - * 批量设置各科科任老师(仅管理员)。 + * 批量设置各科科任老师(按班级管理权限与范围)。 * * 入参里每条是「某班某科的任课老师」: * - 给了 `courseId` 就改那门课; @@ -242,7 +252,7 @@ export async function assignSubjectTeachers( classId: string, input: AssignSubjectTeachersInput, ): Promise { - assertCanAssignTeachers(user); + await assertCanAssignTeachers(user, classId); const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); if (!targetClass) throw ApiError.notFound('班级不存在'); @@ -288,13 +298,13 @@ export async function assignSubjectTeachers( })); } -/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ +/** 设置 / 重置班级账号(班级码 + 班级密码):按班级管理权限与范围 */ export async function updateClassAccount( user: TokenPayload, classId: string, input: { code?: string; password?: string }, ): Promise { - assertCanManageClasses(user); + await assertCanManageClasses(user, classId); if ( 'termWeeks' in input && typeof input.termWeeks === 'number' && @@ -315,13 +325,13 @@ export async function updateClassAccount( return toClassDtoWithAccount(user, updated); } -/** 编辑班级(年级 / 教学周数 / 届别) */ +/** 编辑班级资料;年级只能通过升级计划修改。 */ export async function updateClass( user: TokenPayload, classId: string, input: UpdateClassInput, ): Promise { - assertCanManageClasses(user); + await assertCanManageClasses(user, classId); const current = await prisma.class.findUnique({ where: { id: classId }, @@ -329,13 +339,15 @@ 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.grade ? { grade: input.grade } : {}), ...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}), // 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE) ...(input.termStartDate !== undefined @@ -357,38 +369,60 @@ export async function updateClass( } /** - * 学年升级(仅管理员):把若干班级的年级改掉,**不归档、数据沿用**。 + * 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。 * - * 这是「未毕业而升级的班级不归档」这条例则的操作入口 —— 高一 → 高二 只动 `grade`, - * 班级记录、学生名单、学号、作业与成绩全部原样。归档只在毕业时由归档页显式执行。 + * 从已保存计划推导下一学段;毕业学段必须走年度升级归档。保留班级业务数据。 */ export async function promoteClasses( user: TokenPayload, - input: { classIds: string[]; grade: string }, + input: { classIds: string[] }, ): Promise<{ promoted: number }> { - assertCanManageClasses(user); - const result = await prisma.class.updateMany({ - where: { id: { in: input.classIds }, archivedYearId: null }, - data: { grade: input.grade }, + await assertPermission(user, 'promotion:manage'); + const plan = await loadPromotionPlan(); + const ids = [...new Set(input.classIds)]; + for (const id of ids) await assertClassInScope(user, id, 'promotion:manage'); + await prisma.$transaction(async (tx) => { + const classes = await tx.class.findMany({ where: { id: { in: ids }, archivedYearId: null } }); + if (classes.length !== ids.length) throw ApiError.conflict('部分班级不存在或已归档'); + for (const cls of classes) { + const track = plan.tracks.find((track) => track.stages.some((stage) => stage.grade === cls.grade)); + const index = track?.stages.findIndex((stage) => stage.grade === cls.grade) ?? -1; + if (!track || index < 0) throw ApiError.conflict('班级年级不在升级计划内'); + if (track.stages[index].terminal) throw ApiError.conflict('毕业学段请通过年度升级进行归档'); + const nextGrade = track.stages[index + 1].grade; + await tx.class.update({ where: { id: cls.id }, data: { grade: nextGrade, termStartDate: null } }); + await tx.permissionAudit.create({ + data: { + actorId: user.sub, + actorName: user.name, + action: 'promote-class', + targetType: 'class', + targetId: cls.id, + beforeJson: JSON.stringify({ grade: cls.grade }), + afterJson: JSON.stringify({ grade: nextGrade }), + }, + }); + } }); - for (const classId of input.classIds) { - emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); - } - return { promoted: result.count }; + for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); + return { promoted: ids.length }; } /** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */ export async function deleteClass(user: TokenPayload, classId: string): Promise { - assertCanManageClasses(user); + await assertCanManageClasses(user); + if (await prisma.student.count({ where: { classId } })) + throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属'); emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' }); await prisma.class.delete({ where: { id: classId } }); } /** 班级学生名单 */ export async function listClassStudents(user: TokenPayload, classId: string): Promise { + await assertStaffPermission(user, 'roster:read'); await assertClassAccess(user, classId); const students = await prisma.student.findMany({ - where: { classId, archivedYearId: null, ...activeStudentWhere() }, + where: { classId, ...activeStudentWhere() }, include: { class: { select: { name: true, grade: true } } }, orderBy: { studentNo: 'asc' }, }); @@ -406,85 +440,28 @@ export async function addStudent( classId: string, input: AddStudentInput, ): Promise { - await assertCanManageRoster(user, classId); - await assertClassAccess(user, classId); - const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } }); - let studentId: string; - - if (existing && !isActiveStudent(existing)) - throw ApiError.conflict('学号已被历史学生占用;请使用新学号或在归档页撤回转出'); - if (existing) { - studentId = existing.id; - if (existing.classId !== classId) { - await prisma.studentClassTransfer.create({ - data: { - studentId: existing.id, - studentNo: existing.studentNo, - studentName: input.name || existing.name, - fromClassId: existing.classId, - fromClassName: await classLabel(existing.classId), - toClassId: classId, - toClassName: await classLabel(classId), - operatorId: user.sub, - operatorName: user.name, - mode: 'single', - note: '在班级名单里直接加入', - }, - }); - } - await prisma.student.update({ - where: { id: existing.id }, - data: { classId, name: input.name, status: 'active' }, - }); - } else { - const created = await prisma.student.create({ - data: { - studentNo: input.studentNo, - name: input.name, - classId, - gender: input.gender ?? '', - guardianPhone: input.guardianPhone ?? '', - }, - }); - studentId = created.id; - } - - const student = await prisma.student.findUniqueOrThrow({ - where: { id: studentId }, - include: { class: { select: { name: true, grade: true } } }, + if (existing && (existing.archivedYearId || existing.status !== 'active')) + throw ApiError.conflict('学号已被历史学生占用,请使用转入、复学或撤销归档入口'); + const result = await changeStudentLifecycle(user, { + studentIds: existing ? [existing.id] : [], + kind: 'class-change', + toClassId: classId, + note: '班级名单添加', + ...(!existing ? { newStudent: input } : {}), }); - - emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); - return toStudentDto(student); + return toStudentDto( + await prisma.student.findUniqueOrThrow({ + where: { id: result.studentId }, + include: { class: { select: { name: true, grade: true } } }, + }), + ); } -/** 将学生移出班级(保留名单记录,回到「未分班」) */ export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise { - await assertCanManageRoster(user, classId); - const student = await prisma.student.findUnique({ where: { id: studentId } }); - if (!student) throw ApiError.notFound('学生不存在'); - if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级'); - - await prisma.student.update({ where: { id: studentId }, data: { classId: null } }); - await prisma.studentClassTransfer.create({ - data: { - studentId: student.id, - studentNo: student.studentNo, - studentName: student.name, - fromClassId: classId, - fromClassName: await classLabel(classId), - toClassId: null, - toClassName: '未分班', - operatorId: user.sub, - operatorName: user.name, - mode: 'single', - note: '从班级名单里移出', - }, - }); - - emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); + if (!student || student.classId !== classId) throw ApiError.badRequest('学生不属于此班级'); + await changeStudentLifecycle(user, { studentIds: [studentId], kind: 'unassign', note: '移出班级' }); } /** @@ -543,10 +520,3 @@ async function requireTeacher(teacherId: string, message: string): Promise { - if (!classId) return ''; - const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } }); - return record?.name ?? ''; -} diff --git a/packages/server/src/modules/courses/courses.module.ts b/packages/server/src/modules/courses/courses.module.ts index 1ce173f..92c7349 100644 --- a/packages/server/src/modules/courses/courses.module.ts +++ b/packages/server/src/modules/courses/courses.module.ts @@ -1,7 +1,12 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -17,11 +22,16 @@ const router = Router(); router.use(authenticate()); /** GET /api/courses?classId= - 课程列表 */ -router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = validatedQuery<{ classId?: string }>(req); - sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功'); -}); +router.get( + '/', + requireStaffPermission('schedule:read'), + validate({ query: listCoursesQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { classId } = validatedQuery<{ classId?: string }>(req); + sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功'); + }, +); /** POST /api/courses - 新增课程 */ router.post( diff --git a/packages/server/src/modules/homeworks/homeworks.module.ts b/packages/server/src/modules/homeworks/homeworks.module.ts index 7999a70..322e81c 100644 --- a/packages/server/src/modules/homeworks/homeworks.module.ts +++ b/packages/server/src/modules/homeworks/homeworks.module.ts @@ -1,7 +1,12 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -25,16 +30,21 @@ const router = Router(); router.use(authenticate()); /** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */ -router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery<{ - classId?: string; - courseId?: string; - pendingOnly: boolean; - keyword?: string; - }>(req); - sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功'); -}); +router.get( + '/', + requireStaffPermission('homework:read'), + validate({ query: listHomeworksQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery<{ + classId?: string; + courseId?: string; + pendingOnly: boolean; + keyword?: string; + }>(req); + sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功'); + }, +); /** GET /api/homeworks/:id - 作业详情 */ /** @@ -42,17 +52,27 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) * * 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。 */ -router.get('/days', validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery(req); - sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功'); -}); +router.get( + '/days', + requireStaffPermission('homework:read'), + validate({ query: listHomeworkDaysQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(req); + sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功'); + }, +); -router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功'); -}); +router.get( + '/:id', + requireStaffPermission('homework:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功'); + }, +); /** * POST /api/homeworks - 发布作业(发布后广播 homework:new) @@ -62,6 +82,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { */ router.post( '/', + requireStaffPermission('homework:write'), validate({ body: createHomeworkSchema }), async (req, res) => { const user = getAuthUser(req); @@ -87,10 +108,7 @@ router.post( validate({ body: bulkCreateHomeworkSchema }), async (req, res) => { const user = getAuthUser(req); - const result = await homeworkService.bulkCreateHomeworks( - user, - req.body as BulkCreateHomeworkInput, - ); + const result = await homeworkService.bulkCreateHomeworks(user, req.body as BulkCreateHomeworkInput); sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`); }, ); @@ -127,6 +145,7 @@ router.delete( /** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */ router.patch( '/:id/status', + requireStaffPermission('homework:write'), validate({ params: idParamSchema, body: updateHomeworkStatusSchema }), async (req, res) => { const user = getAuthUser(req); @@ -141,16 +160,22 @@ router.patch( ); /** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */ -router.get('/:id/submissions', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - const result = await homeworkService.listHomeworkSubmissions(user, id); - sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`); -}); +router.get( + '/:id/submissions', + requireStaffPermission('homework:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + const result = await homeworkService.listHomeworkSubmissions(user, id); + sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`); + }, +); /** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */ router.patch( '/:id/submissions', + requireStaffPermission('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 36e74f9..2f1e4a5 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -1,3 +1,4 @@ +import { assertStaffPermission } from '../../lib/permissions.js'; import { SOCKET_EVENTS, dayKeyLocal, @@ -55,6 +56,7 @@ export async function listHomeworks( user: TokenPayload, options: ListHomeworkOptions, ): Promise { + await assertStaffPermission(user, 'homework:read'); const scope = await resolveClassScope(user, options.classId); const device = isClassDevice(user); // ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态" @@ -95,6 +97,7 @@ 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); const today = dayKeyLocal(new Date()); const to = options.to ?? today; @@ -119,6 +122,7 @@ export async function listHomeworkDays( } export async function getHomework(user: TokenPayload, homeworkId: string): Promise { + await assertStaffPermission(user, 'homework:read'); const device = isClassDevice(user); const personalIds = device ? await resolvePersonalIds(user) : []; const homework = await prisma.homework.findUnique({ @@ -152,6 +156,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi * 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。 */ export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise { + await assertStaffPermission(user, 'homework:write'); await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', { allowClassDevice: true, }); @@ -207,6 +212,7 @@ export async function bulkCreateHomeworks( user: TokenPayload, input: BulkCreateHomeworkRequest, ): Promise { + await assertStaffPermission(user, 'homework:write'); const classIds = normalizeBulkClassIds(input.classIds); const results: BulkPublishClassResult[] = []; @@ -255,6 +261,7 @@ export async function updateHomework( homeworkId: string, input: UpdateHomeworkInput, ): Promise { + await assertStaffPermission(user, 'homework:write'); const current = await prisma.homework.findUnique({ where: { id: homeworkId } }); if (!current) throw ApiError.notFound('作业不存在'); @@ -287,6 +294,7 @@ export async function updateHomework( } export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise { + await assertStaffPermission(user, 'homework:write'); const current = await prisma.homework.findUnique({ where: { id: homeworkId }, include: { course: courseSelect, creator: creatorSelect }, @@ -315,6 +323,7 @@ export async function updateHomeworkStatus( homeworkId: string, input: UpdateHomeworkStatusInput, ): Promise { + await assertStaffPermission(user, 'homework:write'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId } }); if (!homework) throw ApiError.notFound('作业不存在'); @@ -373,7 +382,7 @@ export async function updateHomeworkStatus( async function buildSubmissions(classId: string, homeworkId: string): Promise { const [students, statuses] = await Promise.all([ prisma.student.findMany({ - where: { classId, archivedYearId: null, ...activeStudentWhere() }, + where: { classId, ...activeStudentWhere() }, select: { id: true, name: true, studentNo: true }, orderBy: { studentNo: 'asc' }, }), @@ -408,6 +417,7 @@ export async function listHomeworkSubmissions( user: TokenPayload, homeworkId: string, ): Promise { + await assertStaffPermission(user, 'homework:read'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId }, select: { id: true, classId: true }, @@ -427,6 +437,7 @@ export async function updateHomeworkSubmissions( homeworkId: string, input: UpdateHomeworkSubmissionsInput, ): Promise { + await assertStaffPermission(user, 'homework:write'); const homework = await prisma.homework.findUnique({ where: { id: homeworkId }, select: { id: true, classId: true }, @@ -436,7 +447,7 @@ export async function updateHomeworkSubmissions( assertCanManageSubmissions(user); const students = await prisma.student.findMany({ - where: { classId: homework.classId, archivedYearId: null, ...activeStudentWhere() }, + where: { classId: homework.classId, ...activeStudentWhere() }, select: { id: true }, }); if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生'); diff --git a/packages/server/src/modules/imports/class-plan.service.ts b/packages/server/src/modules/imports/class-plan.service.ts index 532a70c..e226953 100644 --- a/packages/server/src/modules/imports/class-plan.service.ts +++ b/packages/server/src/modules/imports/class-plan.service.ts @@ -1,7 +1,7 @@ import { importProfile } from '../class-plans/profile-import.service.js'; import { prisma } from '../../lib/db.js'; import type { TokenPayload } from '../../lib/jwt.js'; -import { assertCanManageSchedule } from '../../lib/access.js'; +import { assertCanImportSchedule } from '../../lib/access.js'; import { weekParityFromDiv } from '@classhelper/shared'; import type { ClassPlanImportInput } from './imports.schemas.js'; @@ -243,7 +243,7 @@ export async function previewClassPlan( user: TokenPayload, input: ClassPlanImportInput, ): Promise { - await assertCanManageSchedule(user, input.classId); + await assertCanImportSchedule(user, input.classId); const parsed = parseClassIslandClassPlan(input.payload); const courses = await prisma.course.findMany({ where: { classId: input.classId }, diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts index f2c214a..9da905c 100644 --- a/packages/server/src/modules/imports/imports.module.ts +++ b/packages/server/src/modules/imports/imports.module.ts @@ -1,7 +1,15 @@ +import { assertPermission } from '../../lib/permissions.js'; +import type { TokenPayload } from '../../lib/jwt.js'; import { Router } from 'express'; import { ApiError, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, + requireAnyPermission, +} from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -28,13 +36,21 @@ import { const router = Router(); router.use(authenticate()); -/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */ -function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void { - if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') { - throw ApiError.forbidden( - kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单', - ); - } +/** 导入类型分别校验对应导入键,名单还校验管理键。 */ +async function assertImportKindAllowed( + kind: (typeof IMPORT_KINDS)[number], + user: TokenPayload, +): Promise { + const key = { + students: 'import:students', + teachers: 'import:teachers', + grades: 'grade:import', + classTeachers: 'import:class-teachers', + scheduleTimes: 'import:schedule', + } as const; + await assertPermission(user, key[kind]); + if (kind === 'students') await assertPermission(user, 'roster:manage'); + if (kind === 'teachers') await assertPermission(user, 'teacher:manage'); } /** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */ @@ -46,20 +62,14 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] { /** * GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板 - * 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员 + * 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:对应导入与管理键 */ router.get( '/template', - requireAnyPermission( - 'import:students', - 'grade:import', - 'import:teachers', - 'import:schedule', - 'import:class-teachers', - ), - (req, res) => { + requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'), + async (req, res) => { const kind = resolveImportKind(req.query.kind); - assertImportKindAllowed(kind, getAuthUser(req).role); + await assertImportKindAllowed(kind, getAuthUser(req)); const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv'; if (format === 'xlsx') { const base64 = buildTemplateXlsx(kind); @@ -78,21 +88,15 @@ router.get( /** * POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射) - * 成绩:管理员或班主任;学生/教师名单:仅管理员(与写入权限保持一致) + * 成绩:管理员或班主任;学生/教师名单按对应导入与管理键校验 */ router.post( '/table/preview', - requireAnyPermission( - 'import:students', - 'grade:import', - 'import:teachers', - 'import:schedule', - 'import:class-teachers', - ), + requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'), validate({ body: tableFileSchema }), - (req, res) => { + async (req, res) => { const input = validatedBody(req); - assertImportKindAllowed(input.kind, getAuthUser(req).role); + await assertImportKindAllowed(input.kind, getAuthUser(req)); sendOk(res, previewTable(input), '表格解析成功'); }, ); @@ -101,21 +105,15 @@ router.post( * POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计) * * 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验: - * 成绩 → 管理员或本班班主任;学生名单 → 仅管理员;科任老师一律 403(需求 7)。 + * 成绩按班级与科目收口;学生名单按管理键、导入键与班主任/全校范围校验。 */ router.post( '/table/commit', - requireAnyPermission( - 'import:students', - 'grade:import', - 'import:teachers', - 'import:schedule', - 'import:class-teachers', - ), + requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'), validate({ body: tableCommitSchema }), async (req, res) => { const input = validatedBody(req); - assertImportKindAllowed(input.kind, getAuthUser(req).role); + await assertImportKindAllowed(input.kind, getAuthUser(req)); // 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局") if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) { throw ApiError.badRequest('请先选择要导入的班级'); @@ -133,19 +131,29 @@ router.post( * POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库) * 权限与"课表管理"一致:管理员或本班班主任 */ -router.post('/time-layout/preview', validate({ body: timeLayoutImportSchema }), async (req, res) => { - const input = validatedBody(req); - // 预览也要校验班级权限(与导入一致),避免越权探测 - const { assertCanManageSchedule } = await import('../../lib/access.js'); - await assertCanManageSchedule(getAuthUser(req), input.classId); - sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成'); -}); +router.post( + '/time-layout/preview', + requirePermission('import:schedule', 'schedule:write'), + validate({ body: timeLayoutImportSchema }), + async (req, res) => { + const input = validatedBody(req); + // 预览也要校验班级权限(与导入一致),避免越权探测 + const { assertCanImportSchedule } = await import('../../lib/access.js'); + await assertCanImportSchedule(getAuthUser(req), input.classId); + sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成'); + }, +); /** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */ -router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (req, res) => { - const { classId } = validatedQuery<{ classId: string }>(req); - sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功'); -}); +router.get( + '/time-layout', + requireStaffPermission('schedule:read'), + validate({ query: timeLayoutQuerySchema }), + async (req, res) => { + const { classId } = validatedQuery<{ classId: string }>(req); + sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功'); + }, +); /** * POST /api/imports/time-layout - 导入 ClassIsland 时间配置 @@ -153,7 +161,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re */ router.post( '/time-layout', - requirePermission('timetable:manage'), + requirePermission('timetable:manage', 'import:schedule'), validate({ body: timeLayoutImportSchema }), async (req, res) => { const result = await importTimeLayout(getAuthUser(req), validatedBody(req)); @@ -183,28 +191,38 @@ router.delete( * POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库) * 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。 */ -router.post('/class-plan/preview', validate({ body: classPlanImportSchema }), async (req, res) => { - const result = await previewClassPlan(getAuthUser(req), validatedBody(req)); - sendOk( - res, - result, - `解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`, - ); -}); +router.post( + '/class-plan/preview', + requirePermission('import:schedule', 'schedule:write'), + validate({ body: classPlanImportSchema }), + async (req, res) => { + const result = await previewClassPlan(getAuthUser(req), validatedBody(req)); + sendOk( + res, + result, + `解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`, + ); + }, +); /** * POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周) * replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。 */ -router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req, res) => { - const result = await importClassPlan(getAuthUser(req), validatedBody(req)); - sendOk( - res, - result, - `导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` + - `${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`, - ); -}); +router.post( + '/class-plan', + requirePermission('import:schedule', 'schedule:write'), + validate({ body: classPlanImportSchema }), + async (req, res) => { + const result = await importClassPlan(getAuthUser(req), validatedBody(req)); + sendOk( + res, + result, + `导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` + + `${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`, + ); + }, +); export const importsModule = defineModule({ name: 'imports', diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts index e7759b9..ea9e9f0 100644 --- a/packages/server/src/modules/imports/table-import.service.ts +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -1,3 +1,7 @@ +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'; @@ -566,7 +570,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput const existing = await prisma.student.findUnique({ where: { studentNo }, - select: { id: true, classId: true, status: true }, + select: { id: true, classId: true, status: true, archivedYearId: true }, }); if (existing && !isActiveStudent(existing)) @@ -576,40 +580,16 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput skipped += 1; continue; } - if (existing.classId !== classId) { - // 换班要留痕,与手动调班同一种历史 - const [from, to] = await Promise.all([classNameOf(existing.classId), classNameOf(classId)]); - await prisma.studentClassTransfer.create({ - data: { - studentId: existing.id, - studentNo, - studentName: name, - fromClassId: existing.classId, - fromClassName: from, - toClassId: classId, - toClassName: to, - operatorId: user.sub, - operatorName: user.name, - mode: 'batch', - note: '学生名单导入', - }, - }); - } - await prisma.student.update({ - where: { id: existing.id }, - data: { - name, - classId, - status: 'active', - ...(gender ? { gender } : {}), - ...(guardianPhone ? { guardianPhone } : {}), - }, + if (existing.classId !== classId) + await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); + await updateStudent(user, existing.id, { + name, + ...(gender ? { gender } : {}), + ...(guardianPhone ? { guardianPhone } : {}), }); updated += 1; } else { - await prisma.student.create({ - data: { studentNo, name, classId, gender, guardianPhone }, - }); + await addStudent(user, classId, { studentNo, name, gender, guardianPhone }); inserted += 1; } } @@ -769,6 +749,7 @@ export async function commitClassTeachers( continue; } + await assertImportAllowed(user, targetClass.id, 'classTeachers'); const role = parseClassTeacherRole(roleCell); if (!role) { errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` }); @@ -816,6 +797,8 @@ export async function commitClassTeachers( }); continue; } + await assertPermission(user, 'teacher:manage'); + await assertPermission(user, 'import:teachers'); const created = await prisma.user.create({ data: { username: teacherNo, @@ -898,9 +881,21 @@ async function assertImportAllowed( classId: string | undefined, kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes', ): Promise { + const key = { + students: 'import:students', + teachers: 'import:teachers', + grades: 'grade:import', + classTeachers: 'import:class-teachers', + scheduleTimes: 'import:schedule', + } as const; + await assertPermission(user, key[kind]); + if (classId) await assertClassAccess(user, classId, key[kind]); + if (kind === 'students') 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 === 'students' || kind === 'grades') { + if (kind === 'grades') { throw ApiError.forbidden( classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE' ? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员' @@ -910,12 +905,6 @@ async function assertImportAllowed( throw ApiError.forbidden('该导入仅限管理员使用'); } -async function classNameOf(classId: string | null): Promise { - if (!classId) return ''; - const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } }); - return record?.name ?? ''; -} - /** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */ function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null { const value = cell.trim().toUpperCase(); diff --git a/packages/server/src/modules/imports/time-layout.service.ts b/packages/server/src/modules/imports/time-layout.service.ts index c6810ec..91e29a9 100644 --- a/packages/server/src/modules/imports/time-layout.service.ts +++ b/packages/server/src/modules/imports/time-layout.service.ts @@ -3,7 +3,7 @@ import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; -import { assertCanManageSchedule } from '../../lib/access.js'; +import { assertCanManageSchedule, assertCanImportSchedule } from '../../lib/access.js'; import type { TimeLayoutImportInput } from './imports.schemas.js'; /** @@ -324,7 +324,7 @@ export async function importTimeLayout( user: TokenPayload, input: TimeLayoutImportInput, ): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> { - await assertCanManageSchedule(user, input.classId); + await assertCanImportSchedule(user, input.classId, 'timetable:manage'); const parsed = parseClassIslandTimeLayout(input.payload); if (parsed.errors.length > 0) { diff --git a/packages/server/src/modules/notifications/notifications.module.ts b/packages/server/src/modules/notifications/notifications.module.ts index 4c952a4..2674c1e 100644 --- a/packages/server/src/modules/notifications/notifications.module.ts +++ b/packages/server/src/modules/notifications/notifications.module.ts @@ -1,7 +1,12 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -21,44 +26,69 @@ const router = Router(); router.use(authenticate()); /** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */ -router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery(req); - sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功'); -}); +router.get( + '/', + requireStaffPermission('notification:read'), + validate({ query: listNotificationsQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(req); + sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功'); + }, +); /** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */ -router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery(req); - sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功'); -}); +router.get( + '/days', + requireStaffPermission('notification:read'), + validate({ query: notificationArchiveQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(req); + sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功'); + }, +); /** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */ -router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery(req); - sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功'); -}); +router.get( + '/creators', + requireStaffPermission('notification:read'), + validate({ query: notificationArchiveQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(req); + sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功'); + }, +); /** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */ -router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = validatedQuery<{ classId?: string }>(req); - sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功'); -}); +router.get( + '/unread-count', + requireStaffPermission('notification:read'), + validate({ query: notificationScopeQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { classId } = validatedQuery<{ classId?: string }>(req); + sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功'); + }, +); /** POST /api/notifications/read-all - 全部标为已读 */ -router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId } = (req.body ?? {}) as { classId?: string }; - sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读'); -}); +router.post( + '/read-all', + requireStaffPermission('notification:read'), + validate({ body: notificationScopeQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { classId } = (req.body ?? {}) as { classId?: string }; + sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读'); + }, +); /** POST /api/notifications - 发布通知(发布后广播 notification:new) */ router.post( '/', - requirePermission('notification:publish'), + requireStaffPermission('notification:publish'), validate({ body: createNotificationSchema }), async (req, res) => { const user = getAuthUser(req); @@ -70,7 +100,7 @@ router.post( /** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */ router.post( '/bulk', - requirePermission('notification:publish'), + requireStaffPermission('notification:publish'), validate({ body: bulkCreateNotificationSchema }), async (req, res) => { const user = getAuthUser(req); @@ -83,11 +113,16 @@ router.post( ); /** POST /api/notifications/:id/read - 标记已读 */ -router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读'); -}); +router.post( + '/:id/read', + requireStaffPermission('notification:read'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读'); + }, +); /** DELETE /api/notifications/:id - 删除通知 */ router.delete( diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 139e129..050fd32 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -1,3 +1,5 @@ +import { assertStaffPermission, assertPermission } from '../../lib/permissions.js'; +import { resolveContentCreator } from '../../lib/access.js'; import { SOCKET_EVENTS, dayKeyLocal, @@ -71,6 +73,7 @@ export async function listNotifications( user: TokenPayload, options: ListNotificationOptions, ): Promise { + await assertStaffPermission(user, 'notification:read'); const scope = await resolveClassScope(user, options.classId); const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER'; // 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己 @@ -109,6 +112,7 @@ 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); const grouped = await prisma.notification.groupBy({ by: ['assignDate'], @@ -129,6 +133,7 @@ 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); const grouped = await prisma.notification.groupBy({ by: ['createdBy'], @@ -166,6 +171,7 @@ export async function bulkCreateNotifications( user: TokenPayload, input: BulkCreateNotificationRequest, ): Promise { + await assertStaffPermission(user, 'notification:publish'); const classIds = normalizeBulkClassIds(input.classIds); const results: BulkPublishClassResult[] = []; @@ -202,6 +208,7 @@ export async function createNotification( user: TokenPayload, input: CreateNotificationInput, ): Promise { + await assertStaffPermission(user, 'notification:publish'); await assertCanPublishNotification(user, input.classId); const priority = input.priority ?? 'NORMAL'; @@ -227,7 +234,7 @@ export async function createNotification( title: input.title, content: input.content, priority, - createdBy: user.sub, + createdBy: await resolveContentCreator(user, input.classId), // 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径) assignDate: dayKeyLocal(new Date()), }, @@ -241,6 +248,7 @@ export async function createNotification( } export async function deleteNotification(user: TokenPayload, notificationId: string): Promise { + await assertPermission(user, 'notification:delete'); const current = await prisma.notification.findUnique({ where: { id: notificationId } }); if (!current) throw ApiError.notFound('通知不存在'); await assertCanPublishNotification(user, current.classId); @@ -259,6 +267,7 @@ export async function markAsRead( user: TokenPayload, notificationId: string, ): Promise<{ notificationId: string; readAt: string; marked: number }> { + await assertStaffPermission(user, 'notification:read'); const notification = await prisma.notification.findUnique({ where: { id: notificationId } }); if (!notification) throw ApiError.notFound('通知不存在'); await assertClassAccess(user, notification.classId); @@ -284,6 +293,7 @@ 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); const personalIds = await resolvePersonalIds(user); const personalWhere = personalIdWhere(personalIds); @@ -314,6 +324,7 @@ 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); const personalIds = await resolvePersonalIds(user); return prisma.notification.count({ diff --git a/packages/server/src/modules/permissions/permissions.module.ts b/packages/server/src/modules/permissions/permissions.module.ts index 1b09ef5..d467312 100644 --- a/packages/server/src/modules/permissions/permissions.module.ts +++ b/packages/server/src/modules/permissions/permissions.module.ts @@ -1,8 +1,9 @@ +import { z } from 'zod'; import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; -import { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; +import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { assignUserGroupsSchema, @@ -38,6 +39,30 @@ router.get('/groups', requirePermission('permission:manage'), async (_req, res) sendOk(res, await permissionService.listGroups(), '获取权限组成功'); }); +router.get('/scopes', requirePermission('permission:manage'), async (_req, res) => + sendOk(res, await permissionService.scopeOptions()), +); +router.get( + '/audit', + requirePermission('permission:manage'), + validate({ + query: z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(30), + }), + }), + async (req, res) => { + const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req); + sendOk(res, await permissionService.listAudits(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))), +); + /** POST /api/permissions/groups - 新建权限组 */ router.post( '/groups', @@ -45,7 +70,7 @@ router.post( validate({ body: createPermissionGroupSchema }), async (req, res) => { const input = validatedBody(req); - sendCreated(res, await permissionService.createGroup(input), '权限组已创建'); + sendCreated(res, await permissionService.createGroup(getAuthUser(req), input), '权限组已创建'); }, ); @@ -57,7 +82,7 @@ router.patch( async (req, res) => { const { id } = validatedParams<{ id: string }>(req); const input = validatedBody(req); - sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新'); + sendOk(res, await permissionService.updateGroup(getAuthUser(req), id, input), '权限组已更新'); }, ); @@ -68,7 +93,7 @@ router.delete( validate({ params: idParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); - await permissionService.deleteGroup(id); + await permissionService.deleteGroup(getAuthUser(req), id); sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限'); }, ); @@ -86,7 +111,7 @@ router.put( async (req, res) => { const { id } = validatedParams<{ id: string }>(req); const input = validatedBody(req); - sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存'); + sendOk(res, await permissionService.assignUserGroups(getAuthUser(req), id, input), '权限组分配已保存'); }, ); diff --git a/packages/server/src/modules/permissions/permissions.service.ts b/packages/server/src/modules/permissions/permissions.service.ts index e370300..b314bd6 100644 --- a/packages/server/src/modules/permissions/permissions.service.ts +++ b/packages/server/src/modules/permissions/permissions.service.ts @@ -16,7 +16,14 @@ import { import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; -import { logger } from '../../lib/logger.js'; +import { + parsePermissionScope, + defaultPromotionPlan, + type PermissionScope, + type PromotionPlan, +} from '@classhelper/shared'; +import { assertPermission } from '../../lib/permissions.js'; +import { getSetting } from '../../lib/settings.js'; import { ADMIN_UNREMOVABLE_PERMISSIONS, ensureDefaultGroups, @@ -66,14 +73,6 @@ const MEMBER_SELECT = { permissionGroups: { select: { groupId: true } }, } as const; -type MemberRow = { - id: string; - username: string; - name: string; - role: string; - permissionGroups: { groupId: string }[]; -}; - /** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */ const KEY_ORDER = new Map(PERMISSION_KEYS.map((key, index) => [key, index])); @@ -97,6 +96,7 @@ function toGroupDto(row: GroupRow): PermissionGroupDto { id: row.id, scope: row.scope as PermissionGroupDto['scope'], scopeValue: row.scopeValue, + scopeLabel: scopeLabel(row), name: row.name, description: row.description, builtin: row.builtin, @@ -111,6 +111,252 @@ function toGroupDto(row: GroupRow): PermissionGroupDto { }; } +function scopeLabel(group: { scope: string; scopeValue: string }) { + try { + return parsePermissionScope(group.scope as PermissionScope, group.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)); + 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), + })), + }; +} + +async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) { + try { + return parsePermissionScope( + input.scope ?? 'SELF_CLASS', + input.scopeValue ?? '', + (await scopeOptions()).grades, + ); + } catch (error) { + throw ApiError.badRequest(String(error instanceof Error ? error.message : error)); + } +} + +async function assertGrant( + actor: TokenPayload, + group: { scope: string; defaultRole?: string | null; keys: readonly string[] }, +) { + await assertPermission(actor, 'permission:manage'); + if (actor.role === 'ADMIN') return; + if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组'); + if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组'); + const own = await loadEffectivePermissions(actor); + if (group.keys.some((key) => !own.has(key as PermissionKey))) + throw ApiError.forbidden('不能授予或管理超出自己生效权限的组'); +} + +function auditData( + actor: TokenPayload, + action: string, + targetType: string, + targetId: string, + before: unknown, + after: unknown, +) { + return { + actorId: actor.sub, + actorName: actor.name, + action, + targetType, + targetId, + beforeJson: JSON.stringify(before), + 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) => { + const row = await tx.permissionGroup.create({ + data: { + name: input.name.trim(), + description: input.description?.trim() ?? '', + scope: scope.scope, + scopeValue: scope.scopeValue, + items: { create: keys.map((key) => ({ key })) }, + }, + select: GROUP_SELECT, + }); + await tx.permissionAudit.create({ + data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)), + }); + return 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 } }); + const row = await tx.permissionGroup.update({ + where: { id: groupId }, + data: { + name: input.name?.trim(), + description: input.description?.trim(), + scope: scope.scope, + scopeValue: scope.scopeValue, + items: { create: keys.map((key) => ({ key })) }, + }, + select: GROUP_SELECT, + }); + await tx.permissionAudit.create({ + data: auditData( + actor, + current.defaultRole ? 'update-default' : 'update', + 'group', + groupId, + before, + toGroupDto(row), + ), + }); + return row; + }); + return toGroupDto(updated); +} + +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) { + const [items, total] = await Promise.all([ + prisma.permissionAudit.findMany({ + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.permissionAudit.count(), + ]); + return { items, total, page, pageSize }; +} + /** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */ export async function permissionCatalog(): Promise { await ensureDefaultGroups(); @@ -150,213 +396,13 @@ export async function listGroups(): Promise { select: GROUP_SELECT, orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }], }); - return (rows as GroupRow[]).map(toGroupDto); -} - -export async function createGroup(input: CreatePermissionGroupRequest): Promise { - const name = input.name.trim(); - const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } }); - if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`); - - const keys = normalizeKeys(input.keys); - const created = await prisma.permissionGroup.create({ - data: { - name, - scope: input.scope ?? 'SELF_CLASS', - scopeValue: input.scopeValue ?? '', - description: input.description?.trim() ?? '', - items: { create: keys.map((key) => ({ key })) }, - }, - select: GROUP_SELECT, - }); - logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`); - return toGroupDto(created as GroupRow); -} - -export async function updateGroup( - groupId: string, - input: UpdatePermissionGroupRequest, -): Promise { - const current = await prisma.permissionGroup.findUnique({ - where: { id: groupId }, - select: { id: true, name: true }, - }); - if (!current) throw ApiError.notFound('权限组不存在'); - - const name = input.name?.trim(); - if (name && name !== current.name) { - const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } }); - if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`); - } - - const keys = input.keys ? normalizeKeys(input.keys) : null; - - await prisma.$transaction(async (tx) => { - await tx.permissionGroup.update({ - where: { id: groupId }, - data: { - ...(name ? { name } : {}), - ...(input.scope ? { scope: input.scope } : {}), - ...(input.scopeValue !== undefined ? { scopeValue: input.scopeValue } : {}), - ...(input.description !== undefined ? { description: input.description.trim() } : {}), - }, - }); - // 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。 - if (keys) { - await tx.permissionGroupItem.deleteMany({ where: { groupId } }); - if (keys.length > 0) { - await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) }); - } - } - }); - - const updated = await prisma.permissionGroup.findUniqueOrThrow({ - where: { id: groupId }, - select: GROUP_SELECT, - }); - logger.info( - `更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`, - ); - return toGroupDto(updated as GroupRow); -} - -export async function deleteGroup(groupId: string): Promise<{ id: string }> { - const current = await prisma.permissionGroup.findUnique({ - where: { id: groupId }, - select: { id: true, name: true, builtin: true, _count: { select: { users: true } } }, - }); - if (!current) throw ApiError.notFound('权限组不存在'); - if (current.builtin) { - throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`); - } - - // 键与成员关系都是 onDelete: Cascade,直接删组即可; - // 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。 - await prisma.permissionGroup.delete({ where: { id: groupId } }); - logger.info(`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`); - return { id: groupId }; -} - -/** 把一行 User(含分组关联)转成权限页用的成员 DTO */ -function toMemberDto(row: MemberRow, keysOfGroup: Map): PermissionMemberDto { - const groupIds = row.permissionGroups.map((link) => link.groupId); - const union = new Set(); - for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key); - - const role = row.role as UserRole; - const permissionCount = - role === 'ADMIN' - ? PERMISSION_KEYS.length - : groupIds.length === 0 - ? ROLE_DEFAULT_PERMISSIONS[role].length - : union.size; - - return { - id: row.id, - username: row.username, - name: row.name, - role, - groupIds, - permissionCount, - fixed: false, - }; -} - -/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */ -export async function listMembers(): Promise { - 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({ select: { id: true, items: { select: { key: true } } } }), - ]); - - const keysOfGroup = new Map( - groups.map((group) => [ - group.id, - group.items - .map((item) => item.key) - .filter(knownKey) - .sort(byCatalogOrder), - ]), - ); - - return Promise.all( - (rows as MemberRow[]).map(async (row) => ({ - ...toMemberDto(row, keysOfGroup), - permissionCount: ( - await loadEffectivePermissions({ - sub: row.id, - role: row.role as UserRole, - username: row.username, - name: row.name, - classId: null, - }) - ).size, - })), - ); -} - -/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */ -export async function assignUserGroups( - userId: string, - input: AssignUserGroupsRequest, -): Promise { - const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT }); - if (!user) throw ApiError.notFound('账号不存在'); - - const row = user as MemberRow; - if (row.role === 'CLASS_DEVICE') { - throw ApiError.badRequest( - 'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配', - ); - } - - const groupIds = [...new Set(input.groupIds)]; - if (groupIds.length > 0) { - const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } }); - if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试'); - } - - await prisma.$transaction(async (tx) => { - await tx.userPermissionGroup.deleteMany({ where: { userId } }); - if (groupIds.length > 0) { - await tx.userPermissionGroup.createMany({ - data: groupIds.map((groupId) => ({ userId, groupId })), - }); - } - }); - - logger.info( - `分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`, - ); - - const groups = await prisma.permissionGroup.findMany({ - select: { id: true, items: { select: { key: true } } }, - }); - const keysOfGroup = new Map( - groups.map((group) => [ - group.id, - group.items - .map((item) => item.key) - .filter(knownKey) - .sort(byCatalogOrder), - ]), - ); - return { - ...toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup), - permissionCount: ( - await loadEffectivePermissions({ - sub: row.id, - role: row.role as UserRole, - username: row.username, - name: row.name, - classId: null, - }) - ).size, - }; + 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, + ).length, + })); } /** diff --git a/packages/server/src/modules/schedules/schedules.module.ts b/packages/server/src/modules/schedules/schedules.module.ts index 7de893e..a30d38b 100644 --- a/packages/server/src/modules/schedules/schedules.module.ts +++ b/packages/server/src/modules/schedules/schedules.module.ts @@ -7,7 +7,12 @@ import { ApiError } from '../../lib/http.js'; import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { + authenticate, + getAuthUser, + requireStaffPermission, + requirePermission, +} from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -23,21 +28,30 @@ import * as scheduleService from './schedules.service.js'; const router = Router(); router.use(authenticate()); -router.get('/active-week', validate({ query: gridQuerySchema }), async (req, res) => - sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))), +router.get( + '/active-week', + requireStaffPermission('schedule:read'), + validate({ query: gridQuerySchema }), + async (req, res) => + sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))), +); +router.get( + '/scheduled', + requireStaffPermission('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); + sendOk( + res, + await prisma.scheduledClassPlan.findMany({ + where: { classId }, + include: { plan: { select: { name: true, isOverlay: true } } }, + orderBy: { date: 'asc' }, + }), + ); + }, ); -router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { - const { classId } = validatedQuery<{ classId: string }>(req); - await assertClassAccess(getAuthUser(req), classId); - sendOk( - res, - await prisma.scheduledClassPlan.findMany({ - where: { classId }, - include: { plan: { select: { name: true, isOverlay: true } } }, - orderBy: { date: 'asc' }, - }), - ); -}); router.delete( '/scheduled/:id', requirePermission('schedule:write'), @@ -93,41 +107,56 @@ router.post( ); /** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */ -router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req); - sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功'); -}); +router.get( + '/', + requireStaffPermission('schedule:read'), + validate({ query: listSchedulesQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req); + sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功'); + }, +); /** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */ -router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req); - sendOk( - res, - await scheduleService.getScheduleGrid(user, { - classId, - // 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析) - week, - }), - '获取周视图成功', - ); -}); +router.get( + '/grid', + requireStaffPermission('schedule:read'), + validate({ query: gridQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req); + sendOk( + res, + await scheduleService.getScheduleGrid(user, { + classId, + // 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析) + week, + }), + '获取周视图成功', + ); + }, +); /** * GET /api/schedules/current?classId=&at= - 班级当前上课状态。 * 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知" * 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。 */ -router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req); - const status = await scheduleService.getClassStatus(user, { - classId, - at: at ? new Date(at) : undefined, - }); - sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段'); -}); +router.get( + '/current', + requireStaffPermission('schedule:read'), + validate({ query: classStatusQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req); + const status = await scheduleService.getClassStatus(user, { + classId, + at: at ? new Date(at) : undefined, + }); + sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段'); + }, +); /** POST /api/schedules - 新增课表 */ router.post( diff --git a/packages/server/src/modules/students/students.module.ts b/packages/server/src/modules/students/students.module.ts index 80fdac4..70eaf4b 100644 --- a/packages/server/src/modules/students/students.module.ts +++ b/packages/server/src/modules/students/students.module.ts @@ -1,11 +1,14 @@ 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, requireAnyPermission } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { createStudentSchema, + transferInSchema, + restoreStudentSchema, + deleteStudentQuerySchema, listStudentsQuerySchema, listTransfersQuerySchema, transferOutStudentsSchema, @@ -29,11 +32,11 @@ router.use(authenticate()); * - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段; * - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`); * 教室机器读本班名单请用 `GET /api/classes/:id/students`; - * - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出仅管理员。 + * - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出还需覆盖源班和目标班。 */ router.get( '/', - requireAnyPermission('roster:read', 'roster:manage'), + requirePermission('roster:read'), validate({ query: listStudentsQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -45,7 +48,7 @@ router.get( /** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */ router.get( '/transfers', - requireAnyPermission('roster:read', 'roster:manage'), + requirePermission('roster:read'), validate({ query: listTransfersQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -62,7 +65,7 @@ router.get( */ router.post( '/transfer', - requirePermission('class:manage'), + requirePermission('roster:manage'), validate({ body: transferStudentsSchema }), async (req, res) => { const user = getAuthUser(req); @@ -74,7 +77,7 @@ router.post( /** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */ router.post( '/transfer-out', - requirePermission('class:manage'), + requirePermission('roster:manage'), validate({ body: transferOutStudentsSchema }), async (req, res) => { const user = getAuthUser(req); @@ -83,10 +86,24 @@ router.post( }, ); +router.post( + '/transfer-in', + requirePermission('roster:manage'), + validate({ body: transferInSchema }), + async (req, res) => sendCreated(res, await studentService.transferInStudent(getAuthUser(req), req.body)), +); +router.post( + '/:id/restore', + requirePermission('roster:manage'), + validate({ params: idParamSchema, body: restoreStudentSchema }), + async (req, res) => + sendOk(res, await studentService.restoreStudent(getAuthUser(req), String(req.params.id), req.body)), +); + /** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */ router.get( '/:id/transfers', - requirePermission('roster:manage'), + requirePermission('roster:read'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -115,6 +132,11 @@ router.post( router.patch( '/:id', requirePermission('roster:manage'), + (req, _res, next) => { + if (req.body && ('classId' in req.body || 'status' in req.body)) + return next(ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改')); + next(); + }, validate({ params: idParamSchema, body: updateStudentSchema }), async (req, res) => { const user = getAuthUser(req); @@ -131,12 +153,16 @@ router.patch( router.delete( '/:id', requirePermission('roster:manage'), - validate({ params: idParamSchema }), + validate({ params: idParamSchema, query: deleteStudentQuerySchema }), async (req, res) => { const user = getAuthUser(req); const { id } = validatedParams<{ id: string }>(req); - await studentService.deleteStudent(user, id); - sendOk(res, { id }, '学生记录已删除'); + const query = validatedQuery<{ hard?: string; confirm?: string }>(req); + await studentService.deleteStudent(user, id, { + hard: query.hard === 'true', + confirm: query.confirm === 'true', + }); + sendOk(res, { id }, '学生名单操作已完成'); }, ); diff --git a/packages/server/src/modules/students/students.schemas.ts b/packages/server/src/modules/students/students.schemas.ts index 5ae4f2e..c24fa58 100644 --- a/packages/server/src/modules/students/students.schemas.ts +++ b/packages/server/src/modules/students/students.schemas.ts @@ -23,22 +23,44 @@ export const listStudentsQuerySchema = z.object({ // 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。 // 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。 -export const createStudentSchema = z.object({ - studentNo: studentNoSchema, - name: studentNameSchema, - classId: z.string().min(1).nullish(), - gender: studentGenderSchema, - guardianPhone: guardianPhoneSchema, - status: studentStatusSchema, -}); +export const createStudentSchema = z + .object({ + studentNo: studentNoSchema, + name: studentNameSchema, + classId: z.string().min(1).nullish(), + gender: studentGenderSchema, + guardianPhone: guardianPhoneSchema, + status: z.enum(['active', 'inactive']).optional(), + }) + .strict(); -export const updateStudentSchema = z.object({ - studentNo: studentNoSchema.optional(), - name: studentNameSchema.optional(), - classId: z.string().min(1).nullish(), - gender: studentGenderSchema, - guardianPhone: guardianPhoneSchema, - status: studentStatusSchema, +export const updateStudentSchema = z + .object({ + studentNo: studentNoSchema.optional(), + name: studentNameSchema.optional(), + gender: studentGenderSchema, + guardianPhone: guardianPhoneSchema, + }) + .strict(); + +export const transferInSchema = z + .object({ + studentId: z.string().min(1).optional(), + studentNo: studentNoSchema.optional(), + name: studentNameSchema.optional(), + toClassId: z.string().min(1), + gender: studentGenderSchema, + guardianPhone: guardianPhoneSchema, + fromSchool: z.string().trim().max(200).optional(), + note: z.string().trim().max(200).optional(), + }) + .strict(); +export const restoreStudentSchema = z + .object({ toClassId: z.string().min(1).optional(), note: z.string().trim().max(200).optional() }) + .strict(); +export const deleteStudentQuerySchema = z.object({ + hard: z.enum(['true', 'false']).optional(), + confirm: z.enum(['true', 'false']).optional(), }); /** @@ -49,17 +71,21 @@ export const updateStudentSchema = z.object({ export const transferStudentsSchema = z.object({ studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500), toClassId: z.string().min(1).nullable(), + allowGradeChange: z.boolean().optional(), note: z.string().trim().max(200).optional(), }); /** 转出(学籍离开本校,不是调班):班级归属保留作历史 */ export const transferOutStudentsSchema = z.object({ + toSchool: z.string().trim().max(200).optional(), studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500), note: z.string().trim().max(200).optional(), }); /** 调班历史查询 */ export const listTransfersQuerySchema = z.object({ + batchId: z.string().optional(), + academicYear: z.coerce.number().int().optional(), studentId: z.string().min(1).optional(), classId: z.string().min(1).optional(), page: z.coerce.number().int().min(1).optional(), diff --git a/packages/server/src/modules/students/students.service.ts b/packages/server/src/modules/students/students.service.ts index 29be19e..759c211 100644 --- a/packages/server/src/modules/students/students.service.ts +++ b/packages/server/src/modules/students/students.service.ts @@ -5,15 +5,17 @@ import { assertClassAccess, classScopeWhere, isAdmin, + getAccessibleClassIds, resolveClassScope, } from '../../lib/access.js'; -import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; +import { assertStaffPermission, 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 { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js'; -import { SOCKET_EVENTS } from '@classhelper/shared'; -import { emitToClass } from '../../realtime/bus.js'; import type { CreateStudentInput, ListStudentsQuery, @@ -36,13 +38,13 @@ const withClass = { class: { select: { name: true, grade: true } } } as const; /** 学生名单(按班级权限过滤) */ export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise { - const scope = await resolveClassScope(user, options.classId); + await assertStaffPermission(user, 'roster:read'); + const scope = await resolveClassScope(user, options.classId, 'roster:read'); const students = await prisma.student.findMany({ where: { ...classScopeWhere(scope), - ...activeStudentWhere(), - ...(options.status ? { AND: [{ status: options.status }, activeStudentWhere()] } : {}), + status: options.status ?? 'active', // 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里 ...(options.includeArchived ? {} : { archivedYearId: null }), ...(options.keyword @@ -74,181 +76,141 @@ export async function findStudentByNo(studentNo: string, classId?: string): Prom export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise { await assertCanManageRoster(user, input.classId ?? undefined); await assertStudentNoFree(input.studentNo); - if (input.classId) await assertClassExists(input.classId); - const created = await prisma.student.create({ - data: { + const result = await changeStudentLifecycle(user, { + studentIds: [], + kind: 'class-change', + toClassId: input.classId, + newStudent: { studentNo: input.studentNo, name: input.name, - classId: input.classId ?? null, - gender: input.gender ?? '', - guardianPhone: input.guardianPhone ?? '', - status: input.status ?? 'active', + gender: input.gender, + guardianPhone: input.guardianPhone, + status: input.status, }, + }); + const created = await prisma.student.findUniqueOrThrow({ + where: { id: result.studentId }, include: withClass, }); - - if (created.classId) { - emitToClass(created.classId, SOCKET_EVENTS.classUpdated, { - classId: created.classId, - action: 'updated', - }); - } return toStudentDto(created); } -/** 修改学生信息(含调班;走本接口的单个调班同样会记入调班历史) */ +/** 资料修改不承担状态或班级迁移;旧请求明确报错。 */ export async function updateStudent( user: TokenPayload, studentId: string, input: UpdateStudentInput, ): Promise { + if ('classId' in input || 'status' in input) + throw ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改'); const student = await prisma.student.findUnique({ where: { id: studentId } }); - if (!student || !isActiveStudent(student)) throw ApiError.notFound('学生不存在'); - + if (!student) throw ApiError.notFound('学生不存在'); + if (student.archivedYearId || !['active', 'inactive'].includes(student.status)) + throw ApiError.conflict('历史学生资料只读,请先办理转入或撤销归档'); await assertCanManageRoster(user, student.classId ?? undefined); - if (input.studentNo && input.studentNo !== student.studentNo) { - await assertStudentNoFree(input.studentNo, studentId); - } - - const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null); - const classChanged = nextClassId !== student.classId; - if (classChanged) await assertCanManageRoster(user, nextClassId ?? undefined); - - const updated = await prisma.student.update({ - where: { id: studentId }, - data: { - ...(input.studentNo ? { studentNo: input.studentNo } : {}), - ...(input.name ? { name: input.name } : {}), - ...(input.gender !== undefined ? { gender: input.gender } : {}), - ...(input.guardianPhone !== undefined ? { guardianPhone: input.guardianPhone } : {}), - ...(input.status !== undefined ? { status: input.status } : {}), - classId: nextClassId, - }, - include: withClass, - }); - - // 换班要留痕(单个调班与批量调班都写同一种历史) - if (classChanged) { - await recordTransfer(user, [updated], student.classId, nextClassId, 'single', ''); - } - + if (input.studentNo) await assertStudentNoFree(input.studentNo, studentId); + const updated = await prisma.student.update({ where: { id: studentId }, data: input, include: withClass }); + notifyStudentClasses([student.classId]); return toStudentDto(updated); } -/** 删除学生记录(级联删除其作业状态、成绩、已读记录与调班历史) */ -export async function deleteStudent(user: TokenPayload, studentId: string): Promise { +/** 默认停用且保留业务;硬删除须管理员确认并证明没有任何历史。 */ +export async function deleteStudent( + user: TokenPayload, + studentId: string, + options: { hard?: boolean; confirm?: boolean } = {}, +): Promise { const student = await prisma.student.findUnique({ where: { id: studentId } }); if (!student) throw ApiError.notFound('学生不存在'); await assertCanManageRoster(user, student.classId ?? undefined); - - await prisma.student.delete({ where: { id: studentId } }); + if (!options.hard) { + await changeStudentLifecycle(user, { + studentIds: [studentId], + kind: 'deactivate', + note: '名单停用,保留全部业务历史', + }); + return; + } + if (!isAdmin(user) || !options.confirm) throw ApiError.forbidden('硬删除需要管理员二次确认'); + await prisma.$transaction(async (tx) => { + const current = await tx.student.findUniqueOrThrow({ + where: { id: studentId }, + include: { + _count: { + select: { grades: true, homeworkStatuses: true, notificationReads: true, transfers: true }, + }, + }, + }); + if ( + current.archivedYearId || + !['active', 'inactive'].includes(current.status) || + Object.values(current._count).some(Boolean) + ) + throw ApiError.conflict('该生有成绩、作业、已读或生命周期历史,不能硬删除;请停用保留记录'); + await tx.student.delete({ where: { id: studentId } }); + }); + notifyStudentClasses([student.classId]); } -/* ---------------------------------------------------------------- 调班 / 转出 */ - -/** - * 调班:单个与批量共用(传一个 id 就是单个)。 - * - * 规则(需求「管理员调班」): - * - 只改学生**当前班级**,学号不变、不创建任何账号; - * - 原班级、新班级、操作人、时间写入 `StudentClassTransfer`,历史可查; - * - 历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。 - */ -export async function transferStudents( - user: TokenPayload, - input: TransferStudentsInput, -): Promise<{ moved: number; transfers: StudentTransferDto[] }> { - if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班'); - - const students = await prisma.student.findMany({ - where: { id: { in: input.studentIds }, ...activeStudentWhere() }, +export async function transferStudents(user: TokenPayload, input: TransferStudentsInput) { + const result = await changeStudentLifecycle(user, { + ...input, + kind: input.toClassId ? 'class-change' : 'unassign', }); - if (students.length !== input.studentIds.length) { - throw ApiError.badRequest('部分学生不存在,请刷新后重试'); - } - if (input.toClassId) await assertClassExists(input.toClassId); - - const movable = students.filter((item) => item.classId !== input.toClassId); - if (movable.length === 0) { - throw ApiError.badRequest('所选学生已经全部在这个班级里'); - } - - await prisma.$transaction( - movable.map((item) => - prisma.student.update({ where: { id: item.id }, data: { classId: input.toClassId } }), - ), - ); - - const mode = movable.length > 1 ? 'batch' : 'single'; - // 注意传的是**调班前**的 movable(classId 还是原班级):历史里的"原班级"要按每条自己的来 - const transfers = await recordTransfer(user, movable, null, input.toClassId, mode, input.note ?? ''); - - notifyClassChange( - movable.map((item) => item.classId), - input.toClassId, - ); - return { moved: movable.length, transfers }; + return { ...result, moved: result.count }; } - -/** - * 学生转出:学籍离开本校(**不是调班**)。 - * - * 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`, - * 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。 - */ -export async function transferOutStudents( +export async function transferOutStudents(user: TokenPayload, input: TransferOutStudentsInput) { + const result = await changeStudentLifecycle(user, { ...input, kind: 'transfer-out' }); + return { ...result, transferred: result.count }; +} +export async function transferInStudent( user: TokenPayload, - input: TransferOutStudentsInput, -): Promise<{ transferred: number; transfers: StudentTransferDto[] }> { - if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以办理学生转出'); - - const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } }); - if (students.length !== input.studentIds.length) { - throw ApiError.badRequest('部分学生不存在,请刷新后重试'); - } - const pending = students.filter((item) => item.status !== 'transferred'); - if (pending.length === 0) throw ApiError.badRequest('所选学生都已经转出'); - - const now = new Date(); - await prisma.$transaction( - pending.map((item) => - prisma.student.update({ - where: { id: item.id }, - data: { status: 'transferred', transferredAt: now, transferNote: input.note ?? '' }, - }), - ), - ); - - // 班级名快照:转出时把学生"离开的那个班"记下来,之后班级改名也不影响历史 - const classNames = await loadClassNames(pending.map((item) => item.classId)); - await prisma.studentClassTransfer.createMany({ - data: pending.map((item) => ({ - studentId: item.id, - studentNo: item.studentNo, - studentName: item.name, - fromClassId: item.classId, - fromClassName: item.classId ? (classNames.get(item.classId) ?? '') : '', - toClassId: null, - toClassName: '转出本校', - operatorId: user.sub, - operatorName: user.name, - mode: 'transfer-out', - note: input.note ?? '', - })), + input: { + studentId?: string; + studentNo?: string; + name?: string; + toClassId: string; + gender?: string; + guardianPhone?: string; + fromSchool?: string; + note?: string; + }, +) { + if (!input.studentId && (!input.studentNo || !input.name)) + throw ApiError.badRequest('新转入学生必须填写学号与姓名'); + const result = await changeStudentLifecycle(user, { + ...input, + kind: 'transfer-in', + studentIds: input.studentId ? [input.studentId] : [], + ...(!input.studentId + ? { + newStudent: { + studentNo: input.studentNo!, + name: input.name!, + gender: input.gender, + guardianPhone: input.guardianPhone, + }, + } + : {}), }); - - notifyClassChange( - pending.map((item) => item.classId), - null, + return toStudentDto( + await prisma.student.findUniqueOrThrow({ where: { id: result.studentId }, include: withClass }), ); - - const created = await prisma.studentClassTransfer.findMany({ - where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' }, - orderBy: { createdAt: 'desc' }, - take: pending.length, +} +export async function restoreStudent( + user: TokenPayload, + id: string, + input: { toClassId?: string; note?: string } = {}, +) { + const student = await prisma.student.findUnique({ where: { id } }); + if (!student) throw ApiError.notFound('学生不存在'); + return changeStudentLifecycle(user, { + studentIds: [id], + kind: 'restore', + toClassId: input.toClassId ?? student.classId, + note: input.note ?? '撤回转出或复学,保留原历史', }); - return { transferred: pending.length, transfers: created.map(toStudentTransferDto) }; } /** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */ @@ -256,9 +218,8 @@ export async function listTransfers( user: TokenPayload, options: ListTransfersQuery, ): Promise> { - if (!isAdmin(user) && !options.classId && !options.studentId) { - throw ApiError.forbidden('查看全部调班历史需要管理员权限'); - } + await assertPermission(user, 'roster:read'); + const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read')); if (options.classId) await assertClassAccess(user, options.classId); if (options.studentId) { @@ -268,11 +229,16 @@ export async function listTransfers( }); if (!student) throw ApiError.notFound('学生不存在'); if (student.classId) await assertClassAccess(user, student.classId); - else if (!isAdmin(user)) throw ApiError.forbidden('无权查看未分班学生的调班历史'); + else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史'); } + const accessible = hasGlobalRosterScope ? null : await getAccessibleClassIds(user, 'roster:read'); const where = { - student: activeStudentWhere(), + ...(options.batchId ? { batchId: options.batchId } : {}), + ...(options.academicYear ? { academicYear: options.academicYear } : {}), + ...(accessible + ? { AND: [{ OR: [{ fromClassId: { in: accessible } }, { toClassId: { in: accessible } }] }] } + : {}), ...(options.studentId ? { studentId: options.studentId } : {}), ...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}), }; @@ -292,76 +258,6 @@ export async function listTransfers( return { items: items.map(toStudentTransferDto), total, page, pageSize }; } -/* ---------------------------------------------------------------- 内部工具 */ - -/** - * 写入调班历史。 - * - * - `fromOverride` 非空时,所有条目都用它作为"原班级"(单个调班场景); - * - 为空时逐条取**该学生自己的 classId** 作为"原班级"(批量调班场景, - * 各人的来源班可能不同)。 - */ -async function recordTransfer( - user: TokenPayload, - students: { id: string; studentNo: string; name: string; classId: string | null }[], - fromOverride: string | null, - toClassId: string | null, - mode: 'single' | 'batch', - note: string, -): Promise { - const fromIds = students.map((item) => fromOverride ?? item.classId); - const classNames = await loadClassNames([...fromIds, toClassId]); - - await prisma.studentClassTransfer.createMany({ - data: students.map((item, index) => { - const fromClassId = fromIds[index] ?? null; - return { - studentId: item.id, - studentNo: item.studentNo, - studentName: item.name, - fromClassId, - fromClassName: fromClassId ? (classNames.get(fromClassId) ?? '') : '', - toClassId, - toClassName: toClassId ? (classNames.get(toClassId) ?? '') : '未分班', - operatorId: user.sub, - operatorName: user.name, - mode, - note, - }; - }), - }); - - const created = await prisma.studentClassTransfer.findMany({ - where: { studentId: { in: students.map((item) => item.id) } }, - orderBy: { createdAt: 'desc' }, - take: students.length, - }); - return created.map(toStudentTransferDto); -} - -async function loadClassNames(classIds: (string | null)[]): Promise> { - const ids = [...new Set(classIds.filter((id): id is string => Boolean(id)))]; - if (ids.length === 0) return new Map(); - const classes = await prisma.class.findMany({ - where: { id: { in: ids } }, - select: { id: true, name: true }, - }); - return new Map(classes.map((item) => [item.id, item.name])); -} - -/** 调班后让相关班级的前端刷新名单(旧班与新班都要收到) */ -function notifyClassChange(fromClassIds: (string | null)[], toClassId: string | null): void { - const targets = new Set([...fromClassIds, toClassId].filter((id): id is string => Boolean(id))); - for (const classId of targets) { - emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' }); - } -} - -async function assertClassExists(classId: string): Promise { - const record = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } }); - if (!record) throw ApiError.badRequest('班级不存在'); -} - /** 学号唯一:`Student.studentNo` 上已有唯一约束,这里给出一句人话的 409 */ async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise { const existing = await prisma.student.findUnique({ diff --git a/packages/server/src/modules/term/promotion.service.ts b/packages/server/src/modules/term/promotion.service.ts index 781390c..283538d 100644 --- a/packages/server/src/modules/term/promotion.service.ts +++ b/packages/server/src/modules/term/promotion.service.ts @@ -1,210 +1,462 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; -import { dayKeyLocal, formatClassName } from '@classhelper/shared'; +import { + dayKeyLocal, + formatClassName, + isDayKey, + defaultPromotionPlan, + SOCKET_EVENTS, + type PromotionPlan, +} from '@classhelper/shared'; import { prisma } from '../../lib/db.js'; +import type { Prisma } from '../../generated/prisma/client.js'; +import type { TokenPayload } from '../../lib/jwt.js'; import { env } from '../../config/env.js'; import { ApiError } from '../../lib/http.js'; +import { assertGlobalPermission } from '../../lib/access.js'; import { exportSnapshot } from '../../lib/snapshot.js'; -import { getSetting, setSetting, SETTING_KEYS } from '../../lib/settings.js'; +import { SETTING_KEYS } from '../../lib/settings.js'; import { getMaxTermWeeks } from '../../lib/term.js'; import { hashPassword } from '../../lib/password.js'; import { logger } from '../../lib/logger.js'; +import { emitToClass } from '../../realtime/bus.js'; import { notifyScheduleChanged } from '../class-plans/class-plans.service.js'; -export const promotionConfigSchema = z.object({ - month: z.number().int().min(1).max(12), - day: z.number().int().min(1).max(31), - hour: z.number().int().min(0).max(23), - classCount: z.number().int().min(1).max(99).default(1), -}); -export async function promotionStatus(now = new Date()) { - const raw = await getSetting(SETTING_KEYS.promotionAt); - let config = { month: 8, day: 1, hour: 0, classCount: 1 }; - if (raw) { - const parsed = promotionConfigSchema.safeParse(JSON.parse(raw)); - if (parsed.success) config = parsed.data; - } +export const promotionPlanSchema = z + .object({ + tracks: z + .array( + z.object({ + id: z.string().regex(/^[a-z][a-z0-9-]{0,19}$/), + name: z.string().trim().min(1).max(40), + entryClassCount: z.number().int().min(0).max(99), + stages: z + .array(z.object({ grade: z.string().trim().min(1).max(32), terminal: z.boolean().optional() })) + .min(1) + .max(20), + }), + ) + .min(1) + .max(20), + termStartDate: z.string().refine(isDayKey, '学期开始日期无效').nullable().default(null), + resetTermWeeks: z.boolean().default(true), + }) + .superRefine((plan, ctx) => { + const grades = new Set(), + tracks = new Set(); + for (const [index, track] of plan.tracks.entries()) { + if (tracks.has(track.id)) + ctx.addIssue({ code: 'custom', path: ['tracks', index, 'id'], message: '轨道标识不能重复' }); + tracks.add(track.id); + for (const [i, stage] of track.stages.entries()) { + if (grades.has(stage.grade)) + ctx.addIssue({ + code: 'custom', + path: ['tracks', index, 'stages', i], + message: '每个年级只能出现一次', + }); + grades.add(stage.grade); + if (Boolean(stage.terminal) !== (i === track.stages.length - 1)) + ctx.addIssue({ + code: 'custom', + path: ['tracks', index, 'stages', i], + message: '仅最后一个学段必须标记为毕业学段', + }); + } + } + }); +export const promotionConfigSchema = z + .object({ + month: z.number().int().min(1).max(12), + day: z.number().int().min(1).max(31), + hour: z.number().int().min(0).max(23), + classCount: z.number().int().min(0).max(99).optional(), + plan: promotionPlanSchema.optional(), + }) + .refine((config) => config.day <= new Date(2000, config.month, 0).getDate(), '该月份没有所选日期'); + +type Db = Prisma.TransactionClient; +async function loadConfig(db: Db = prisma) { + const settings = await db.appSetting.findMany({ + where: { key: { in: [SETTING_KEYS.promotionAt, SETTING_KEYS.promotionPlan] } }, + }); + const raw = settings.find((setting) => setting.key === SETTING_KEYS.promotionAt)?.value; + const config = raw + ? promotionConfigSchema.parse(JSON.parse(raw)) + : { month: 8, day: 1, hour: 0, classCount: 1 }; + const rawPlan = settings.find((setting) => setting.key === SETTING_KEYS.promotionPlan)?.value; + const plan = rawPlan + ? promotionPlanSchema.parse(JSON.parse(rawPlan)) + : defaultPromotionPlan(config.classCount ?? 1); + return { + config: { + month: config.month, + day: config.day, + hour: config.hour, + classCount: plan.tracks[0].entryClassCount, + }, + plan, + }; +} +export async function loadPromotionPlan(): Promise { + return (await loadConfig()).plan; +} +function dueYear(config: { month: number; day: number; hour: number }, now: Date) { const due = new Date( now.getFullYear(), config.month - 1, Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()), config.hour, ); - const year = now < due ? now.getFullYear() - 1 : now.getFullYear(); + return now < due ? now.getFullYear() - 1 : now.getFullYear(); +} +export async function promotionStatus(now = new Date()) { + const { config, plan } = await loadConfig(); + const year = dueYear(config, now); const run = await prisma.promotionRun.findUnique({ where: { year } }); return { config, + plan, year, - pending: !run || run.status !== 'done', - runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 20 }), + pending: run?.status !== 'done', + runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 30 }), today: dayKeyLocal(now), }; } -export async function configurePromotion(input: z.infer) { - await setSetting(SETTING_KEYS.promotionAt, JSON.stringify(input)); +export async function configurePromotion(input: z.infer, actor?: TokenPayload) { + if (actor) await assertGlobalPermission(actor, 'promotion:manage'); + const parsed = promotionConfigSchema.parse(input); + const current = await loadConfig(); + const plan = + parsed.plan ?? + (parsed.classCount === undefined + ? current.plan + : { + ...current.plan, + tracks: current.plan.tracks.map((track, i) => + i ? track : { ...track, entryClassCount: parsed.classCount! }, + ), + }); + await prisma.$transaction(async (tx) => { + for (const [key, value] of [ + [ + SETTING_KEYS.promotionAt, + JSON.stringify({ + month: parsed.month, + day: parsed.day, + hour: parsed.hour, + classCount: plan.tracks[0].entryClassCount, + }), + ], + [SETTING_KEYS.promotionPlan, JSON.stringify(plan)], + ]) + await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } }); + }); return promotionStatus(); } -/** 一年一个事务幂等键;快照失败不得升级。启动遗漏只提示补跑,不擅自改演示库。 */ -export async function runPromotion(year: number, dryRun = false) { - const existing = await prisma.promotionRun.findUnique({ where: { year } }); - if (existing?.status === 'done') return existing; - const classes = await prisma.class.findMany({ - where: { archivedYearId: null, grade: { in: ['高一', '高二', '高三'] } }, +/** 只读预览与执行校验共用同一查询,哈希覆盖配置、学生、班级和周次。 */ +async function buildPreview(year: number, db: Db = prisma) { + const { config, plan } = await loadConfig(db); + const [allClasses, students, weeks, archives] = await Promise.all([ + db.class.findMany({ orderBy: { id: 'asc' } }), + db.student.findMany({ + orderBy: { id: 'asc' }, + select: { id: true, classId: true, status: true, archivedYearId: true, updatedAt: true }, + }), + db.termWeek.findMany({ orderBy: { id: 'asc' } }), + db.archivedYear.findMany({ orderBy: { id: 'asc' } }), + ]); + const classes = allClasses.filter((cls) => !cls.archivedYearId); + const blockers: { code: string; message: string; classId?: string }[] = []; + const warnings = ['新建班级只有骨架,没有班主任或任课关系,请随后分配。']; + const grades = new Set(plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade))); + for (const cls of classes) { + if (!grades.has(cls.grade)) + blockers.push({ + code: 'UNKNOWN_GRADE', + classId: cls.id, + message: `${cls.name} 的年级「${cls.grade}」不在升级计划内`, + }); + if (!cls.enrollmentYear || !cls.classIndex) + blockers.push({ code: 'MISSING_COHORT', classId: cls.id, message: `${cls.name} 缺少入学年份或班号` }); + if ( + students.some( + (student) => student.classId === cls.id && (student.archivedYearId || student.status === 'graduated'), + ) + ) + blockers.push({ + code: 'INCONSISTENT_STUDENT', + classId: cls.id, + message: `${cls.name} 含已归档或已毕业学生,请先处理数据一致性`, + }); + } + const tracks = plan.tracks.map((track, ti) => { + const relevant = classes.filter((cls) => track.stages.some((stage) => stage.grade === cls.grade)); + const seen = new Set(); + const affected = relevant.map((cls) => { + const key = `${cls.enrollmentYear}:${cls.classIndex}`; + if (seen.has(key)) + blockers.push({ + code: 'DUPLICATE_INDEX', + classId: cls.id, + message: `${track.name} 同届存在重复班号:${cls.name}`, + }); + seen.add(key); + const index = track.stages.findIndex((stage) => stage.grade === cls.grade); + const terminal = Boolean(track.stages[index].terminal); + if (terminal && archives.some((archive) => archive.enrollmentYear === cls.enrollmentYear)) + blockers.push({ + code: 'ARCHIVE_EXISTS', + classId: cls.id, + message: `${cls.enrollmentYear} 届已存在档案`, + }); + const members = students.filter((student) => student.classId === cls.id); + return { + id: cls.id, + name: cls.name, + grade: cls.grade, + enrollmentYear: cls.enrollmentYear, + classIndex: cls.classIndex, + nextGrade: terminal ? '毕业归档' : track.stages[index + 1].grade, + terminal, + active: members.filter((student) => student.status === 'active').length, + inactive: members.filter((student) => student.status === 'inactive').length, + transferred: members.filter((student) => student.status === 'transferred').length, + }; + }); + const newClasses = []; + for (let i = 1; i <= track.entryClassCount; i++) { + if ( + relevant.some( + (cls) => cls.enrollmentYear === year && cls.classIndex === i && cls.grade === track.stages[0].grade, + ) + ) + continue; + const code = + ti === 0 ? `N${year}${String(i).padStart(2, '0')}` : `T${ti}${year}${String(i).padStart(2, '0')}`; + if (allClasses.some((cls) => cls.code === code)) + blockers.push({ code: 'CODE_CONFLICT', message: `新班级码 ${code} 已被占用,请先修改冲突班级码` }); + newClasses.push({ + name: formatClassName(year, i), + grade: track.stages[0].grade, + code, + classIndex: i, + enrollmentYear: year, + }); + } + return { id: track.id, name: track.name, classes: affected, newClasses }; }); - const classIds = classes.map((c) => c.id); - const studentCount = await prisma.student.count({ where: { classId: { in: classIds }, status: 'active' } }); - if (dryRun) - return { year, classCount: classes.length, studentCount, status: 'dry-run', snapshotRequired: true }; + warnings.push( + plan.resetTermWeeks + ? `将删除 ${weeks.length} 条全校及班级周次区间。` + : '保留原有周次区间,它们会继续覆盖新学年日期。', + ); + const planHash = createHash('sha256') + .update(JSON.stringify({ year, config, plan, allClasses, students, weeks, archives })) + .digest('hex'); + return { + year, + classCount: classes.length, + studentCount: students.filter( + (student) => student.status === 'active' && classes.some((cls) => cls.id === student.classId), + ).length, + status: 'dry-run', + snapshotRequired: true, + plan, + tracks, + blockers, + warnings, + termWeekDeleteCount: plan.resetTermWeeks ? weeks.length : 0, + termStartDate: plan.termStartDate ?? `${year}-09-01`, + planHash, + }; +} +export async function previewPromotion(year: number) { + return buildPreview(year); +} +function assertExecutable(preview: Awaited>, hash?: string) { + if (hash && hash !== preview.planHash) + throw new ApiError(409, 'CONFLICT', '预览已过期,配置或业务数据已变化,请重新预览', { + code: 'PLAN_CHANGED', + }); + if (preview.blockers.length) + throw new ApiError(409, 'CONFLICT', '升级存在阻断项,请修正后重新预览', preview.blockers); +} +type RunOptions = { + dryRun?: boolean; + actor?: TokenPayload; + planHash?: string; + confirm?: boolean; + catchUp?: boolean; +}; +const running = new Map>>>(); +export async function runPromotion(year: number, dryRunOrOptions: boolean | RunOptions = false) { + const options = typeof dryRunOrOptions === 'boolean' ? { dryRun: dryRunOrOptions } : dryRunOrOptions; + if (options.actor) { + await assertGlobalPermission(options.actor, 'promotion:manage'); + const status = await promotionStatus(); + if (year > status.year) throw ApiError.badRequest('不能执行尚未到期的未来学年'); + if (year < status.year && !(options.actor.role === 'ADMIN' && options.catchUp)) + throw ApiError.forbidden('过去学年仅管理员明确补跑'); + if (!options.dryRun && (!options.confirm || !options.planHash)) + throw ApiError.badRequest('执行必须确认学年并提供本次预览哈希'); + } + if (options.dryRun) return buildPreview(year); + const prior = running.get(year); + if (prior) { + if (options.planHash && (await prisma.promotionRun.findUnique({ where: { year } }))?.status !== 'done') + assertExecutable(await buildPreview(year), options.planHash); + return prior; + } + const task = executePromotion(year, options); + running.set(year, task); + try { + return await task; + } finally { + running.delete(year); + } +} +async function executePromotion(year: number, options: RunOptions) { + const existing = await prisma.promotionRun.findUnique({ where: { year } }); + if (existing?.status === 'done') return { ...existing, alreadyDone: true }; + const preview = await buildPreview(year); + assertExecutable(preview, options.planHash); const dir = path.resolve( env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'), 'promotion-snapshots', ); const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`); + let snapshotSha256 = ''; + const actor = options.actor; try { await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), { - flag: 'wx', - }); - } catch (error) { - await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount); - throw ApiError.internal(`执行前快照失败,未升级任何班级:${String(error)}`); - } - const { config } = await promotionStatus(); - const passwordHash = await hashPassword(env.defaultClassPassword); - const maxWeeks = await getMaxTermWeeks(); - try { + const content = JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)); + await fs.writeFile(snapshotPath, content, { flag: 'wx' }); + snapshotSha256 = createHash('sha256').update(content).digest('hex'); + const passwordHash = await hashPassword(env.defaultClassPassword); + const maxWeeks = await getMaxTermWeeks(); await prisma.$transaction( async (tx) => { - // 唯一键在事务里争抢,重入/多进程都只能有一个提交成功。 - if (existing) await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } }); + const fresh = await buildPreview(year, tx); + assertExecutable(fresh, preview.planHash); + const current = await tx.promotionRun.findUnique({ where: { year } }); + if (current?.status === 'done') return; + await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } }); await tx.promotionRun.create({ - data: { year, status: 'running', snapshotPath, classCount: classes.length, studentCount }, + data: { + year, + status: 'running', + snapshotPath, + snapshotSha256, + operatorId: actor?.sub, + operatorName: actor?.name ?? '学年自动升级', + detailsJson: JSON.stringify(fresh), + classCount: fresh.classCount, + studentCount: fresh.studentCount, + }, }); - const graduating = classes.filter((c) => c.grade === '高三'); - const cohorts = [...new Set(graduating.map((c) => c.enrollmentYear ?? year - 3))]; - for (const enrollmentYear of cohorts) { - const ids = graduating - .filter((c) => (c.enrollmentYear ?? year - 3) === enrollmentYear) - .map((c) => c.id); - const archived = await tx.archivedYear.create({ + const graduating = fresh.tracks.flatMap((track) => track.classes.filter((cls) => cls.terminal)); + for (const enrollmentYear of new Set(graduating.map((cls) => cls.enrollmentYear!))) { + const ids = graduating.filter((cls) => cls.enrollmentYear === enrollmentYear).map((cls) => cls.id); + const counts = graduating.filter((cls) => ids.includes(cls.id)); + const archive = await tx.archivedYear.create({ data: { enrollmentYear, graduationYear: year, name: `${enrollmentYear} 级`, - operatorName: '学年自动升级', + operatorId: actor?.sub, + operatorName: actor?.name ?? '学年自动升级', classCount: ids.length, - studentCount: await tx.student.count({ where: { classId: { in: ids }, status: 'active' } }), + studentCount: counts.reduce((n, cls) => n + cls.active, 0), + transferredCount: counts.reduce((n, cls) => n + cls.transferred, 0), + inactiveCount: counts.reduce((n, cls) => n + cls.inactive, 0), + homeworkCount: await tx.homework.count({ where: { classId: { in: ids } } }), + notificationCount: await tx.notification.count({ where: { classId: { in: ids } } }), }, }); await tx.class.updateMany({ where: { id: { in: ids } }, - data: { archivedYearId: archived.id, archivedAt: new Date() }, + data: { archivedYearId: archive.id, archivedAt: new Date() }, }); await tx.student.updateMany({ where: { classId: { in: ids }, status: 'active' }, - data: { status: 'graduated', archivedYearId: archived.id, archivedAt: new Date() }, + data: { status: 'graduated' }, + }); + await tx.student.updateMany({ + where: { classId: { in: ids } }, + data: { archivedYearId: archive.id, archivedAt: new Date() }, }); } - for (const cls of classes.filter((c) => c.grade !== '高三')) - await tx.class.update({ - where: { id: cls.id }, - data: { - grade: cls.grade === '高一' ? '高二' : '高三', - termStartDate: null, - termWeeks: Math.min(cls.termWeeks, maxWeeks), - }, - }); - for (let i = 1; i <= config.classCount; i++) { - if ( - await tx.class.findFirst({ where: { enrollmentYear: year, classIndex: i, archivedYearId: null } }) - ) - continue; - await tx.class.create({ - data: { - name: formatClassName(year, i), - enrollmentYear: year, - classIndex: i, - grade: '高一', - teacherId: null, - code: `N${year}${String(i).padStart(2, '0')}`, - passwordHash, - termWeeks: Math.min(20, maxWeeks), - }, - }); + for (const track of fresh.tracks) { + for (const cls of track.classes.filter((cls) => !cls.terminal)) + await tx.class.update({ + where: { id: cls.id }, + data: { grade: cls.nextGrade, termStartDate: null }, + }); + for (const cls of track.newClasses) + await tx.class.create({ + data: { + ...cls, + teacherId: null, + passwordHash, + termWeeks: Math.min(20, maxWeeks), + termStartDate: null, + }, + }); } - await tx.termWeek.deleteMany(); + if (fresh.plan.resetTermWeeks) await tx.termWeek.deleteMany(); await tx.appSetting.upsert({ where: { key: SETTING_KEYS.termStartDate }, - create: { key: SETTING_KEYS.termStartDate, value: `${year}-09-01` }, - update: { value: `${year}-09-01` }, + create: { key: SETTING_KEYS.termStartDate, value: fresh.termStartDate }, + update: { value: fresh.termStartDate }, }); await tx.promotionRun.update({ where: { year }, - data: { - status: 'done', - message: '已升级、归档并建立无班主任/任课关系的新班骨架', - finishedAt: new Date(), - }, + data: { status: 'done', message: '已按计划升级、归档并建立新班骨架', finishedAt: new Date() }, }); }, { timeout: 30000 }, ); } catch (error) { - if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done') - return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); - await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount); - throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`); - } - await notifyScheduleChanged(); - return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); -} - -/** 失败也留可见审计;事务内检查,不能把并发已成功的年份覆盖成失败。 */ -async function recordPromotionFailure( - year: number, - snapshotPath: string, - error: unknown, - classCount: number, - studentCount: number, -) { - await prisma.$transaction(async (tx) => { - const current = await tx.promotionRun.findUnique({ where: { year } }); - if (current?.status === 'done') return; - const data = { + const completed = await prisma.promotionRun.findUnique({ where: { year } }); + if (completed?.status === 'done') return { ...completed, alreadyDone: true }; + const failure = { status: 'failed', snapshotPath, - message: String(error), - classCount, - studentCount, + snapshotSha256, + operatorId: actor?.sub, + operatorName: actor?.name ?? '学年自动升级', + detailsJson: JSON.stringify(preview), + error: String(error), + message: '升级失败,业务事务未提交', finishedAt: new Date(), }; - if (current) await tx.promotionRun.update({ where: { year }, data }); - else await tx.promotionRun.create({ data: { year, ...data } }); - }); + await prisma.promotionRun.upsert({ where: { year }, create: { year, ...failure }, update: failure }); + if (error instanceof ApiError) throw error; + throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`); + } + for (const track of preview.tracks) + for (const cls of track.classes) + emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' }); + await notifyScheduleChanged(); + return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false }; } let timer: ReturnType | null = null; export async function startPromotionScheduler() { if (timer) return; let observed = new Date(); - if ((await promotionStatus(observed)).pending) - logger.warn('有遗漏的学年升级,请管理员在学期周次页查看预览与快照说明后补跑'); + if ((await promotionStatus(observed)).pending) logger.warn('有遗漏的学年升级,请在学年升级页预览后补跑'); timer = setInterval(() => { void (async () => { const now = new Date(); - const { config } = await promotionStatus(now); - const due = new Date( - now.getFullYear(), - config.month - 1, - Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()), - config.hour, - ); - if (observed < due && now >= due) await runPromotion(now.getFullYear()); + const { config } = await loadConfig(); + if (dueYear(config, now) > dueYear(config, observed)) await runPromotion(now.getFullYear()); observed = now; - })().catch((e) => logger.error(`自动升级失败:${String(e)}`)); - }, 30_000); + })().catch((error) => logger.error(`自动升级失败:${String(error)}`)); + }, 30000); timer.unref(); } diff --git a/packages/server/src/modules/term/term.module.ts b/packages/server/src/modules/term/term.module.ts index 7f5318c..cd600ab 100644 --- a/packages/server/src/modules/term/term.module.ts +++ b/packages/server/src/modules/term/term.module.ts @@ -1,12 +1,14 @@ +import { assertGlobalPermission } from '../../lib/access.js'; import { z } from 'zod'; import { promotionStatus, + previewPromotion, promotionConfigSchema, configurePromotion, runPromotion, } from './promotion.service.js'; import { Router } from 'express'; -import { sendOk } from '../../lib/http.js'; +import { ApiError, sendOk } from '../../lib/http.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; @@ -22,22 +24,47 @@ import * as termService from './term.service.js'; const router = Router(); router.use(authenticate()); -router.get('/promotion', requirePermission('system:manage'), async (_req, res) => +router.use('/promotion', async (req, _res, next) => { + await assertGlobalPermission(getAuthUser(req), 'promotion:manage'); + next(); +}); +router.get('/promotion', requirePermission('promotion:manage'), async (_req, res) => sendOk(res, await promotionStatus()), ); router.put( '/promotion', - requirePermission('system:manage'), + requirePermission('promotion:manage'), validate({ body: promotionConfigSchema }), - async (req, res) => sendOk(res, await configurePromotion(req.body)), + async (req, res) => sendOk(res, await configurePromotion(req.body, getAuthUser(req))), +); +router.post( + '/promotion/preview', + requirePermission('promotion:manage'), + validate({ body: z.object({ year: z.number().int().min(2000).max(2100) }) }), + async (req, res) => sendOk(res, await previewPromotion(req.body.year)), ); router.post( '/promotion/run', - requirePermission('system:manage'), + requirePermission('promotion:manage'), + (req, _res, next) => { + if (req.body && 'dryRun' in req.body) + return next( + ApiError.badRequest('请更新页面:预览使用 /term/promotion/preview,执行需提供计划哈希并确认学年'), + ); + next(); + }, validate({ - body: z.object({ year: z.number().int().min(2000).max(2100), dryRun: z.boolean().default(true) }), + body: z + .object({ + year: z.number().int().min(2000).max(2100), + planHash: z.string().length(64), + confirm: z.literal(true), + catchUp: z.boolean().optional(), + }) + .strict(), }), - async (req, res) => sendOk(res, await runPromotion(req.body.year, req.body.dryRun)), + async (req, res) => + sendOk(res, await runPromotion(req.body.year, { ...req.body, actor: getAuthUser(req) })), ); /** diff --git a/packages/server/src/modules/term/term.service.ts b/packages/server/src/modules/term/term.service.ts index e119357..099145e 100644 --- a/packages/server/src/modules/term/term.service.ts +++ b/packages/server/src/modules/term/term.service.ts @@ -4,7 +4,8 @@ import { type HolidaySuggestionDto, type TermWeeksDto, } from '@classhelper/shared'; -import { assertCanManageClasses, assertClassAccess } from '../../lib/access.js'; +import { assertClassAccess } from '../../lib/access.js'; +import { assertPermission } from '../../lib/permissions.js'; import { env } from '../../config/env.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; @@ -17,7 +18,7 @@ import { SOCKET_EVENTS } from '@classhelper/shared'; import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js'; /** - * 学期周次(仅管理员可写)。 + * 学期周次(具有 term:write 的员工可写)。 * * 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、 * 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。 @@ -65,7 +66,7 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null): } /** - * 保存逐周配置(整表覆盖)。仅管理员。 + * 保存逐周配置(整表覆盖)。需要 term:write。 * * 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。 * 保存后把新的周次口径下发给该班所有在线设备。 @@ -74,7 +75,7 @@ export async function updateTermWeeks( user: TokenPayload, input: UpdateTermWeeksInput, ): Promise { - assertCanManageClasses(user); + await assertPermission(user, 'term:write'); const scope = scopeOf(input.classId); const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks()); @@ -135,13 +136,13 @@ export async function updateTermWeeks( } /** - * 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。仅管理员。 + * 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。需要 term:write。 * * 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉, * 比从空白开始一周周填要快得多。 */ export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise { - assertCanManageClasses(user); + await assertPermission(user, 'term:write'); const scope = scopeOf(input.classId); const context = await loadTermContext(scope || null); const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate; diff --git a/packages/shared/package.json b/packages/shared/package.json index ce078e1..ba66d1a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/shared", - "version": "1.4.1", + "version": "1.4.2", "private": true, "description": "ClassHelper - 三端共享的类型、常量与工具函数", "type": "module", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b87bedc..7025b53 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,3 +10,4 @@ export * from './class-plan.js'; export * from './island-layout.js'; export type * from './types.js'; export * from './courseware.js'; +export * from './lifecycle.js'; diff --git a/packages/shared/src/lifecycle.ts b/packages/shared/src/lifecycle.ts new file mode 100644 index 0000000..2544e1f --- /dev/null +++ b/packages/shared/src/lifecycle.ts @@ -0,0 +1,52 @@ +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('关联班级范围只允许全部关联班级或仅班主任班'); + return { + scope, + scopeValue: value, + grades: [], + scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级', + }; + } + if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围'); + const grades = [...new Set(value.split(/[,,、\s]+/u).filter(Boolean))].sort(); + if (!grades.length) throw new Error('请选择范围中的年级'); + if (scope === 'GRADE' && grades.length !== 1) throw new Error('年级范围只能选择一个年级'); + if (knownGrades && grades.some((grade) => !knownGrades.includes(grade))) + throw new Error('范围包含未知年级,请刷新选项'); + return { + scope, + scopeValue: grades.join(','), + grades, + scopeLabel: `${scope === 'GRADE' ? '年级' : '部'}:${grades.join('、')}`, + }; +} + +export interface PromotionPlan { + tracks: { + id: string; + name: string; + entryClassCount: number; + stages: { grade: string; terminal?: boolean }[]; + }[]; + termStartDate: string | null; + resetTermWeeks: boolean; +} +export function defaultPromotionPlan(classCount = 1): PromotionPlan { + return { + tracks: [ + { + id: 'high', + name: '高中', + entryClassCount: classCount, + stages: [{ grade: '高一' }, { grade: '高二' }, { grade: '高三', terminal: true }], + }, + ], + termStartDate: null, + resetTermWeeks: true, + }; +} diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index ffbb751..d596cdf 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -200,6 +200,7 @@ export const PERMISSION_KEYS = [ // 学期与归档 'term:read', 'term:write', + 'promotion:manage', 'archive:manage', 'dashboard:read', // 系统与安全 @@ -258,7 +259,7 @@ export const PERMISSION_LABELS: Record = { 'class:read': '查看班级与班级列表', 'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)', 'roster:read': '查看学生名单', - 'roster:manage': '学生名单管理(增删 / 调班 / 导入)', + 'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)', 'teacher:read': '查看教师名单', 'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)', 'schedule:read': '查看课表', @@ -285,6 +286,7 @@ export const PERMISSION_LABELS: Record = { 'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)', 'term:read': '查看学期周次', 'term:write': '修改学期周次与开学日期', + 'promotion:manage': '配置、预览与执行学年升级', 'archive:manage': '毕业归档', 'dashboard:read': '查看统计看板', 'database:manage': '数据库快照 / 导出 / 切库', @@ -342,7 +344,10 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per title: '客户端设备', keys: ['client:read', 'client:manage', 'client:update', 'client:update-push', 'remote:control'], }, - { title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] }, + { + title: '学期与统计', + keys: ['term:read', 'term:write', 'promotion:manage', 'archive:manage', 'dashboard:read'], + }, { title: '系统与安全', keys: [ @@ -399,6 +404,7 @@ export const PERMISSION_DEFAULT_ROLES: Record => api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }), /** 学年升级:只改年级,不归档、数据沿用 */ - promote: (classIds: string[], grade: string): Promise<{ promoted: number }> => - api.post(`${API_PATHS.classes}/promote`, { classIds, grade }), + promote: (classIds: string[]): Promise<{ promoted: number }> => + api.post(`${API_PATHS.classes}/promote`, { classIds }), }; /* ------------------------------------------------------------------ 课程 */ @@ -317,11 +316,16 @@ export const studentApi = { api.patch(`${API_PATHS.students}/${id}`, payload), remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`), /** 调班:单个与批量同一接口(studentIds 长度 1 即单个);学号不变、历史可查 */ - transfer: (studentIds: string[], toClassId: string | null, note?: string): Promise<{ moved: number }> => - api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note }), + transfer: ( + studentIds: string[], + toClassId: string | null, + note?: string, + allowGradeChange?: boolean, + ): Promise<{ moved: number }> => + api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note, allowGradeChange }), /** 转出(学籍离开本校,不是调班) */ - transferOut: (studentIds: string[], note?: string): Promise<{ transferred: number }> => - api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note }), + transferOut: (studentIds: string[], note?: string, toSchool?: string): Promise<{ transferred: number }> => + api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note, toSchool }), /** 某个学生的调班 / 转出历史 */ transfersOf: (id: string): Promise> => api.get(`${API_PATHS.students}/${id}/transfers`), @@ -380,8 +384,6 @@ export const archiveApi = { /** 归档班级的作业与通知(只读) */ classContent: (id: string, classId: string, limit = 200): Promise => api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }), - create: (payload: CreateArchiveRequest): Promise => - api.post(API_PATHS.archives, payload), /** 撤销归档(误操作的退路,不删任何数据) */ remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`), }; diff --git a/packages/web-admin/src/components/ArchiveIcon.vue b/packages/web-admin/src/components/ArchiveIcon.vue new file mode 100644 index 0000000..88dcb0e --- /dev/null +++ b/packages/web-admin/src/components/ArchiveIcon.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/packages/web-admin/src/components/PromotionSettings.vue b/packages/web-admin/src/components/PromotionSettings.vue index 9198b60..3ca9bce 100644 --- a/packages/web-admin/src/components/PromotionSettings.vue +++ b/packages/web-admin/src/components/PromotionSettings.vue @@ -1,77 +1,297 @@ + diff --git a/packages/web-admin/src/components/TransferredStudents.vue b/packages/web-admin/src/components/TransferredStudents.vue index 883868f..0ff34aa 100644 --- a/packages/web-admin/src/components/TransferredStudents.vue +++ b/packages/web-admin/src/components/TransferredStudents.vue @@ -2,30 +2,77 @@ import { onMounted, ref } from 'vue'; import { ElMessageBox } from 'element-plus'; import { api } from '@/api/http'; +import { classApi } from '@/api'; +import { useAuthStore } from '@/stores/auth'; +import type { ClassDto } from '@classhelper/shared'; +const auth = useAuthStore(); const students = ref< { id: string; studentNo: string; name: string; transferNote: string; class: { name: string } | null }[] >([]); +const classes = ref([]), + target = ref(''), + restoring = ref(''), + visible = ref(false), + saving = ref(false); async function load() { students.value = await api.get('/archives/transferred'); } -async function restore(id: string) { - await ElMessageBox.confirm('撤回后学生恢复到原班,转出历史仍保留。', '撤回转出'); - await api.post(`/archives/transferred/${id}/restore`); - await load(); +async function openRestore(id: string) { + restoring.value = id; + target.value = ''; + classes.value = await classApi.list(); + visible.value = true; +} +async function restore() { + await ElMessageBox.confirm( + '恢复到所选在读班级;原转出历史保留。原班已归档时必须指定其他在读班。', + '撤回转出', + ); + saving.value = true; + try { + await api.post( + `/archives/transferred/${restoring.value}/restore`, + target.value ? { toClassId: target.value } : {}, + ); + visible.value = false; + await load(); + } finally { + saving.value = false; + } } onMounted(load); diff --git a/packages/web-admin/src/config/navigation.ts b/packages/web-admin/src/config/navigation.ts index 08e3007..696deb4 100644 --- a/packages/web-admin/src/config/navigation.ts +++ b/packages/web-admin/src/config/navigation.ts @@ -11,7 +11,7 @@ export const MANAGEMENT_GROUPS = [ id: 'school', title: '班级与人员', icon: 'School', - paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/archives'], + paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'], }, { id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] }, { diff --git a/packages/web-admin/src/router/index.ts b/packages/web-admin/src/router/index.ts index 0ff28fd..7ac1262 100644 --- a/packages/web-admin/src/router/index.ts +++ b/packages/web-admin/src/router/index.ts @@ -58,13 +58,13 @@ const routes: RouteRecordRaw[] = [ path: 'classes', name: 'classes', component: () => import('@/views/ClassesView.vue'), - meta: { title: '班级管理', icon: 'School', roles: ['ADMIN'] }, + meta: { title: '班级管理', icon: 'School', permission: 'class:read' }, }, { path: 'students', name: 'students', component: () => import('@/views/StudentsView.vue'), - meta: { title: '学生管理', icon: 'User', roles: ['ADMIN'] }, + meta: { title: '学生管理', icon: 'User', permission: 'roster:read' }, }, { path: 'teachers', @@ -77,14 +77,20 @@ const routes: RouteRecordRaw[] = [ name: 'term-weeks', component: () => import('@/views/TermWeeksView.vue'), // 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限 - meta: { title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] }, + meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' }, + }, + { + path: 'promotion', + name: 'promotion', + component: () => import('@/components/PromotionSettings.vue'), + meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' }, }, { path: 'archives', name: 'archives', component: () => import('@/views/ArchivesView.vue'), // 毕业归档会改动整届班级与学生的状态,与「班级管理」同一档权限 - meta: { title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] }, + meta: { title: '毕业归档', icon: 'FolderOpened', permission: 'archive:manage' }, }, { path: 'schedules', diff --git a/packages/web-admin/src/views/ArchivesView.vue b/packages/web-admin/src/views/ArchivesView.vue index 8381a54..a5d65f6 100644 --- a/packages/web-admin/src/views/ArchivesView.vue +++ b/packages/web-admin/src/views/ArchivesView.vue @@ -85,7 +85,7 @@ async function openContent(row: ArchivedClassDto): Promise { async function undo(row: ArchivedYearDto): Promise { await ElMessageBox.confirm( `撤销「${row.name}」的毕业归档?\n` + - '该届班级会回到在读列表、毕业学生会恢复为在读;作业、通知与成绩一条都不会被删除。', + '仅撤销归档标记:毕业学生恢复在读,转出和停用状态保留。不回滚年级、不删除新班、不恢复已清空周次;完整回滚需手工恢复快照。', '撤销归档', { type: 'warning', confirmButtonText: '确认撤销' }, ); @@ -154,6 +154,7 @@ onMounted(loadYears); + @@ -187,6 +188,10 @@ onMounted(loadYears); :title="detail.note" /> +

+ 有转入记录 {{ detail.transferInCount }} 人 · 转出 {{ detail.transferred.length }} 人 · 停用 + {{ detail.inactive.length }} 人 +

@@ -247,6 +252,13 @@ onMounted(loadYears); + + + + + + + diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index 43835e8..fff1426 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -17,8 +17,10 @@ import { type SubjectTeacherDto, type UpdateClassAccountRequest, type UserDto, + type PromotionPlan, } from '@classhelper/shared'; import { classApi, courseApi, teacherApi, termApi } from '@/api'; +import { api } from '@/api/http'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; import { useResponsive } from '@/composables/useResponsive'; @@ -30,7 +32,7 @@ const realtime = useRealtimeStore(); const { isMobile } = useResponsive(); /** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */ -const isAdmin = computed(() => auth.role === 'ADMIN'); +const canManageClass = computed(() => auth.can('class:manage')); /** 小屏下详情描述改为单列,避免文字被压成竖排 */ const detailColumns = computed(() => (isMobile.value ? 1 : 3)); @@ -39,7 +41,9 @@ const loading = ref(false); const classes = ref([]); const maxTermWeeks = ref(getMaxTermWeeks()); onMounted(async () => { - maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks); + if (auth.can('term:read')) maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks); + if (auth.can('promotion:manage')) + promotionPlan.value = (await api.get<{ plan: PromotionPlan }>('/term/promotion')).plan; }); const keyword = ref(''); @@ -81,7 +85,7 @@ const previewName = computed(() => formatClassName(form.enrollmentYear, form.cla const staffOptions = ref([]); async function loadStaffOptions(): Promise { - if (!isAdmin.value) return; + if (!canManageClass.value) return; if (staffOptions.value.length > 0) return; staffOptions.value = await teacherApi.list().catch(() => []); } @@ -125,13 +129,12 @@ async function submitForm(): Promise { if (editingId.value) { await classApi.update(editingId.value, { - grade: form.grade, enrollmentYear: form.enrollmentYear, classIndex: form.classIndex, termWeeks: form.termWeeks, }); // 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩) - if (isAdmin.value && form.headTeacherId) { + if (canManageClass.value && form.headTeacherId) { const target = classes.value.find((item) => item.id === editingId.value); if (target && target.teacher?.id !== form.headTeacherId) { await classApi.assignHeadTeacher(editingId.value, form.headTeacherId); @@ -147,7 +150,7 @@ async function submitForm(): Promise { // 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。 termWeeks: form.termWeeks, ...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}), - ...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}), + ...(canManageClass.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}), }); ElMessage.success(`班级 ${previewName.value} 创建成功`); } @@ -417,28 +420,32 @@ async function toggleGradeQuery(enabled: boolean): Promise { } } -const promoteForm = reactive({ grade: '', saving: false }); - -/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */ +const promotionPlan = ref(); +const promoteForm = reactive({ saving: false }); +const nextStage = computed(() => { + const track = promotionPlan.value?.tracks.find((track) => + track.stages.some((stage) => stage.grade === detail.value?.grade), + ); + const index = track?.stages.findIndex((stage) => stage.grade === detail.value?.grade) ?? -1; + return track && index >= 0 + ? track.stages[index].terminal + ? '毕业归档(请到学年升级页)' + : track.stages[index + 1].grade + : '请先配置升级计划'; +}); async function promote(): Promise { - if (!detail.value || !promoteForm.grade.trim()) { - ElMessage.warning('请填写升级后的年级,例如「高二」'); - return; - } - const target = promoteForm.grade.trim(); + if (!detail.value) return; await ElMessageBox.confirm( - `把「${detail.value.name}」的年级改为「${target}」?升级只改年级,不归档:` + - '班级记录、学生名单、学号、作业与成绩全部沿用。', - '学年升级', - { type: 'info', confirmButtonText: '确认升级' }, + `将「${detail.value.name}」从 ${detail.value.grade} 升至 ${nextStage.value},保留班级业务数据。`, + '确认升级', + { type: 'warning' }, ); promoteForm.saving = true; try { - await classApi.promote([detail.value.id], target); - ElMessage.success('已升级,数据继续沿用'); - promoteForm.grade = ''; + await classApi.promote([detail.value.id]); await refreshDetail(); await loadClasses(); + ElMessage.success('班级已升至下一学段'); } finally { promoteForm.saving = false; } @@ -470,7 +477,7 @@ onUnmounted(() => { @@ -537,9 +546,11 @@ onUnmounted(() => { @@ -596,12 +607,12 @@ onUnmounted(() => { - + - + { -
+
更换班主任 {
- + 添加学生 共 {{ detail?.students.length ?? 0 }} 人 @@ -688,7 +705,9 @@ onUnmounted(() => { @@ -702,7 +721,9 @@ onUnmounted(() => { @@ -731,7 +752,7 @@ onUnmounted(() => { - + -
+
{
-
+
学年升级
@@ -813,12 +834,15 @@ onUnmounted(() => {
- - 升级 + 下一学段:{{ nextStage }} + + 升级 +
diff --git a/packages/web-admin/src/views/PermissionsView.vue b/packages/web-admin/src/views/PermissionsView.vue index f2615de..4ddb133 100644 --- a/packages/web-admin/src/views/PermissionsView.vue +++ b/packages/web-admin/src/views/PermissionsView.vue @@ -24,6 +24,7 @@ import { type UserRole, } from '@classhelper/shared'; import { permissionApi } from '@/api'; +import { api } from '@/api/http'; import { useAuthStore } from '@/stores/auth'; import { useResponsive } from '@/composables/useResponsive'; @@ -31,7 +32,7 @@ const { isMobile } = useResponsive(); const auth = useAuthStore(); /** 当前 Tab:权限组 / 成员 */ -const activeTab = ref<'groups' | 'members'>('groups'); +const activeTab = ref<'groups' | 'members' | 'audit'>('groups'); const loading = ref(false); /** 加载失败的中文提示(非空 = 页面顶部显示错误态 + 重试按钮) */ @@ -95,11 +96,13 @@ async function loadAll(): Promise { const [groupList, memberList, catalogData] = await Promise.all([ permissionApi.listGroups(), permissionApi.listMembers(), - catalog.value ? Promise.resolve(catalog.value) : permissionApi.catalog(), + permissionApi.catalog(), ]); groups.value = groupList; members.value = memberList; catalog.value = catalogData; + scopes.value = await api.get('/permissions/scopes'); + await loadAudits(); await auth.ensurePermissions(true); } catch (error) { loadError.value = errorMessage(error); @@ -171,7 +174,10 @@ function defaultRoleHint(key: PermissionKey): string { * 这里只打一个「高权限」标记提醒配置的人。 */ function isHighPrivilege(key: PermissionKey): boolean { - return catalog.value?.unremovable.includes(key) ?? false; + return ( + (catalog.value?.defaultRoles[key]?.length === 1 && catalog.value.defaultRoles[key][0] === 'ADMIN') || + (catalog.value?.unremovable.includes(key) ?? false) + ); } /** 分类勾选状态:全选 / 全不选 / 选了一部分(决定"全选-清空"按钮的文案) */ @@ -203,6 +209,10 @@ async function submitGroup(): Promise { ElMessage.warning(`已存在名为「${name}」的权限组`); return; } + await ElMessageBox.confirm(groupImpact.value, '权限变更影响预览', { + type: 'warning', + confirmButtonText: '确认保存', + }); groupDialog.saving = true; try { const payload = { @@ -264,9 +274,10 @@ const assignDialog = reactive({ }); function openAssign(member: PermissionMemberDto): void { - if (member.fixed) return; assignDialog.member = member; - assignDialog.groupIds = [...member.groupIds]; + assignDialog.groupIds = member.groupIds.length + ? [...member.groupIds] + : groups.value.filter((g) => g.defaultRole === member.role).map((g) => g.id); assignDialog.visible = true; } @@ -280,6 +291,10 @@ const roleDefaultCount = computed(() => { async function submitAssign(): Promise { const member = assignDialog.member; if (!member) return; + await ElMessageBox.confirm(assignmentImpact.value, '分组生效差异', { + type: 'warning', + confirmButtonText: '确认分配', + }); assignDialog.saving = true; try { // 空数组 = 回到角色默认模板(服务端契约) @@ -293,15 +308,102 @@ async function submitAssign(): Promise { assignDialog.saving = false; } } +const scopes = ref<{ grades: string[]; divisions: { name: string; grades: string[] }[] }>({ + grades: [], + divisions: [], +}); +const selectedGrades = computed({ + get: () => groupDialog.scopeValue.split(',').filter(Boolean), + set: (value: string[]) => { + groupDialog.scopeValue = value.join(','); + }, +}); +const memberSearch = ref(''), + memberRole = ref(''); +const filteredMembers = computed(() => + members.value.filter( + (member) => + (!memberRole.value || member.role === memberRole.value) && + `${member.name} ${member.username}`.includes(memberSearch.value.trim()), + ), +); +type Audit = { + id: string; + actorName: string; + action: string; + targetId: string; + beforeJson: string; + afterJson: string; + createdAt: string; +}; +const auditPage = ref(1), + audit = ref<{ items: Audit[]; total: number }>({ items: [], total: 0 }); +async function loadAudits() { + audit.value = await api.get('/permissions/audit', { page: auditPage.value, pageSize: 30 }); +} +const groupMembersVisible = ref(false), + selectedGroupMembers = ref([]); +async function showMembers(group: PermissionGroupDto) { + selectedGroupMembers.value = await api.get(`/permissions/groups/${group.id}/members`); + groupMembersVisible.value = true; +} +function copyGroup(group: PermissionGroupDto) { + openEditGroup(group); + groupDialog.id = ''; + groupDialog.name = `${group.name}副本`; +} +function effectiveKeys( + member: PermissionMemberDto, + groupIds: string[], + override?: { id: string; keys: PermissionKey[] }, +) { + const selected = groups.value.filter((group) => + groupIds.length ? groupIds.includes(group.id) : group.defaultRole === member.role, + ); + return [ + ...new Set([ + ...selected.flatMap((group) => (group.id === override?.id ? override.keys : group.keys)), + ...(member.role === 'ADMIN' ? (catalog.value?.unremovable ?? []) : []), + ]), + ]; +} +function keyChanges(before: PermissionKey[], after: PermissionKey[]) { + const labels = (keys: PermissionKey[]) => + keys.map((key) => catalog.value?.labels[key] ?? key).join('、') || '无'; + return `新增:${labels(after.filter((key) => !before.includes(key)))};移除:${labels(before.filter((key) => !after.includes(key)))}`; +} +const groupImpact = computed(() => { + const group = groups.value.find((group) => group.id === groupDialog.id); + const affected = members.value.filter((member) => + member.groupIds.length ? member.groupIds.includes(groupDialog.id) : group?.defaultRole === member.role, + ); + const added = new Set(), + removed = new Set(); + for (const member of affected) { + const next = effectiveKeys(member, member.groupIds, { id: groupDialog.id, keys: groupDialog.keys }); + next.filter((key) => !member.permissions.includes(key)).forEach((key) => added.add(key)); + member.permissions.filter((key) => !next.includes(key)).forEach((key) => removed.add(key)); + } + const label = (keys: Set) => + [...keys].map((key) => catalog.value?.labels[key] ?? key).join('、') || '无'; + return `将影响 ${affected.length} 名成员。新增:${label(added)};移除:${label(removed)}。范围按当前选择保存,可能改变成员可访问的班级。`; +}); +const assignmentImpact = computed(() => { + const member = assignDialog.member; + if (!member) return ''; + const next = effectiveKeys(member, assignDialog.groupIds); + const defaults = catalog.value?.roleDefaults[member.role] ?? []; + return `对当前权限:${keyChanges(member.permissions, next)}。对角色默认模板:${keyChanges(defaults, next)}。所选组替代默认模板,如需保留请同时选择同角色默认组。`; +}); + + @@ -361,9 +465,11 @@ async function submitAssign(): Promise { - + + + +