feat: 完善学生生命周期管理与权限范围并发布 1.4.2

This commit is contained in:
LoveChengke committed 2026-10-23 22:55:21 +08:00
1 parent 6347d1e5aa
commit 24e8dd902b
92 files changed
+6468 -1922

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "classhelper-server-runtime",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+25 -4
View File
@@ -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、明细与错误。快照失败不升级,失败事务回滚后可修正重试。
撤销归档只清标记并将毕业生恢复在读,转出/停用不变;不回滚年级、不删除新班、不恢复已清空周次,整学年恢复需管理员手工使用快照。
+6 -2
View File
@@ -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,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。
## 日课表与时间表
+40 -10
View File
@@ -13,10 +13,10 @@ 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 与管理员业务断言 |
| 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 | 查看/修改班级个人成绩查询开关,班级端不能修改 |
@@ -28,22 +28,22 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
## 教学与文件
| 路径/动作 | 行为及边界 |
| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| /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 | 复制、导入导出及完整档案导入 |
| 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 | 旧预览与完整档案提交;提交走关系化导入 |
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage |
| /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 + 员工身份;本人文件、任课班级与可见范围限制 |
@@ -53,7 +53,7 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
## 设备、系统与运维
| 路径/动作 | 行为及边界 |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
@@ -64,8 +64,8 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
| 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;不提供创建归档代替年度升级的入口 |
| 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;只解析待确认动作,密钥只进不出 |
@@ -75,3 +75,33 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
课件上传是原始文件流,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 且在对应范围内的员工才能获得学生明细。
+14 -2
View File
@@ -11,10 +11,10 @@ description: 现行模型职责、迁移与快照边界。
## 模型职责
| 模型 | 职责 |
| ---------------------- | -------------------------------------------------- |
| ---------------------- | --------------------------------------------------- |
| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class |
| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 |
| `StudentClassTransfer` | 调班与转出历史,保留班级/姓名快照 |
| `StudentClassTransfer` | 生命周期历史,kind/batchId/学年/班级年级及学校快照 |
| `ArchivedYear` | 毕业届别与统计快照 |
| `TermWeek` | 逐周日期区间,classId 空串为全校默认 |
| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 |
@@ -26,6 +26,7 @@ description: 现行模型职责、迁移与快照边界。
| `NotificationRead` | 按 studentId 记录已读 |
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
| `Grade` | studentId、课程、考试、分数和等级 |
| `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 |
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue |
| `PermissionGroupItem` | 组内权限键 |
| `UserPermissionGroup` | 账号与组的多对多分配 |
@@ -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,无需新增学校或学生账号模型。
+13 -1
View File
@@ -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 执行必须提供哈希、确认并检查年份。
+24 -4
View File
@@ -35,14 +35,14 @@ HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.rol
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
| 班级端 | 登录绑定班级 |
空年级/部配置不放行。设备写操作只合并含对应功能键的分组范围,不能借只读组扩权。
空年级/部配置不放行。范围按当前功能键独立合并,不能借其他只读组扩大名单、班级或设备写权限。
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。
| 业务 | 主要功能键与额外边界 |
| ---------------- | ---------------------------------------------------------------------------- |
| 名单读取/管理 | roster:read / roster:manage;管理仍须管理员或本班班主任断言 |
| 教师列表/维护 | teacher:read / teacher:manage;导入名单另检查导入权限和角色 |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| 名单读取/管理 | roster:read / roster:manage;管理需名单键与目标班范围;关联班级仍须班主任关系,全校名单范围可跨班维护 |
| 教师列表/维护 | teacher:read / teacher:manage;导入教师另检查 import:teachers 与 teacher:manage |
| 日课表写入 | schedule:write + 管理员/本班班主任 |
| 全校时间表写入 | timetable:manage;默认给 ADMIN |
| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 |
@@ -50,6 +50,7 @@ HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.rol
| 课件 | 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;新库由默认模板创建。
自定义组和已分配其他组的管理员不会自动获得该键,需要在权限页显式授予。
班级端权限仍为空,本班名单、课表、通知、叫人和作业代录走显式员工守卫例外;班级端发布内容归属本班班主任。
+8 -1
View File
@@ -11,12 +11,13 @@ 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:lifecycle | 临时 SQLite 验证权限治理、事务故障回滚、状态机、历史、多轨道、计划哈希和快照 |
| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 |
| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 |
| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 |
@@ -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 触发器模拟历史写入失败,不能通过删除断言或放宽状态规则通过测试。
+8 -4
View File
@@ -13,15 +13,15 @@ description: 登录、菜单分组、权限与移动布局。
侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。
| 分组 | 主要页面 |
| ---------- | -------------------------------------------------- |
| ---------- | ------------------------------------------------------------ |
| 工作台 | 仪表盘、AI 助手 |
| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 |
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、毕业归档 |
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、学年升级、毕业归档 |
| 设备运维 | 客户端在线状态、命令、更新与安装策略 |
| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 |
班级/学生/教师管理、学期周次、归档和数据库页面还有管理员角色门槛;
教师只读名单、教学页与设备页按权限键和数据范围开放。
班级、学生、学期周次、学年升级和归档页面按各自生效权限开放;教师管理和数据库页面仍保留角色门槛。
名单、教学页与设备页还按对应数据范围开放,写按钮需额外管理键。
每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。
顶栏提供本人密码、使用引导、检查更新与退出等入口。
@@ -32,3 +32,7 @@ description: 登录、菜单分组、权限与移动布局。
同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。
下一步:[建班级、排课表](setup-class.md)。
权限页选组会替代角色默认模板,保存前可查看新增/移除权限与影响成员;保留默认能力时同时选择同角色默认组。
名单页默认在读,历史状态与包含归档分开筛选;资料编辑、调班、转出、转入/复学各有专用入口。
学年升级先保存计划、预览阻断项和周次影响,再输入学年确认执行;执行记录保留快照与 SHA256。
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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');
@@ -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");
+30 -2
View File
@@ -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())
+2 -2
View File
@@ -465,7 +465,7 @@ async function main(): Promise<void> {
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<void> {
toClassName: '转出本校',
operatorId: admin.id,
operatorName: admin.name,
mode: 'transfer-out',
kind: 'transfer-out',
note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day),
},
+65 -54
View File
@@ -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 {
+416
View File
@@ -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<unknown>, 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<void>((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<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}
+2
View File
@@ -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: {
+69 -15
View File
@@ -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<boolean> {
export async function canAccessClass(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
): Promise<boolean> {
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<void> {
export async function assertClassAccess(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await assertClassAccess(user, classId);
@@ -298,10 +349,13 @@ export async function resolveClassScope(
permission?: ContractPermissionKey,
): Promise<ClassScope> {
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);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
+18 -2
View File
@@ -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),
+32 -12
View File
@@ -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<Prisma.ClassWhereInput> {
permission ??= currentRequestPermission(user);
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
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
);
}
+31
View File
@@ -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<TokenPayload, ReadonlySet<PermissionKey>>();
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
requestPermission.set(user, key);
}
export function currentRequestPermission(user: TokenPayload) {
return requestPermission.get(user);
}
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
requestPermissions.set(user, permissions);
}
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await hasEffectivePermission(user, key)))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
bindRequestPermission(user, key);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(user, matched);
}
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
+1
View File
@@ -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];
+15 -1
View File
@@ -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<string, unknown>)[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<string, unknown>) };
delete copy.notificationChannel;
@@ -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<StudentLifecycleKind, string[]> = {
'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;
}
+3 -3
View File
@@ -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;
}
+9 -1
View File
@@ -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();
};
}
@@ -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(),
},
'获取归档汇总成功',
@@ -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({
/** 只看某一届 */
@@ -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<typeof createArchiveSchema>;
/**
* 毕业归档。
@@ -36,12 +30,12 @@ type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
*
* 全部接口**仅管理员**。
* 全部接口受 archive:manage 与全校范围保护。
*/
/** 届别列表(按入学年份倒序) */
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
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<ArchivedYearDetailDto> {
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<ArchivedClassContentDto> {
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<ArchivedYearDetailDto> {
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<void> {
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 } } }),
@@ -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 };
}
@@ -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) => {
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',
@@ -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<NotificationDto> {
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 },
@@ -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) =>
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) => {
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) => {
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) => {
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('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
},
);
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'),
@@ -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 {
@@ -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) => {
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) => {
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) => {
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) => {
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(
@@ -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),
});
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
@@ -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<ClassDto[]> {
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<ClassDetailDto> {
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<ClassDto> {
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<ClassDto> {
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<SubjectTeacherDto[]> {
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<ClassDto> {
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<ClassDto> {
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<void> {
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<StudentDto[]> {
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<StudentDto> {
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),
if (existing && (existing.archivedYearId || existing.status !== 'active'))
throw ApiError.conflict('学号已被历史学生占用,请使用转入、复学或撤销归档入口');
const result = await changeStudentLifecycle(user, {
studentIds: existing ? [existing.id] : [],
kind: 'class-change',
toClassId: classId,
toClassName: await classLabel(classId),
operatorId: user.sub,
operatorName: user.name,
mode: 'single',
note: '在班级名单里直接加入',
},
note: '班级名单添加',
...(!existing ? { newStudent: input } : {}),
});
}
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 },
return toStudentDto(
await prisma.student.findUniqueOrThrow({
where: { id: result.studentId },
include: { class: { select: { name: true, grade: true } } },
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toStudentDto(student);
}),
);
}
/** 将学生移出班级(保留名单记录,回到「未分班」) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
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<strin
}
return teacher.id;
}
/** 班级名(写调班历史快照用);班级不存在时返回空串 */
async function classLabel(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
@@ -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) => {
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(
@@ -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,7 +30,11 @@ const router = Router();
router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => {
router.get(
'/',
requireStaffPermission('homework:read'),
validate({ query: listHomeworksQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
@@ -34,7 +43,8 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
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) => {
router.get(
'/days',
requireStaffPermission('homework:read'),
validate({ query: listHomeworkDaysQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListHomeworkDaysInput>(req);
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
});
},
);
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
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) => {
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);
@@ -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<HomeworkDto[]> {
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<HomeworkDaysDto> {
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<HomeworkDto> {
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<HomeworkDto> {
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<BulkPublishResult> {
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<HomeworkDto> {
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<void> {
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<HomeworkStatusDto> {
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<HomeworkSubmissionsDto> {
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<HomeworkSubmissionsDto> {
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<HomeworkSubmissionsDto> {
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('当前班级还没有学生,请先在名单里添加学生');
@@ -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<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
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 },
@@ -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<void> {
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<TableFileInput>(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<TableCommitInput>(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) => {
router.post(
'/time-layout/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanManageSchedule } = await import('../../lib/access.js');
await assertCanManageSchedule(getAuthUser(req), input.classId);
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) => {
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<TimeLayoutImportInput>(req));
@@ -183,20 +191,29 @@ 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) => {
router.post(
'/class-plan/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(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) => {
router.post(
'/class-plan',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
@@ -204,7 +221,8 @@ router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
);
});
},
);
export const importsModule = defineModule({
name: 'imports',
@@ -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: {
if (existing.classId !== classId)
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
await updateStudent(user, existing.id, {
name,
classId,
status: 'active',
...(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<void> {
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<string> {
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();
@@ -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) {
@@ -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) => {
router.get(
'/',
requireStaffPermission('notification:read'),
validate({ query: listNotificationsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListNotificationQuery>(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) => {
router.get(
'/days',
requireStaffPermission('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(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) => {
router.get(
'/creators',
requireStaffPermission('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
});
},
);
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
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) => {
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) => {
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(
@@ -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<NotificationDto[]> {
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<NotificationDaysDto> {
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<NotificationCreatorsDto> {
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<BulkPublishResult> {
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<NotificationDto> {
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<void> {
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<number> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({
@@ -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<CreatePermissionGroupInput>(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<UpdatePermissionGroupInput>(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<AssignUserGroupsInput>(req);
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
sendOk(res, await permissionService.assignUserGroups(getAuthUser(req), id, input), '权限组分配已保存');
},
);
@@ -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<string, number>(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<PermissionGroupDto> {
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<PermissionGroupDto> {
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<PermissionMemberDto[]> {
await ensureDefaultGroups();
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ include: { items: true } }),
]);
return rows.map((row) => {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const effective = groups.filter((group) =>
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
);
const permissions = normalizeKeys([
...effective.flatMap((group) => group.items.map((item) => item.key)),
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]);
return {
id: row.id,
username: row.username,
name: row.name,
role: row.role as UserRole,
groupIds,
permissions,
permissionCount: permissions.length,
scopeLabels: [...new Set(effective.map(scopeLabel))],
};
});
}
export async function groupMembers(groupId: string) {
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
if (!group) throw ApiError.notFound('权限组不存在');
return (await listMembers()).filter((member) =>
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
);
}
export async function assignUserGroups(
actor: TokenPayload,
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
await assertPermission(actor, 'permission:manage');
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
const ids = [...new Set(input.groupIds)];
const groups = await prisma.permissionGroup.findMany({
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
include: { items: true },
});
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
for (const group of groups) {
if (group.defaultRole && group.defaultRole !== user.role)
throw ApiError.forbidden('默认组只能分配给同角色账号');
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
}
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
const before = (await listMembers()).find((member) => member.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (ids.length)
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
await tx.permissionAudit.create({
data: auditData(actor, 'assign', 'user', userId, before, {
groupIds: ids,
permissions: normalizeKeys([
...groups.flatMap((group) => group.items.map((item) => item.key)),
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]),
}),
});
});
return (await listMembers()).find((member) => member.id === userId)!;
}
export async function listAudits(page = 1, pageSize = 30) {
const [items, total] = await Promise.all([
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<PermissionCatalogDto> {
await ensureDefaultGroups();
@@ -150,213 +396,13 @@ export async function listGroups(): Promise<PermissionGroupDto[]> {
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
return (rows as GroupRow[]).map(toGroupDto);
}
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
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<PermissionGroupDto> {
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<string, PermissionKey[]>): PermissionMemberDto {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const union = new Set<PermissionKey>();
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<PermissionMemberDto[]> {
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<string, PermissionKey[]>(
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<PermissionMemberDto> {
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<string, PermissionKey[]>(
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,
}));
}
/**
@@ -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,10 +28,18 @@ import * as scheduleService from './schedules.service.js';
const router = Router();
router.use(authenticate());
router.get('/active-week', validate({ query: gridQuerySchema }), async (req, res) =>
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', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
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(
@@ -37,7 +50,8 @@ router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1)
orderBy: { date: 'asc' },
}),
);
});
},
);
router.delete(
'/scheduled/:id',
requirePermission('schedule:write'),
@@ -93,14 +107,23 @@ router.post(
);
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
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) => {
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(
@@ -112,14 +135,19 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
}),
'获取周视图成功',
);
});
},
);
/**
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
*/
router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => {
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, {
@@ -127,7 +155,8 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
at: at ? new Date(at) : undefined,
});
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
});
},
);
/** POST /api/schedules - 新增课表 */
router.post(
@@ -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 }, '学生名单操作已完成');
},
);
@@ -23,22 +23,44 @@ export const listStudentsQuerySchema = z.object({
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
export const createStudentSchema = z.object({
export const createStudentSchema = z
.object({
studentNo: studentNoSchema,
name: studentNameSchema,
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
});
status: z.enum(['active', 'inactive']).optional(),
})
.strict();
export const updateStudentSchema = z.object({
export const updateStudentSchema = z
.object({
studentNo: studentNoSchema.optional(),
name: studentNameSchema.optional(),
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
})
.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(),
@@ -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<StudentDto[]> {
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<StudentDto> {
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<StudentDto> {
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<void> {
/** 默认停用且保留业务;硬删除须管理员确认并证明没有任何历史。 */
export async function deleteStudent(
user: TokenPayload,
studentId: string,
options: { hard?: boolean; confirm?: boolean } = {},
): Promise<void> {
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(
export async function transferStudents(user: TokenPayload, input: TransferStudentsInput) {
const result = await changeStudentLifecycle(user, {
...input,
kind: input.toClassId ? 'class-change' : 'unassign',
});
return { ...result, moved: result.count };
}
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: TransferStudentsInput,
): Promise<{ moved: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班');
const students = await prisma.student.findMany({
where: { id: { in: input.studentIds }, ...activeStudentWhere() },
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,
},
}
: {}),
});
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 } }),
),
return toStudentDto(
await prisma.student.findUniqueOrThrow({ where: { id: result.studentId }, include: withClass }),
);
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 };
}
/**
* 学生转出:学籍离开本校(**不是调班**)。
*
* 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`,
* 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
*/
export async function transferOutStudents(
export async function restoreStudent(
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 ?? '',
})),
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 ?? '撤回转出或复学,保留原历史',
});
notifyClassChange(
pending.map((item) => item.classId),
null,
);
const created = await prisma.studentClassTransfer.findMany({
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
orderBy: { createdAt: 'desc' },
take: pending.length,
});
return { transferred: pending.length, transfers: created.map(toStudentTransferDto) };
}
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
@@ -256,9 +218,8 @@ export async function listTransfers(
user: TokenPayload,
options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> {
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<StudentTransferDto[]> {
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<Map<string, string>> {
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<void> {
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<void> {
const existing = await prisma.student.findUnique({
@@ -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({
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<string>(),
tracks = new Set<string>();
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(1).max(99).default(1),
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] } },
});
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;
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<PromotionPlan> {
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<typeof promotionConfigSchema>) {
await setSetting(SETTING_KEYS.promotionAt, JSON.stringify(input));
export async function configurePromotion(input: z.infer<typeof promotionConfigSchema>, 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}」不在升级计划内`,
});
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 };
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<string>();
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 };
});
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<ReturnType<typeof buildPreview>>, 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<number, Promise<Awaited<ReturnType<typeof executePromotion>>>>();
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 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();
try {
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 !== '高三'))
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.grade === '高一' ? '高二' : '高三',
termStartDate: null,
termWeeks: Math.min(cls.termWeeks, maxWeeks),
},
data: { grade: cls.nextGrade, termStartDate: null },
});
for (let i = 1; i <= config.classCount; i++) {
if (
await tx.class.findFirst({ where: { enrollmentYear: year, classIndex: i, archivedYearId: null } })
)
continue;
for (const cls of track.newClasses)
await tx.class.create({
data: {
name: formatClassName(year, i),
enrollmentYear: year,
classIndex: i,
grade: '高一',
...cls,
teacherId: null,
code: `N${year}${String(i).padStart(2, '0')}`,
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<typeof setInterval> | 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();
}
@@ -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) })),
);
/**
@@ -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<TermWeeksDto> {
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<TermWeeksDto> {
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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
+1
View File
@@ -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';
+52
View File
@@ -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,
};
}
+9 -2
View File
@@ -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<PermissionKey, string> = {
'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<PermissionKey, string> = {
'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<PermissionKey, readonly UserRole[]
'remote:control': ['ADMIN'],
'term:read': ['ADMIN', 'TEACHER'],
'term:write': ['ADMIN'],
'promotion:manage': ['ADMIN'],
'archive:manage': ['ADMIN'],
'dashboard:read': ['ADMIN', 'TEACHER'],
'database:manage': ['ADMIN'],
@@ -454,6 +460,7 @@ export const PERMISSION_MATRIX: readonly {
subject: boolean;
classDevice?: boolean;
}[] = [
{ action: '学年升级(默认管理员,可按权限组授权)', admin: true, head: false, subject: false },
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
+21 -9
View File
@@ -186,16 +186,14 @@ export interface CreateStudentRequest {
classId?: string | null;
gender?: StudentGender;
guardianPhone?: string;
status?: StudentStatus;
status?: 'active' | 'inactive';
}
export interface UpdateStudentRequest {
studentNo?: string;
name?: string;
classId?: string | null;
gender?: StudentGender;
guardianPhone?: string;
status?: StudentStatus;
}
/** 学生是否属于某个班的判定范围(列表查询用) */
@@ -207,10 +205,19 @@ export interface StudentQueryParams {
/* ------------------------------------------------------------------ 调班 */
/** 调班 / 转出的方式 */
export type StudentTransferMode = 'single' | 'batch' | 'transfer-out';
export type StudentTransferMode = 'single' | 'batch' | 'transfer-out' | 'restore-transfer';
export type StudentLifecycleKind =
'class-change' | 'transfer-in' | 'transfer-out' | 'restore' | 'unassign' | 'deactivate';
/** 一次调班 / 转出的历史记录(原班级 / 新班级 / 操作人 / 时间) */
export interface StudentTransferDto {
kind: StudentLifecycleKind;
batchId: string;
academicYear: number | null;
fromGrade: string;
toGrade: string;
fromSchool: string;
toSchool: string;
id: string;
studentId: string;
/** 学号快照:学生被删除后历史仍可读 */
@@ -234,6 +241,7 @@ export interface StudentTransferDto {
* **学号不变**,只改学生当前的班级归属;历史作业与成绩保留原归属。
*/
export interface TransferStudentsRequest {
allowGradeChange?: boolean;
studentIds: string[];
toClassId: string | null;
note?: string;
@@ -244,6 +252,7 @@ export interface TransferStudentsRequest {
* 单个与批量共用 —— 传一个 id 就是单个。
*/
export interface TransferOutStudentsRequest {
toSchool?: string;
studentIds: string[];
/** 转出去向 / 原因备注 */
note?: string;
@@ -272,6 +281,7 @@ export interface ArchivedYearDto {
classCount: number;
studentCount: number;
transferredCount: number;
inactiveCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: string;
@@ -292,11 +302,14 @@ export interface ArchivedClassDto {
}
export interface ArchivedYearDetailDto extends ArchivedYearDto {
/** 本届保留名单中有转入记录的学生数,按学生去重 */
transferInCount: number;
classes: ArchivedClassDto[];
/** 本届毕业生 */
graduates: StudentDto[];
/** 本届在读期间转出的学生 */
transferred: StudentDto[];
inactive: StudentDto[];
}
/** 归档班级的作业与通知(只读) */
@@ -488,8 +501,6 @@ export interface UpdateClassRequest {
enrollmentYear?: number;
/** 班号(同上) */
classIndex?: number;
/** 当前年级(高一 / 高二 / 高三;学年升级时改它,**不归档**) */
grade?: string;
/** 本学期教学周数(1~30,仅管理员可调) */
termWeeks?: number;
}
@@ -1456,6 +1467,7 @@ export interface UpdateInfo {
export interface PermissionGroupDto {
scope?: ContractPermissionScope;
scopeValue?: string;
scopeLabel: string;
id: string;
name: string;
description: string;
@@ -1476,10 +1488,10 @@ export interface PermissionMemberDto {
role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */
groupIds: string[];
/** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */
/** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */
permissionCount: number;
/** 该账号固定拥有全部权限,不接受分配(管理员) */
fixed: boolean;
permissions: PermissionKey[];
scopeLabels: string[];
}
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
+11 -9
View File
@@ -18,7 +18,6 @@ import {
type ClientPushResult,
type ClientReleaseDto,
type CourseDto,
type CreateArchiveRequest,
type CreateCallRequest,
type CreateClassRequest,
type CreateClientCommandRequest,
@@ -131,8 +130,8 @@ export const classApi = {
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
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<PaginatedResult<StudentTransferDto>> =>
api.get(`${API_PATHS.students}/${id}/transfers`),
@@ -380,8 +384,6 @@ export const archiveApi = {
/** 归档班级的作业与通知(只读) */
classContent: (id: string, classId: string, limit = 200): Promise<ArchivedClassContentDto> =>
api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }),
create: (payload: CreateArchiveRequest): Promise<ArchivedYearDetailDto> =>
api.post(API_PATHS.archives, payload),
/** 撤销归档(误操作的退路,不删任何数据) */
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`),
};
@@ -0,0 +1,52 @@
<script setup lang="ts">
// 档案编辑器采用与参考一致的细线图标,不依赖系统字体图标。
defineProps<{ name: string }>();
const paths: Record<string, string> = {
archive: 'M3 4h12v7H3z M6 11v8h15v-8H6 M10 14h4 M15 4h6v7',
file: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 12h6 M9 15h6 M9 18h4',
chart: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 17v-4 M12 17v-7 M15 17v-5',
group: 'M8 7H5V4 M16 7h3V4 M8 17H5v3 M16 17h3v3 M9 8h6v8H9z M3 11v2 M21 11v2',
save: 'M4 3h13l3 3v15H4z M8 3v6h8V3 M8 21v-8h9v8',
transfer: 'M13 3h5l3 3v12h-7 M18 3v5h3 M4 16h8 M8 12l4 4-4 4 M4 5h5v5H4z',
help: 'M9 7a3 3 0 1 1 5 2c-2 1-2 2-2 4 M12 17v.2',
refresh: 'M20 7v5h-5 M20 12a8 8 0 1 0-2 5 M20 7l-3-3',
left: 'M20 12H4 M10 6l-6 6 6 6',
right: 'M4 12h16 M14 6l6 6-6 6',
plus: 'M12 4v16 M4 12h16',
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 7v5l4 2',
grid: 'M4 4h16v16H4z M4 9h16 M4 14h16 M9 4v16 M15 4v16',
calendar: 'M4 5h16v16H4z M4 10h16 M8 3v4 M16 3v4 M8 14h1 M12 14h1 M16 14h1 M8 17h1 M12 17h1',
list: 'M8 5h13 M8 12h13 M8 19h13 M3 4h1v2H3z M3 11h1v2H3z M3 18h1v2H3z',
settings:
'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M10 3h4l1 3 3 1 3 3v4l-3 1-1 3-3 3h-4l-1-3-3-1-3-3v-4l3-1 1-3z',
close: 'M5 5l14 14 M19 5L5 19',
shield: 'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',
back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3',
chevron: 'M6 9l6 6 6-6',
};
</script>
<template>
<svg
class="archive-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="paths[name] || paths.file" />
</svg>
</template>
<style scoped>
.archive-icon {
display: inline-block;
flex: 0 0 auto;
width: 22px;
height: 22px;
vertical-align: middle;
}
</style>
@@ -1,77 +1,297 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { type PromotionPlan } from '@classhelper/shared';
import { api } from '@/api/http';
type Status = {
config: { month: number; day: number; hour: number; classCount: number };
year: number;
pending: boolean;
runs: {
type Run = {
year: number;
status: string;
message: string;
classCount: number;
studentCount: number;
snapshotPath: string;
snapshotSha256: string;
operatorName: string;
detailsJson: string;
error: string;
};
type Preview = {
year: number;
planHash: string;
classCount: number;
studentCount: number;
blockers: { code: string; message: string }[];
warnings: string[];
termWeekDeleteCount: number;
termStartDate: string;
tracks: {
id: string;
name: string;
classes: {
id: string;
name: string;
grade: string;
nextGrade: string;
active: number;
inactive: number;
transferred: number;
}[];
newClasses: { name: string; grade: string; code: string }[];
}[];
};
type Status = {
config: { month: number; day: number; hour: number; classCount: number };
plan: PromotionPlan;
year: number;
pending: boolean;
runs: Run[];
};
const status = ref<Status>();
const preview = ref<Preview>();
const year = ref(2026);
const visible = ref(false),
saving = ref(false),
running = ref(false),
loading = ref(false);
async function load() {
status.value = await api.get('/term/promotion');
year.value = status.value!.year;
}
function normalizeStages() {
for (const track of status.value?.plan.tracks ?? [])
track.stages.forEach((stage, i) => {
stage.terminal = i === track.stages.length - 1;
});
}
function addTrack() {
status.value?.plan.tracks.push({
id: `track-${Date.now().toString(36)}`,
name: '新轨道',
entryClassCount: 0,
stages: [{ grade: '', terminal: true }],
});
}
function moveStage(track: PromotionPlan['tracks'][number], index: number, direction: number) {
const next = index + direction;
if (next < 0 || next >= track.stages.length) return;
[track.stages[index], track.stages[next]] = [track.stages[next], track.stages[index]];
normalizeStages();
}
async function save() {
if (status.value) await api.put('/term/promotion', status.value.config);
await load();
}
async function catchUp() {
if (!status.value) return;
const preview = await api.post<{ classCount: number; studentCount: number }>('/term/promotion/run', {
year: status.value.year,
dryRun: true,
});
await ElMessageBox.confirm(
`将影响 ${preview.classCount} 个班、${preview.studentCount} 名学生。执行前自动保存全库快照;高三归档,其余升一级,新建高一骨架。`,
'补跑学年升级',
{ type: 'warning' },
);
await api.post('/term/promotion/run', { year: status.value.year, dryRun: false });
normalizeStages();
saving.value = true;
try {
await api.put('/term/promotion', { ...status.value.config, plan: status.value.plan });
preview.value = undefined;
await load();
ElMessage.success('升级计划已保存');
} finally {
saving.value = false;
}
}
async function showPreview() {
loading.value = true;
try {
preview.value = await api.post('/term/promotion/preview', { year: year.value });
visible.value = true;
} finally {
loading.value = false;
}
}
async function execute() {
if (!preview.value || running.value) return;
const { year: target, planHash } = preview.value;
await ElMessageBox.prompt(
`请输入 ${target} 确认执行。快照可供手工恢复;撤销归档不会回滚年级、新班或周次。`,
'确认学年升级',
{
inputValidator: (value) => value === String(target) || '输入必须与预览学年一致',
confirmButtonText: '执行升级',
type: 'warning',
},
);
running.value = true;
try {
await api.post('/term/promotion/run', { year: target, planHash, confirm: true, catchUp: true });
visible.value = false;
await load();
ElMessage.success('学年升级完成,快照与执行明细已保留');
} finally {
running.value = false;
}
}
onMounted(load);
</script>
<template>
<el-card v-if="status">
<h3>每年自动学年升级</h3>
<el-form inline>
<el-form-item label="月">
<el-card v-if="status" shadow="never">
<h3>学年升级计划</h3>
<el-alert
title="先保存计划,再预览影响。执行前保存全库快照;恢复整学年需要管理员手工恢复快照。"
type="info"
:closable="false"
/>
<el-form label-position="top" :disabled="saving || running">
<div class="toolbar">
<el-form-item label="执行月份">
<el-input-number v-model="status.config.month" :min="1" :max="12" />
</el-form-item>
<el-form-item label="日">
<el-form-item label="执行日期">
<el-input-number v-model="status.config.day" :min="1" :max="31" />
</el-form-item>
<el-form-item label="时">
<el-form-item label="执行小时">
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
</el-form-item>
<el-form-item label="新高一班级数">
<el-input-number v-model="status.config.classCount" :min="1" :max="99" />
<el-form-item label="新学期开学日(空则当年 9 月 1 日)">
<el-date-picker
v-model="status.plan.termStartDate"
type="date"
value-format="YYYY-MM-DD"
clearable
/>
</el-form-item>
<el-button @click="save">保存升级时间</el-button>
<el-form-item label="周次区间处理">
<el-switch
v-model="status.plan.resetTermWeeks"
active-text="重置全校及班级周次"
inactive-text="保留原周次"
/>
</el-form-item>
</div>
<el-card v-for="(track, ti) in status.plan.tracks" :key="track.id" shadow="never" class="track-card">
<div class="toolbar">
<el-form-item label="轨道名称"><el-input v-model="track.name" /></el-form-item>
<el-form-item label="起始年级新班数">
<el-input-number v-model="track.entryClassCount" :min="0" :max="99" />
</el-form-item>
<el-button
:disabled="status.plan.tracks.length === 1"
type="danger"
plain
@click="status.plan.tracks.splice(ti, 1)"
>
删除轨道
</el-button>
</div>
<div v-for="(stage, si) in track.stages" :key="si" class="toolbar stage-row">
<el-form-item :label="`第 ${si + 1} 学段`">
<el-input v-model="stage.grade" placeholder="填写年级名称" />
</el-form-item>
<el-tag v-if="si === track.stages.length - 1" type="warning">毕业学段</el-tag>
<el-button :disabled="si === 0" @click="moveStage(track, si, -1)">上移</el-button>
<el-button :disabled="si === track.stages.length - 1" @click="moveStage(track, si, 1)">
下移
</el-button>
<el-button
:disabled="track.stages.length === 1"
@click="
track.stages.splice(si, 1);
normalizeStages();
"
>
移除学段
</el-button>
</div>
<el-button
@click="
track.stages.push({ grade: '', terminal: true });
normalizeStages();
"
>
添加学段
</el-button>
</el-card>
<div class="toolbar">
<el-button @click="addTrack">添加轨道</el-button>
<el-button type="primary" :loading="saving" @click="save">保存计划</el-button>
</div>
</el-form>
<p>新学年开学日重置为 9 月 1 日,保留总周数上限;新班主任与任课关系由管理员指定。</p>
<div class="toolbar preview-toolbar">
<el-form-item label="预览学年">
<el-input-number v-model="year" :min="2000" :max="status.year" />
</el-form-item>
<el-button :loading="loading" @click="showPreview">预览已保存计划</el-button>
<el-tag :type="status.pending ? 'warning' : 'success'">
{{ status.pending ? `待执行 ${status.year} 学年` : '应执行学年已完成' }}
</el-tag>
</div>
<h4>执行记录</h4>
<el-table :data="status.runs">
<el-table-column prop="year" label="学年" width="90" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="operatorName" label="操作人" min-width="130" />
<el-table-column prop="classCount" label="班级" width="80" />
<el-table-column prop="studentCount" label="在读学生" width="100" />
<el-table-column label="记录与快照" min-width="350">
<template #default="{ row }">
<details>
<summary>{{ row.error || row.message }}</summary>
<p class="digest">
快照:{{ row.snapshotPath }}
<br />
SHA256:{{ row.snapshotSha256 }}
</p>
<pre>{{ row.detailsJson }}</pre>
</details>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="visible" title="学年升级影响预览" size="90%" :close-on-click-modal="!running">
<template v-if="preview">
<p>
{{ preview.year }} 学年 · {{ preview.classCount }} 个班 · {{ preview.studentCount }} 名在读学生 ·
开学日 {{ preview.termStartDate }} · 删除周次 {{ preview.termWeekDeleteCount }} 条
</p>
<el-alert
v-if="status.pending"
title="有尚未执行的学年升级,请预览影响后补跑"
v-for="block in preview.blockers"
:key="block.message"
:title="block.message"
type="error"
:closable="false"
/>
<el-alert
v-for="warning in preview.warnings"
:key="warning"
:title="warning"
type="warning"
:closable="false"
/>
<el-button v-if="status.pending" @click="catchUp">预览并补跑 {{ status.year }} 学年</el-button>
<el-table :data="status.runs">
<el-table-column prop="year" label="学年" />
<el-table-column prop="status" label="结果" />
<el-table-column prop="classCount" label="班级数" />
<el-table-column prop="studentCount" label="学生数" />
<el-table-column prop="message" label="执行记录" />
<el-table-column prop="snapshotPath" label="执行前快照" />
<section v-for="track in preview.tracks" :key="track.id">
<h4>{{ track.name }}</h4>
<el-table :data="track.classes">
<el-table-column prop="name" label="班级" min-width="150" />
<el-table-column prop="grade" label="当前年级" />
<el-table-column prop="nextGrade" label="处理结果" />
<el-table-column prop="active" label="在读" />
<el-table-column prop="inactive" label="停用" />
<el-table-column prop="transferred" label="转出" />
</el-table>
<el-table :data="track.newClasses" empty-text="不新建班级">
<el-table-column prop="name" label="新班" />
<el-table-column prop="grade" label="年级" />
<el-table-column prop="code" label="班级码" />
</el-table>
</section>
<p class="digest">计划哈希:{{ preview.planHash }}</p>
<el-button type="danger" :disabled="preview.blockers.length > 0" :loading="running" @click="execute">
确认学年并执行
</el-button>
</template>
</el-drawer>
</el-card>
</template>
<style scoped>
.track-card,
.preview-toolbar {
margin-top: 16px;
margin-bottom: 16px;
}
.stage-row {
margin-bottom: 8px;
}
.digest,
pre {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>
@@ -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<ClassDto[]>([]),
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`);
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);
</script>
<template>
<el-card>
<el-card shadow="never">
<h3>已转出</h3>
<el-table :data="students">
<el-table-column prop="studentNo" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="class.name" label="原班级" />
<el-table-column prop="transferNote" label="转出说明" />
<el-table-column label="操作">
<template #default="{ row }"><el-button link @click="restore(row.id)">撤回转出</el-button></template>
<el-table-column v-if="auth.can('roster:manage')" label="操作">
<template #default="{ row }">
<el-button link @click="openRestore(row.id)">撤回转出</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="visible" title="撤回转出" width="min(92%, 480px)">
<el-form label-position="top">
<el-form-item label="目标在读班级(留空使用原班)">
<el-select v-model="target" clearable>
<el-option
v-for="cls in classes"
:key="cls.id"
:value="cls.id"
:label="`${cls.name} / ${cls.grade}`"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="restore">确认撤回</el-button>
</template>
</el-dialog>
</el-card>
</template>
+1 -1
View File
@@ -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'] },
{
+10 -4
View File
@@ -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',
+13 -1
View File
@@ -85,7 +85,7 @@ async function openContent(row: ArchivedClassDto): Promise<void> {
async function undo(row: ArchivedYearDto): Promise<void> {
await ElMessageBox.confirm(
`撤销「${row.name}」的毕业归档?\n` +
'该届班级会回到在读列表、毕业学生会恢复为在读;作业、通知与成绩一条都不会被删除。',
'仅撤销归档标记:毕业学生恢复在读,转出和停用状态保留。不回滚年级、不删除新班、不恢复已清空周次;完整回滚需手工恢复快照。',
'撤销归档',
{ type: 'warning', confirmButtonText: '确认撤销' },
);
@@ -154,6 +154,7 @@ onMounted(loadYears);
<el-table-column label="毕业生" width="90">
<template #default="{ row }">{{ row.studentCount }}</template>
</el-table-column>
<el-table-column prop="inactiveCount" label="停用学生" width="100" />
<el-table-column label="转出学生" width="100">
<template #default="{ row }">{{ row.transferredCount }}</template>
</el-table-column>
@@ -187,6 +188,10 @@ onMounted(loadYears);
:title="detail.note"
/>
<p v-if="detail">
有转入记录 {{ detail.transferInCount }} 人 · 转出 {{ detail.transferred.length }} 人 · 停用
{{ detail.inactive.length }} 人
</p>
<el-tabs v-model="activeTab">
<el-tab-pane label="班级" name="classes">
<el-table :data="detail?.classes ?? []" size="small" class="mt-12" empty-text="该届没有班级">
@@ -247,6 +252,13 @@ onMounted(loadYears);
<el-table-column prop="transferNote" label="去向 / 备注" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`停用学生(${detail?.inactive.length ?? 0})`" name="inactive">
<el-table :data="detail?.inactive ?? []">
<el-table-column prop="studentNo" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="className" label="原班级" />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
+67 -43
View File
@@ -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<ClassDto[]>([]);
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<UserDto[]>([]);
async function loadStaffOptions(): Promise<void> {
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<void> {
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<void> {
// 服务端落库恒为默认 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<void> {
}
}
const promoteForm = reactive({ grade: '', saving: false });
/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */
const promotionPlan = ref<PromotionPlan>();
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<void> {
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(() => {
<div class="page-header">
<div>
<h2 class="page-title">班级管理</h2>
<p class="page-subtitle">教师只能看到并管理自己创建或被分配的班级</p>
<p class="page-subtitle">按授权范围查看班级,管理操作需要相应权限。</p>
</div>
<div class="toolbar">
<el-input
@@ -489,7 +496,9 @@ onUnmounted(() => {
>
导入任课关系
</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
<el-button v-if="canManageClass" type="primary" :icon="'Plus'" @click="openCreate">
新建班级
</el-button>
</div>
</div>
@@ -537,9 +546,11 @@ onUnmounted(() => {
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)">
班级账号
</el-button>
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -596,12 +607,12 @@ onUnmounted(() => {
</span>
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" />
<el-input :disabled="!!editingId" v-model="form.grade" placeholder="例如 高一" />
</el-form-item>
<el-form-item v-if="!editingId" label="班级码">
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
</el-form-item>
<el-form-item v-if="isAdmin" label="班主任">
<el-form-item v-if="canManageClass" label="班主任">
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
<el-option
v-for="item in staffOptions"
@@ -645,7 +656,7 @@ onUnmounted(() => {
</el-descriptions>
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
<div v-if="isAdmin && detail" class="toolbar mt-12">
<div v-if="canManageClass && detail" class="toolbar mt-12">
<span class="text-muted">更换班主任</span>
<el-select
:model-value="detail.teacher?.id ?? ''"
@@ -668,7 +679,13 @@ onUnmounted(() => {
<el-tabs v-model="activeTab" class="mt-12">
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
<el-button
v-if="auth.can('roster:manage')"
type="primary"
size="small"
:icon="'Plus'"
@click="studentFormVisible = true"
>
添加学生
</el-button>
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
@@ -688,7 +705,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)">
移出
</el-button>
</template>
</el-table-column>
</el-table>
@@ -702,7 +721,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -731,7 +752,7 @@ onUnmounted(() => {
<el-table-column label="任课老师" width="260">
<template #default="{ row }">
<el-select
v-if="isAdmin"
v-if="canManageClass"
:model-value="row.teacherId"
placeholder="选择老师"
style="width: 220px"
@@ -748,14 +769,14 @@ onUnmounted(() => {
<span v-else>{{ row.teacherName || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="isAdmin" label="操作" width="110">
<el-table-column v-if="canManageClass" label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="clearSubjectTeacher(row)">解除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="isAdmin" class="toolbar mt-12">
<div v-if="canManageClass" class="toolbar mt-12">
<el-select
v-model="subjectDraft.subjectName"
placeholder="选择科目"
@@ -796,14 +817,14 @@ onUnmounted(() => {
<el-switch
:model-value="detail?.studentGradeQueryEnabled ?? true"
:loading="gradeQuerySaving"
:disabled="!isAdmin && auth.role !== 'TEACHER'"
:disabled="!auth.can('grade:write')"
@change="(value: boolean) => toggleGradeQuery(value)"
/>
</div>
<el-divider />
<div v-if="isAdmin" class="setting-row">
<div v-if="auth.can('promotion:manage')" class="setting-row">
<div>
<div class="setting-title">学年升级</div>
<div class="text-muted">
@@ -813,12 +834,15 @@ onUnmounted(() => {
</div>
</div>
<div class="toolbar">
<el-input
v-model="promoteForm.grade"
placeholder="升级后的年级,如 高二"
style="width: 200px"
/>
<el-button type="primary" :loading="promoteForm.saving" @click="promote">升级</el-button>
<span>下一学段:{{ nextStage }}</span>
<el-button
type="primary"
:loading="promoteForm.saving"
:disabled="nextStage.includes('毕业') || nextStage.includes('配置')"
@click="promote"
>
升级
</el-button>
</div>
</div>
</el-tab-pane>
+191 -15
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<PermissionMemberDto[]>([]);
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<PermissionKey>(),
removed = new Set<PermissionKey>();
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<PermissionKey>) =>
[...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)}。所选组替代默认模板,如需保留请同时选择同角色默认组。`;
});
</script>
<template>
<div class="page">
<div v-if="auth.can('permission:manage')" class="page">
<div class="page-header">
<div>
<h2 class="page-title">权限组管理</h2>
<p class="page-subtitle">
创建权限组并把账号分配进去,用来给老师额外开通某项能力(也可以反过来收回权限)。
分配的权限组会替代角色默认模板;如需保留默认权限,请同时选择同角色默认组。
</p>
</div>
</div>
@@ -346,6 +448,8 @@ async function submitAssign(): Promise<void> {
<span v-else class="text-muted">(无描述)</span>
</template>
</el-table-column>
<el-table-column prop="scopeLabel" label="范围" min-width="170" />
<el-table-column prop="updatedAt" label="更新时间" min-width="190" />
<el-table-column label="权限数" width="90">
<template #default="{ row }">{{ row.keys.length }}</template>
</el-table-column>
@@ -361,9 +465,11 @@ async function submitAssign(): Promise<void> {
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button link type="primary" @click="openEditGroup(row)">编辑</el-button>
<el-button link @click="copyGroup(row)">复制</el-button>
<el-button link @click="showMembers(row)">成员</el-button>
<!-- 内置组禁止删除:按钮 disabled + tooltip 说明(disabled 元素不发光标事件,外面套一层 span) -->
<el-tooltip :disabled="!row.builtin" content="内置权限组不能删除" placement="top">
<span>
@@ -380,6 +486,13 @@ async function submitAssign(): Promise<void> {
<!-- ------------------------------------------------ Tab 2:成员 -->
<el-tab-pane label="成员" name="members">
<div class="toolbar">
<el-input v-model="memberSearch" placeholder="搜索姓名或工号" clearable style="width: 220px" />
<el-select v-model="memberRole" clearable placeholder="全部角色">
<el-option value="ADMIN" label="管理员" />
<el-option value="TEACHER" label="教师" />
</el-select>
</div>
<div class="toolbar tab-toolbar">
<span class="text-muted">
只列出管理员与教师账号;管理员始终保留权限与系统管理入口,可调整默认组或分配其他权限组。
@@ -387,7 +500,7 @@ async function submitAssign(): Promise<void> {
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="members" empty-text="暂无成员">
<el-table v-loading="loading" :data="filteredMembers" empty-text="暂无成员">
<el-table-column prop="name" label="姓名" min-width="120" show-overflow-tooltip />
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
<el-table-column label="角色" width="130">
@@ -409,14 +522,17 @@ async function submitAssign(): Promise<void> {
<span v-else class="text-muted">角色默认模板</span>
</template>
</el-table-column>
<el-table-column label="数据范围" min-width="180">
<template #default="{ row }">{{ row.scopeLabels.join(';') }}</template>
</el-table-column>
<el-table-column label="生效权限数" width="110">
<template #default="{ row }">{{ row.permissionCount }}</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-tooltip :disabled="!row.fixed" content="管理员始终保留权限与系统管理入口" placement="top">
<el-tooltip :disabled="true" content="管理员始终保留权限与系统管理入口" placement="top">
<span>
<el-button link type="primary" :disabled="row.fixed" @click="openAssign(row)">
<el-button link type="primary" :disabled="false" @click="openAssign(row)">
分配权限组
</el-button>
</span>
@@ -426,7 +542,38 @@ async function submitAssign(): Promise<void> {
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="变更记录" name="audit">
<el-table :data="audit.items">
<el-table-column prop="createdAt" label="时间" min-width="190" />
<el-table-column prop="actorName" label="操作人" min-width="120" />
<el-table-column prop="action" label="操作" min-width="120" />
<el-table-column prop="targetId" label="目标" min-width="180" />
<el-table-column label="变更前后" min-width="300">
<template #default="{ row }">
<details>
<summary>查看差异</summary>
<pre>{{ row.beforeJson }}</pre>
<pre>{{ row.afterJson }}</pre>
</details>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="auditPage"
:page-size="30"
:total="audit.total"
layout="prev, pager, next"
@current-change="loadAudits"
/>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="groupMembersVisible" title="生效成员" :size="isMobile ? '92%' : '560px'">
<el-table :data="selectedGroupMembers">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="username" label="工号" />
<el-table-column prop="permissionCount" label="生效权限数" />
</el-table>
</el-drawer>
<!-- ------------------------------------------------ 新建 / 编辑权限组 -->
<el-dialog
@@ -445,15 +592,36 @@ async function submitAssign(): Promise<void> {
/>
</el-form-item>
<el-form-item label="数据范围">
<el-select v-model="groupDialog.scope">
<el-select v-model="groupDialog.scope" @change="groupDialog.scopeValue = ''">
<el-option label="关联班级" value="SELF_CLASS" />
<el-option label="年级" value="GRADE" />
<el-option label="部(多个年级)" value="DIVISION" />
<el-option label="全校" value="ALL" />
</el-select>
</el-form-item>
<el-form-item label="范围值">
<el-input v-model="groupDialog.scopeValue" placeholder="高一 或 高一,高二,高三;空年级范围不授权" />
<el-form-item v-if="groupDialog.scope === 'SELF_CLASS'" label="关联班级规则">
<el-select v-model="groupDialog.scopeValue">
<el-option label="班主任与任课班级" value="" />
<el-option label="仅班主任班" value="HEAD" />
</el-select>
</el-form-item>
<el-form-item v-else-if="groupDialog.scope === 'GRADE'" label="年级" required>
<el-select v-model="groupDialog.scopeValue">
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
</el-select>
</el-form-item>
<el-form-item v-else-if="groupDialog.scope === 'DIVISION'" label="部范围(选择多个年级)" required>
<el-select v-model="selectedGrades" multiple>
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
</el-select>
<el-button
v-for="division in scopes.divisions"
:key="division.name"
link
@click="selectedGrades = division.grades"
>
{{ division.name }}
</el-button>
</el-form-item>
<el-form-item label="描述">
<el-input
@@ -497,6 +665,7 @@ async function submitAssign(): Promise<void> {
</p>
</el-form-item>
</el-form>
<el-alert :title="groupImpact" type="warning" :closable="false" />
<template #footer>
<el-button @click="groupDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="groupDialog.saving" @click="submitGroup">保存</el-button>
@@ -523,11 +692,18 @@ async function submitAssign(): Promise<void> {
filterable
placeholder="不选任何组 = 使用角色默认模板"
>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id">
<el-option
v-for="group in groups"
:key="group.id"
:label="group.name"
:value="group.id"
:disabled="!!group.defaultRole && group.defaultRole !== assignDialog.member.role"
>
<span>{{ group.name }}</span>
<span class="text-muted">({{ group.keys.length }} 项权限)</span>
</el-option>
</el-select>
<el-alert :title="assignmentImpact" type="warning" :closable="false" />
<p class="assign-hint text-muted">
不选任何组 = 使用角色默认模板({{ roleLabel(assignDialog.member.role) }} 默认
{{ roleDefaultCount }} 项权限);选了组就取各组权限的并集。
File diff suppressed because it is too large. Load diff
+232 -43
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/api/http';
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
@@ -18,13 +19,17 @@ import {
import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const readOnly = computed(() => useRouteName.value === 'roster');
const useRouteName = computed(() => route.name);
const route = useRoute();
const auth = useAuthStore();
const readOnly = computed(() => !auth.can('roster:manage'));
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
const filter = reactive({ classId: '', keyword: '', status: '' as '' | StudentStatus });
const filter = reactive({
classId: '',
keyword: '',
status: 'active' as '' | StudentStatus,
includeArchived: false,
});
/** 表格勾选(批量调班 / 批量转出用) */
const selection = ref<StudentDto[]>([]);
@@ -51,7 +56,7 @@ function exportRoster() {
const classOptions = computed(() => classes.value);
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
classes.value = auth.can('class:read') ? await classApi.list() : [];
}
let studentRequest = 0;
@@ -59,7 +64,8 @@ async function loadStudents(): Promise<void> {
const request = ++studentRequest;
loading.value = true;
try {
const params: { classId?: string; keyword?: string; status?: StudentStatus } = {};
const params: { classId?: string; keyword?: string; status?: StudentStatus; includeArchived?: boolean } =
{ includeArchived: filter.includeArchived };
if (filter.classId) params.classId = filter.classId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
if (filter.status) params.status = filter.status;
@@ -125,7 +131,7 @@ async function submitForm(): Promise<void> {
const payload = {
studentNo: form.studentNo.trim(),
name: form.name.trim(),
classId: form.classId || null,
...(!editingId.value ? { classId: form.classId || null } : {}),
gender: form.gender,
guardianPhone: form.guardianPhone.trim(),
};
@@ -142,12 +148,27 @@ async function submitForm(): Promise<void> {
}
async function removeStudent(row: StudentDto): Promise<void> {
await ElMessageBox.confirm(`删除学生「${row.name}(${row.studentNo})」及其成绩、作业记录?`, '危险操作', {
await ElMessageBox.confirm(
`停用学生「${row.name}(${row.studentNo})」?成绩、作业、已读与历史全部保留;停用后可复学。`,
'危险操作',
{
type: 'warning',
confirmButtonText: '确认删除',
});
confirmButtonText: '确认停用',
},
);
await studentApi.remove(row.id);
ElMessage.success('学生已删除');
ElMessage.success('学生已停用');
await loadStudents();
}
async function hardDeleteStudent(row: StudentDto): Promise<void> {
await ElMessageBox.prompt(
`仅能删除没有成绩、作业、已读或历史的学生记录。请输入学号 ${row.studentNo} 确认永久删除。`,
'删除空记录',
{ inputValidator: (value) => value === row.studentNo || '学号不一致', type: 'warning' },
);
await api.delete(`/students/${row.id}?hard=true&confirm=true`);
ElMessage.success('空学生记录已删除');
await loadStudents();
}
@@ -158,7 +179,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
const transferVisible = ref(false);
const transferSaving = ref(false);
const transferTargets = ref<StudentDto[]>([]);
const transferForm = reactive({ toClassId: '', note: '' });
const transferForm = reactive({ toClassId: '', note: '', allowGradeChange: false });
function openTransfer(rows: StudentDto[]): void {
if (rows.length === 0) {
@@ -178,6 +199,7 @@ async function submitTransfer(): Promise<void> {
transferTargets.value.map((item) => item.id),
transferForm.toClassId || null,
transferForm.note.trim() || undefined,
transferForm.allowGradeChange,
);
ElMessage.success(`已调动 ${result.moved} 名学生(学号不变,历史可查)`);
transferVisible.value = false;
@@ -192,19 +214,64 @@ async function transferOut(rows: StudentDto[]): Promise<void> {
ElMessage.warning('请先勾选要办理转出的学生');
return;
}
const names = rows.map((item) => item.name).join('、');
const { value } = await ElMessageBox.prompt(
`将 ${rows.length} 名学生(${names})标记为「已转出」。\n` +
'转出表示学籍离开本校:班级归属与历史成绩、作业都会保留,只是不再计入在读名单。',
'办理学生转出',
{ inputPlaceholder: '转出去向 / 原因(可选)', inputValue: '', confirmButtonText: '确认转出' },
outTargets.value = rows;
outForm.note = '';
outForm.toSchool = '';
outVisible.value = true;
}
const outTargets = ref<StudentDto[]>([]),
outVisible = ref(false),
outSaving = ref(false);
const outForm = reactive({ toSchool: '', note: '' });
async function submitOut() {
outSaving.value = true;
try {
await studentApi.transferOut(
outTargets.value.map((row) => row.id),
outForm.note,
outForm.toSchool,
);
const result = await studentApi.transferOut(
rows.map((item) => item.id),
(value ?? '').trim() || undefined,
);
ElMessage.success(`已办理 ${result.transferred} 名学生转出`);
outVisible.value = false;
await loadStudents();
} finally {
outSaving.value = false;
}
}
const inVisible = ref(false),
inSaving = ref(false);
const inForm = reactive({ studentId: '', studentNo: '', name: '', toClassId: '', fromSchool: '', note: '' });
function openIn(row?: StudentDto) {
Object.assign(inForm, {
studentId: row?.id ?? '',
studentNo: row?.studentNo ?? '',
name: row?.name ?? '',
toClassId: filter.classId,
fromSchool: '',
note: '',
});
inVisible.value = true;
}
async function submitIn() {
if (!inForm.toClassId || !inForm.studentNo || !inForm.name) {
ElMessage.warning('请填写学号、姓名和目标班级');
return;
}
inSaving.value = true;
try {
await api.post('/students/transfer-in', {
...(inForm.studentId
? { studentId: inForm.studentId }
: { studentNo: inForm.studentNo, name: inForm.name }),
toClassId: inForm.toClassId,
fromSchool: inForm.fromSchool,
note: inForm.note,
});
inVisible.value = false;
await loadStudents();
ElMessage.success('转入或复学已完成');
} finally {
inSaving.value = false;
}
}
/* ------------------------------------------------------------ 调班历史 */
@@ -227,6 +294,11 @@ async function openHistory(row: StudentDto): Promise<void> {
}
const TRANSFER_MODE_LABELS: Record<string, string> = {
'class-change': '调班 / 建档',
restore: '撤回 / 复学',
unassign: '移出班级',
deactivate: '停用',
'transfer-in': '转入',
single: '单个调班',
batch: '批量调班',
'transfer-out': '转出',
@@ -303,15 +375,14 @@ async function submitCall(): Promise<void> {
style="width: 170px"
@change="loadStudents"
>
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
<el-select
v-model="filter.status"
placeholder="全部状态"
clearable
style="width: 130px"
@change="loadStudents"
>
<el-select v-model="filter.status" placeholder="在读" style="width: 130px" @change="loadStudents">
<el-option
v-for="status in STUDENT_STATUSES"
:key="status"
@@ -329,8 +400,17 @@ async function submitCall(): Promise<void> {
/>
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
<el-button @click="exportRoster">导出名单</el-button>
<el-switch v-model="filter.includeArchived" active-text="包含归档" @change="loadStudents" />
<el-button v-if="!readOnly" @click="openIn()">转入学生</el-button>
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button v-if="!readOnly" type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
<el-button
v-if="!readOnly && auth.can('import:students')"
type="warning"
:icon="'Document'"
@click="openImport"
>
导入名单
</el-button>
</div>
</div>
@@ -395,13 +475,59 @@ async function submitCall(): Promise<void> {
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column v-if="!readOnly" label="操作" width="330" fixed="right">
<el-table-column label="操作" width="370" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openCall(row)">叫人</el-button>
<el-button link type="primary" @click="openTransfer([row])">调班</el-button>
<el-button link @click="openHistory(row)">调班历史</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
<el-button
v-if="auth.can('call:create') && row.status === 'active'"
link
type="success"
@click="openCall(row)"
>
叫人
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
link
type="primary"
@click="openTransfer([row])"
>
调班
</el-button>
<el-button link @click="openHistory(row)">生命周期历史</el-button>
<el-button
v-if="!readOnly && ['active', 'inactive'].includes(row.status) && !row.archivedYearId"
link
type="primary"
@click="openEdit(row)"
>
编辑
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
link
type="danger"
@click="removeStudent(row)"
>
停用
</el-button>
<el-button
v-if="
auth.role === 'ADMIN' && ['active', 'inactive'].includes(row.status) && !row.archivedYearId
"
link
type="danger"
@click="hardDeleteStudent(row)"
>
删除空记录
</el-button>
<el-button
v-if="!readOnly && ['transferred', 'inactive'].includes(row.status) && !row.archivedYearId"
link
type="primary"
@click="openIn(row)"
>
转入 / 复学
</el-button>
</template>
</el-table-column>
</el-table>
@@ -417,6 +543,11 @@ async function submitCall(): Promise<void> {
<el-form-item label="学生">
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
</el-form-item>
<el-form-item label="原班 / 年级">
<span>
{{ transferTargets.map((s) => `${s.className || '未分班'} / ${s.grade || ''}`).join('、') }}
</span>
</el-form-item>
<el-form-item label="调入班级">
<el-select
v-model="transferForm.toClassId"
@@ -424,9 +555,17 @@ async function submitCall(): Promise<void> {
clearable
style="width: 100%"
>
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-if="auth.role === 'ADMIN'" label="跨年级">
<el-checkbox v-model="transferForm.allowGradeChange">允许跨年级(必须填写备注)</el-checkbox>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="transferForm.note"
@@ -441,6 +580,45 @@ async function submitCall(): Promise<void> {
</template>
</el-dialog>
<el-dialog v-model="outVisible" title="办理转出" width="min(92%, 520px)">
<p>将 {{ outTargets.length }} 名学生转出,原班级和全部业务历史保留。</p>
<el-form label-position="top">
<el-form-item label="去向学校"><el-input v-model="outForm.toSchool" maxlength="200" /></el-form-item>
<el-form-item label="原因 / 备注">
<el-input v-model="outForm.note" type="textarea" maxlength="200" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="outVisible = false">取消</el-button>
<el-button type="danger" :loading="outSaving" @click="submitOut">确认转出</el-button>
</template>
</el-dialog>
<el-dialog v-model="inVisible" title="转入 / 复学" width="min(92%, 520px)">
<el-form label-position="top">
<el-form-item label="学号" required>
<el-input v-model="inForm.studentNo" :disabled="!!inForm.studentId" />
</el-form-item>
<el-form-item label="姓名" required>
<el-input v-model="inForm.name" :disabled="!!inForm.studentId" />
</el-form-item>
<el-form-item label="目标在读班级" required>
<el-select v-model="inForm.toClassId">
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="来源学校"><el-input v-model="inForm.fromSchool" maxlength="200" /></el-form-item>
<el-form-item label="备注"><el-input v-model="inForm.note" maxlength="200" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="inVisible = false">取消</el-button>
<el-button type="primary" :loading="inSaving" @click="submitIn">确认办理</el-button>
</template>
</el-dialog>
<!-- 调班历史 -->
<el-drawer v-model="historyVisible" :title="`调班历史 — ${historyTarget?.name ?? ''}`" size="640px">
<el-table v-loading="historyLoading" :data="historyRows" empty-text="该生还没有调班记录">
@@ -448,7 +626,7 @@ async function submitCall(): Promise<void> {
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="方式" width="100">
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.mode] ?? row.mode }}</template>
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.kind] ?? row.kind }}</template>
</el-table-column>
<el-table-column label="原班级" width="130">
<template #default="{ row }">{{ row.fromClassName || '未分班' }}</template>
@@ -456,6 +634,12 @@ async function submitCall(): Promise<void> {
<el-table-column label="新班级" width="130">
<template #default="{ row }">{{ row.toClassName || '未分班' }}</template>
</el-table-column>
<el-table-column prop="batchId" label="批次" min-width="200" />
<el-table-column prop="academicYear" label="学年" width="90" />
<el-table-column prop="fromGrade" label="原年级" width="100" />
<el-table-column prop="toGrade" label="新年级" width="100" />
<el-table-column prop="fromSchool" label="来源学校" min-width="120" />
<el-table-column prop="toSchool" label="去向学校" min-width="120" />
<el-table-column prop="operatorName" label="操作人" width="100" />
<el-table-column prop="note" label="备注" show-overflow-tooltip />
</el-table>
@@ -542,9 +726,14 @@ async function submitCall(): Promise<void> {
<el-form-item label="家长手机号">
<el-input v-model="form.guardianPhone" placeholder="可选" />
</el-form-item>
<el-form-item label="班级">
<el-form-item v-if="!editingId" label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in classOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
+28 -6
View File
@@ -12,6 +12,7 @@
*
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
import { useAuthStore } from '@/stores/auth';
import PromotionSettings from '@/components/PromotionSettings.vue';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
@@ -26,6 +27,7 @@ import {
} from '@classhelper/shared';
import { classApi, termApi } from '@/api';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const classes = ref<ClassDto[]>([]);
@@ -176,7 +178,7 @@ onMounted(async () => {
<template>
<div class="page">
<PromotionSettings />
<PromotionSettings v-if="auth.can('promotion:manage')" />
<el-form-item v-if="!scope" label="全校学期总周数">
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
</el-form-item>
@@ -201,10 +203,24 @@ onMounted(async () => {
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
<el-button type="warning" :icon="'Calendar'" :loading="holidayLoading" @click="fetchHolidays">
<el-button
v-if="auth.can('term:write')"
type="warning"
:icon="'Calendar'"
:loading="holidayLoading"
@click="fetchHolidays"
>
拉取放假安排
</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">保存</el-button>
<el-button
v-if="auth.can('term:write')"
type="primary"
:loading="saving"
:disabled="!dirty"
@click="save"
>
保存
</el-button>
</div>
</div>
@@ -229,7 +245,9 @@ onMounted(async () => {
placeholder="第 1 教学周的周一"
style="width: 200px"
/>
<el-button type="primary" plain :loading="saving" @click="autoGenerate">按开学日期生成</el-button>
<el-button v-if="auth.can('term:write')" type="primary" plain :loading="saving" @click="autoGenerate">
按开学日期生成
</el-button>
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
</div>
</el-card>
@@ -254,7 +272,9 @@ onMounted(async () => {
<template #header>
<div class="card-header">
<span>逐周日期区间({{ weeks.length }} 周)</span>
<el-button size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
<el-button v-if="auth.can('term:write')" size="small" :icon="'Plus'" @click="addWeek">
添加一周
</el-button>
</div>
</template>
@@ -304,7 +324,9 @@ onMounted(async () => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button link type="danger" @click="removeWeek($index)">删除</el-button>
<el-button v-if="auth.can('term:write')" link type="danger" @click="removeWeek($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -0,0 +1,467 @@
# ClassHelper 权限组治理与学生年度生命周期重构提示词
> 用法:在仓库根目录(含 `AGENTS.md`)把本文件整体交给编码代理。
> 这不是“加几个按钮”的任务,而是把**权限键、数据范围、写入口、年度升级**收敛成单一路径。
> 如果当前会话支持计划确认,先输出差距清单与分阶段计划;确认后逐阶段实现。若为自动执行模式,按阶段直接实现,但每阶段结束必须停下来汇报实际验证输出。
## 0. 你的角色与工作方式
你是本仓库的高级全栈工程师,熟悉 TypeScript、Express、Prisma、Vue 3、Element Plus 与 pnpm monorepo。
开始前必须按顺序阅读:
1. `AGENTS.md`(最高优先级工程约束)
2. `docs/dev/permissions.md`、`docs/app/classes.md`、`docs/dev/api.md`、`docs/dev/implementation.md`、`docs/dev/data-model.md`、`docs/dev/testing.md`
3. 本提示词第 2 节列出的源码文件与符号
工作规则:
- 先只读调研,不要先改代码;先给出“已核实差距 + 实施方案 + 风险 + 测试计划”。
- 分阶段提交;每阶段结束运行对应验证命令,汇报**本次实际输出**,不要引用历史报告数字。
- 不要留下 `TODO`、空实现、只改前端不改服务端、只改服务端不改 UI 的半截功能。
- 发现本文与代码冲突时,以代码和 `AGENTS.md` 为准,并在汇报中列出冲突点与你的处理方式。
- 代码注释、文档、界面文案、提交描述一律中文;提交使用中文 Conventional Commits。
- 只格式化本次改动文件,不要全仓库跑 Prettier;换行用 LF。
## 1. 硬约束(不可违反)
- 单一 `packages/server/prisma/schema.prisma`;保持 SQLite/MySQL 通用,不加 Prisma `enum`、不加 `@db.*`。
- 模型变动必须同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑;不能修改已应用的历史迁移。
- 权限每次请求实时计算,改动立即生效;前端隐藏不是鉴权,服务端必须独立校验。
- 班级端会话(`CLASS_DEVICE`)权限集合为空,但既有能力必须保留:本班名单/课表/通知/叫人可读,作业可代录,成绩不可写,个人成绩查询受班级开关控制。
- 不破坏离线客户端、preload 白名单、CSP、设备心跳与现有 API 兼容;改路由守卫后必须检查桌面客户端调用链。
- 不修改版本号、不发布、不动 `releases/`、不对真实业务库执行 `db:seed`/`db:reset`、不向真实班级发测试消息。
- 验收使用隔离临时库;测试失败先定位,不要为了通过而放宽断言。
## 2. 已核实现状(按这些事实设计,不要重新猜测)
### 2.1 权限组
- **P1 权限键有目录、无入口。** `class:read`、`schedule:read`、`homework:read`、`notification:read` 在 `shared/src/permissions.ts` 中定义,但服务端没有任何路由使用它们;`GET /classes`、`GET /schedules`、`GET /homeworks`、`GET /notifications` 只做登录与班级范围断言。结果是:从权限组取消这些键,用户仍然能读。
- **P2 写入口也有缺口。** `POST /homeworks` 没有 `homework:write` 守卫;`GET /classes/:id`、`GET /classes/:id/students` 没有功能键守卫;`POST /notifications` 使用 `requirePermission` 而不是 `requireStaffPermission`,班级端会被空权限集合挡住,与 `canPublishNotification` 允许 `CLASS_DEVICE` 的规则不一致。
- **P3 键与业务断言错位。** `POST /students/transfer`、`POST /students/transfer-out` 路由要求 `class:manage`,服务层又硬编码 `isAdmin`;持有 `roster:manage` 的班主任不能调班,持有 `class:manage` 的教师仍被 `assertCanManageClasses` 拒绝。`POST /classes/promote` 同样只认 `class:manage` + ADMIN。归档模块更明显:路由要求 `archive:manage`,但 `archives.service` 与 `transferred.service` 的每个入口都调用 `assertCanManageClasses`(只认 ADMIN),非管理员即使被授予 `archive:manage` 也无法使用。
- **P4 升级权限过宽。** `/term/promotion` 的读取、配置、执行全部要求 `system:manage`。要授权教务主任执行升级,只能把系统设置权限一并给出。
- **P5 页面仍按角色门控。** `web-admin/src/router/index.ts` 中 `students`、`classes`、`archives`、`term-weeks` 的 `meta.roles` 固定为 `['ADMIN']`;权限组即使授予 `roster:manage`/`class:read`/`archive:manage`/`term:read`,页面也进不去。
- **P6 分配可越权。** `assignUserGroups` 只校验组存在,不校验 `defaultRole` 与目标账号角色;把 ADMIN 默认组分配给 TEACHER 会让其获得全部键。拥有 `permission:manage` 的教师也可以新建含 `system:manage`/`database:manage` 的组并分配给自己。
- **P7 范围是自由文本。** `scopeValue` 直接按逗号拆分;`GRADE` 与 `DIVISION` 实现完全相同;空值、未知年级、全角逗号都没有统一校验;`SELF_CLASS` 的 `HEAD` 语义只存在于代码分支,UI 没有解释。
- **P8 导入键不生效。** `import:students` 挂在路由上,但 `imports.module.ts` 的 `assertImportKindAllowed` 仍用 `role !== 'ADMIN'` 拒绝教师。
- **P9 成员列表 N+1 且字段失效。** `listMembers` 对每个账号调用一次 `loadEffectivePermissions`;`PermissionMemberDto.fixed` 永远为 false,前端“管理员固定”提示不会出现。
- **P10 无审计、无差异预览。** 权限组变更只写 `logger.info`,没有操作人、前后差异或受影响成员;编辑组/分配组时看不到生效权限变化。
- **P11 默认组语义与文案相反。** 页面说“给老师额外开通某项能力”,但一旦分配任何组,就不再使用角色默认组(并集只含所分配组),可能反而收掉教师默认权限。必须显式提示并预览,不能靠用户猜。
### 2.2 学生年度流程
- **S1 PATCH 绕过生命周期。** `PATCH /students/:id` 接受 `classId` 和 `status`;可直接把学生改成 `transferred`/`graduated`,不写 `transferredAt`、不写历史、不挂归档;换班也不会广播班级更新。
- **S2 调班不是原子操作。** `transferStudents` 先事务改 `Student.classId`,事务外再写 `StudentClassTransfer`;历史写入失败会留下“人已调走、无记录”。`recordTransfer` 按 `createdAt desc` 取最近 N 条,没有批次标识,存在取错/漏取风险。
- **S3 转出接受毕业生。** `transferOutStudents` 只排除已是 `transferred` 的记录;`graduated`/已归档学生也会被改成 `transferred`,覆盖毕业事实。没有结构化的转入学校/去向字段。
- **S4 转出历史查不到。** `listTransfers` 的 where 带 `student: activeStudentWhere()`,而 `activeStudentWhere` 排除 `transferred`;因此转出记录在班级/学生历史里不可见,`GET /students/:id/transfers` 对转出学生返回空。
- **S5 状态筛选是坏的。** `listStudents` 始终 AND `activeStudentWhere()`;`status=transferred` 永远返回空,但 UI 提供了“已转出/已毕业”筛选项。
- **S6 可复活毕业生。** `isActiveStudent` 只排除 `transferred/inactive`,不排除 `graduated`;`addStudent` 对已毕业学生会走“已存在”分支,把 `status` 改回 `active`,但保留 `archivedYearId`,造成隐藏的不一致记录。该分支也不是事务。
- **S7 删除是硬删。** `deleteStudent` 级联删除成绩、作业状态、已读和调班历史;对毕业/转出学生没有保护。
- **S8 升级归档漏人。** `runPromotion` 只对 `status: active` 的学生写 `archivedYearId`;转出/停用学生不挂届别,归档详情的“转出学生”为空,`ArchivedYear.transferredCount` 也不会写。
- **S9 升级清空周次。** `runPromotion` 直接 `termWeek.deleteMany()`,并把 `term.startDate` 固定写成 `${year}-09-01`;忽略配置日期,清掉班级自定义周次。
- **S10 年级硬编码。** 升级只认 `高一/高二/高三`;`PATCH /classes/:id` 可把年级改成任意字符串,`POST /classes/promote` 也接受任意目标年级,能绕过“只升一级”。
- **S11 新班码冲突后置。** 新高一按 `enrollmentYear+classIndex` 判重,但 `Class.code` 全局唯一;冲突要到事务里才失败,没有预检。
- **S12 升级不可预览。** `dryRun` 只返回班级数/学生数;没有逐班、逐状态明细、阻断项、计划哈希。`PromotionRun` 没有操作人、明细、快照摘要。
- **S13 升级年份任意。** `POST /term/promotion/run` 接受 2000–2100 的任意 `year`,可手动升级未来学年并生成假档案;没有“只能补跑应执行学年”的限制。
- **S14 死代码。** `archives.service.createArchive` 仍存在,但没有路由;文档明确“不提供创建归档代替年度升级的入口”。保留会让人误以为有第二条毕业路径。
- **S15 撤回转出限制过死。** `restoreTransferred` 只允许原班仍在读;原班已归档时无法把学生恢复到其他班,也没有审计记录。
## 3. 目标与非目标
### 3.1 目标
权限组:
1. 每个权限键至少有一个真实服务端入口;每个受控入口至少有一个权限键。
2. 数据范围可配置、可校验、可展示;不再用自由文本猜年级。
3. 防止权限管理员自我提权、跨角色分配默认组、越范围授权。
4. 权限变更可审计、可预览影响;成员列表不再 N+1。
5. Web 菜单/页面/按钮按生效权限门控,而不是继续写死 ADMIN。
学生年度流程:
1. 学生状态、班级变更、转出、转入、毕业、升级只有一条受控路径。
2. 所有写操作在事务内完成,并写可追溯的历史(操作人、批次、学年、原/新班级与年级)。
3. 在读/归档/转出查询语义一致;转出历史必须查得到。
4. 年度升级支持可配置学段、逐班预览、阻断项、幂等执行、快照与执行记录。
5. 升级不丢学生、不清空未确认的数据;毕业归档包含转出/停用学生。
### 3.2 非目标
- 不引入学生个人账号、SSO、多租户、多学校隔离。
- 不重做成绩/作业/课表业务,只改权限入口与学年流程的边界。
- 不做“一键回滚整个学年升级”;回滚依赖执行前快照与手工恢复,UI 必须写清楚。
- 不升版本、不发版、不改 `releases/`。
- 不为了兼容旧前端而保留两套权限事实源;DTO 兼容字段可以由新字段派生。
## 4. 必须实现的设计
### 4.1 权限键矩阵(单一事实源)
以 `shared/src/permissions.ts` 为准扩展现有目录;不要新建第二份键表。
| 能力/入口 | 权限键 | 守卫方式 | 范围与备注 |
| ---------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
| 班级列表/详情读取 | `class:read` | `requireStaffPermission` | 班级端仍看自己绑定的班;教师按 scope |
| 班级创建/修改/删除、班级账号、班主任/任课分配 | `class:manage` | `requirePermission` + 服务层 `assertPermission` + 目标班范围 | 创建/删除建议要求 scope=ALL;修改要求目标班在 scope 内 |
| 学生名单读取、班级名单读取 | `roster:read` | `requireStaffPermission` | 班级端本班;教师按 scope |
| 学生新建/资料修改/导入/调班/转出/转入/撤回转出 | `roster:manage` | `requirePermission` + 服务层断言 | 跨班操作要求 scope=ALL 或同时覆盖源班与目标班;ADMIN 不受范围限制 |
| 教师读取/维护 | `teacher:read` / `teacher:manage` | 现有守卫 | 导入教师仍要求 `teacher:manage` + `import:teachers` |
| 课表读取/编辑 | `schedule:read` / `schedule:write` | `requireStaffPermission` / `requirePermission` | 班级端读本班;写仍要求班主任/管理员 |
| 全校时间表 | `timetable:manage` | 现有守卫 | 不变 |
| 作业读取/写入 | `homework:read` / `homework:write` | `requireStaffPermission` | 班级端可代录作业;写仍按科目收口 |
| 通知读取/发布/删除 | `notification:read` / `notification:publish` / `notification:delete` | `requireStaffPermission` / `requirePermission` | 班级端可发布;删除仍只给有键者 |
| 成绩读取/写入/导入/个人明细 | `grade:read` / `grade:write` / `grade:import` / `student:grade-query` | 现有守卫 | 班级端只读聚合;明细受班级开关 |
| 学期周次读取/修改 | `term:read` / `term:write` | 现有守卫 | 修改仅授权者 |
| 学年升级读取/配置/预览/执行/手动升级 | **新增 `promotion:manage`** | `requirePermission('promotion:manage')` | 默认仅 ADMIN;替代 `system:manage`;范围按 `archive:manage` 同等敏感处理 |
| 毕业归档读取/撤销 | `archive:manage` | `requirePermission` + 服务层 `assertPermission` | 不做手动创建归档;撤回转出改由 `roster:manage` 控制 |
| 学生/教师/课表/任课导入 | `import:*` | 路由 + 服务层 | 必须真正按键判断,不能再用 `role !== 'ADMIN'` 代替 |
| 客户端/远控/更新 | `client:*` / `remote:control` | 现有守卫 | 不变 |
| 系统/数据库/权限 | `system:manage` / `database:manage` / `permission:manage` | 现有守卫 | 不变 |
强制规则:
1. **新增键必须同步**:`PERMISSION_KEYS`、`PERMISSION_LABELS`、`PERMISSION_CATEGORIES`、`PERMISSION_DEFAULT_ROLES`、`ROLE_DEFAULT_PERMISSIONS`(派生即可)、必要的 `PERMISSION_MATRIX`。
2. **旧库迁移**:`ensureDefaultGroups()` 的 `update: {}` 不会给旧库默认组补新键;`promotion:manage` 必须通过迁移插入已有的 ADMIN 默认组(用 `INSERT ... SELECT ... WHERE defaultRole='ADMIN'`,旧库尚未创建默认组时允许 0 行)。新库由 `ROLE_DEFAULT_PERMISSIONS.ADMIN` 自然包含;随后 `ensureDefaultGroups()` 新建的 ADMIN 默认组也会包含该键。
3. **删除装饰键**:如果某个键最终找不到任何真实入口,就删除它并更新迁移/文档,不要保留“看起来能用”的键。
4. **班级端兼容**:凡是班级端会调的读/写路由,用 `requireStaffPermission` 或显式 `isClassDevice` 分支;不要因为加了 `requirePermission` 把班级端挡死。改完必须跑桌面客户端相关验收。
5. **页面门控**:`students` 用 `permission: 'roster:read'`,`classes` 用 `class:read`,`archives` 用 `archive:manage`,`term-weeks` 用 `term:read`;写按钮再用 `auth.can('...:manage')` 隐藏。路由守卫与服务端都要检查,前端隐藏不能代替鉴权。
### 4.2 服务端断言分层
统一为三层,顺序不能反:
1. **路由层**:`requirePermission`(员工必须持有键,班级端必被挡)、`requireStaffPermission`(班级端放行,员工查键)、`requireAnyPermission`(读或写任一即可)。
2. **服务层权限断言**:`assertPermission(user, key)` / `assertAnyPermission(user, keys)`;不要在 service 里继续写 `user.role === 'ADMIN'` 来代替已有键。
3. **业务关系断言**:`assertClassAccess`、`assertCanManageRoster`、`assertCanManageSubjectContent`、班主任/任课关系、班级端开关;这些不能被权限组替代。
建议新增/整理:
- `lib/permissions.ts`:`assertPermission`、`assertAnyPermission`、`hasEffectivePermission`。
- `lib/access.ts`:`assertClassInScope(user, classId, key)`、`assertClassesInScope`;把 `assertCanManageClasses`、`assertCanManageRoster` 改为“键 + 关系 + 范围”,而不是只认 ADMIN。
- 避免重复查询:路由中间件已经算过 `req.auth.permissions`;能传上下文就传,不能传时至少不要在同一请求里重复查两次权限组。
- **班级端例外**:`assertPermission` 只适用于员工;班级端可用的路径用 `assertStaffPermission` 或显式 `isClassDevice` 分支,不能把班级端的空权限集合当成“没有权限”而误伤作业代录、通知/叫人等既有能力。
### 4.3 数据范围模型
在 shared 中保留 `PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL'`,增加统一解析与展示:
- `SELF_CLASS`:`scopeValue` 只允许 `''`(班主任或任课班)或 `'HEAD'`(仅班主任班);其他值拒绝。
- `GRADE`:只允许一个年级;`DIVISION`:允许逗号分隔的年级集合;统一全角逗号/顿号/空白,去重排序。
- `ALL`:忽略 `scopeValue`,服务端强制存空串。
- `GRADE`/`DIVISION` 的空值、未知年级一律 400;未知年级的判定基准是数据库中 `Class.grade` 的 distinct 值加上升级计划里的年级。
- 新增 `GET /permissions/scopes`:返回可选年级、部范围建议、当前计划年级;权限页用选择器,不再让用户手填。
- `teacherScopeWhere` 必须走同一个解析器;不要在各处 `split(',')`。
- DTO 增加 `scopeLabel`(中文展示),避免前端各自拼。
### 4.4 防提权与审计
必须实现:
1. **默认组只能给同角色**:`PermissionGroup.defaultRole` 非空的组,只能分配给相同 `User.role` 的账号;ADMIN 默认组不能分配给 TEACHER。显式分配教师默认组给教师是允许的(用于“保留默认 + 追加权限”)。
2. **不能授予自己没有的键**:非 ADMIN 的 `permission:manage` 账号,创建/修改组时提交的键集合必须是其生效权限的子集;修改已有组时,组内现有键也必须是其子集;否则 403。
3. **不能分配超出自己的组**:非 ADMIN 只能把“组内所有键都在自己权限内”的组分配给别人;不能给自己分配会新增权限的组。
4. **范围提权**:非 ADMIN 不得创建/修改/分配 `scope=ALL`;`GRADE`/`DIVISION` 组只允许 ADMIN,或范围覆盖目标年级的账号(最简实现:仅 ADMIN 可管理非 `SELF_CLASS` 组)。
5. **默认组编辑**:ADMIN 默认组、TEACHER 默认组影响所有未分组账号,只有 ADMIN 可改;非 ADMIN 的权限管理员只能改自定义组。
6. **内置组**:仍不可删除;改名/改键按上述规则。
7. **审计**:新增 `PermissionAudit` 表(`id`、`actorId`、`actorName`、`action`、`targetType`、`targetId`、`beforeJson`、`afterJson`、`createdAt`),记录组的增删改、成员分配、默认组修改。同步 migration 与 `SNAPSHOT_TABLES`。提供分页读取接口(`permission:manage`)和权限页“变更记录”Tab。
8. **成员列表**:`listMembers` 一次预取所有组 + 默认组,内存计算每个账号的生效键;`PermissionMemberDto` 增加 `permissions: PermissionKey[]` 与 `scopeLabels`,`fixed` 要么删除,要么只对真正不可分配的账号为 true;不要保留永远 false 的字段。
9. **组详情**:新增 `GET /permissions/groups/:id/members` 或在组列表 DTO 带成员摘要;权限页可展开查看成员,不再只有数量。
10. **保存前预览**:前端在编辑组/分配组时展示“将影响 N 名成员、新增/移除哪些键”;默认组分配时提示“已选组会替代角色默认模板,如需保留请同时选择教师默认组”。
### 4.5 学生状态机
只允许以下状态与迁移;`archivedYearId` 是与状态正交的归档标记,不是第五种状态。
| 当前状态 | 允许动作 | 结果 |
| ------------- | ------------------------------------- | ------------------------------------------------------------------------------ |
| `active` | 调班、转出、停用、随年度升级毕业/归档 | 调班改 `classId`;转出→`transferred`;停用→`inactive`;毕业→`graduated` + 归档 |
| `inactive` | 复学、转出、随年度升级归档 | 复学→`active`;转出→`transferred`;归档保持 `inactive` |
| `transferred` | 撤回转出(原班或指定在读班) | 回到 `active`;保留原转出历史 |
| `graduated` | 仅撤销归档(ADMIN) | 回到 `active`;不通过名单编辑直接改 |
禁止:
- `PATCH /students/:id` 直接改 `status` 为 `transferred`/`graduated`。
- 把 `graduated`/已归档学生加入班级或“复活”。
- 对 `transferred`/`graduated` 再次办理转出。
- 删除仍有成绩、作业、已读或历史的学生。
### 4.6 写入口与 API
| 接口 | 权限 | 行为 |
| -------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /students` | `roster:manage` | 只允许创建 `active`/`inactive`;学号重复 409,不复活历史学生 |
| `PATCH /students/:id` | `roster:manage` | 只改姓名、性别、家长手机号、学号(学号变更需唯一);**移除 `classId`/`status`**,收到就 400 并提示使用专用接口 |
| `POST /students/transfer` | `roster:manage` | 批量调班;事务内改班 + 写历史;目标班必须未归档;默认只允许同年级,跨年级需 ADMIN + `allowGradeChange` + 备注 |
| `POST /students/transfer-out` | `roster:manage` | 只允许 `active`/`inactive`;写 `transferredAt`、`transferNote`、可选取向学校;保留 `classId` 作历史;写 `kind=transfer-out` |
| `POST /students/transfer-in` | `roster:manage` | 新建或把 `transferred` 学生重新入校;必须指定在读班;记录来源学校;写 `kind=transfer-in` |
| `POST /students/:id/restore`(或保留 `/archives/transferred/:id/restore`) | `roster:manage` | 撤回转出;可指定目标在读班;原班已归档时允许转到其他班;写 `kind=restore`;保留旧路径时同步改键,归档页按钮按 `auth.can('roster:manage')` 显示 |
| `DELETE /students/:id` | `roster:manage` | 默认软停用;仅当无成绩/作业/已读/历史且状态允许时才硬删;硬删需 ADMIN 二次确认 |
| `GET /students/transfers` | `roster:read` | 不再按学生状态过滤;按批次/学生/班级/学年分页;转出记录必须可见 |
| `GET /students/:id/transfers` | `roster:read` + 班级范围 | 只读权限也能查历史;不再要求 `roster:manage` |
| `POST /classes/:id/students` | `roster:manage` | 已存在的在读学生走调班生命周期;`transferred` 需显式转入;`graduated`/归档一律 409 |
| `DELETE /classes/:id/students/:studentId` | `roster:manage` | 走“移出班级”生命周期,写历史 + 广播,不直接清空 |
| `GET /classes/:id/students` | `roster:read`(班级端放行) | 只返回在读;归档/转出走归档页 |
实现要求:
- 所有“学生 + 历史”写入在**同一个事务**内;失败一起回滚。
- 每次批量操作生成 `batchId`(UUID),同批次记录可一起查询/撤销说明。
- 写操作提交后再广播 `classUpdated`,旧班与新班都通知。
- 调班/转出/转入/复学/移出统一收进 `lib/student-lifecycle.ts`,`students.service` 与 `classes.service` 不再各写一套。
- 错误文案必须可操作,例如“该生已毕业,请先在归档页撤销归档”“目标班级已归档,不能调入”。
### 4.7 历史模型
把 `StudentClassTransfer` 从“调班历史”升级为“学生生命周期历史”:
- 新增 `kind String @default("class-change")`:`class-change` / `transfer-in` / `transfer-out` / `restore` / `unassign`。
- 新增 `batchId String @default("")`,加索引。
- 新增 `academicYear Int?`:写入时按升级计划/当前学年计算,历史配置变化不影响旧记录。
- 新增 `fromGrade String @default("")`、`toGrade String @default("")`、`fromSchool String @default("")`、`toSchool String @default("")`。
- 旧 `mode` 字段迁移为 `kind`(`single`/`batch` → `class-change`,`transfer-out` → `transfer-out`,`restore-transfer` → `restore`),不要长期双写两个事实源;DTO 保留 `mode` 兼容字段时由 `kind` 派生,并标注 deprecated。
- `ArchivedYear` 增加 `inactiveCount Int @default(0)`;`PromotionRun` 增加 `operatorId`、`operatorName`、`detailsJson`、`snapshotSha256`、`error`(`message` 保留)。
- 新表/新字段同步 `SNAPSHOT_TABLES`;迁移为旧行补默认值,不重写历史语义。
### 4.8 年度升级计划
不要把 `高一/高二/高三` 写死在服务里。使用“轨道 + 有序学段”:
```json
{
"tracks": [
{
"id": "high",
"name": "高中",
"entryClassCount": 6,
"stages": [{ "grade": "高一" }, { "grade": "高二" }, { "grade": "高三", "terminal": true }]
}
],
"termStartDate": null,
"resetTermWeeks": true
}
```
- 默认只配置高中一条轨道,保持现有行为;结构支持初中/小学/完全中学多条轨道。
- 每个年级只能出现在一条轨道;同一轨道内顺序即升级顺序;最后一个学段必须 `terminal: true`。
- `entryClassCount` 是每条轨道起始年级每年新建班数;0 表示不新建。
- 计划存 `AppSetting` 的 `term.promotionPlan`(JSON 文本),执行时间仍用 `term.promotionAt`;`promotionStatus` 兼容旧 `{month,day,hour,classCount}` 并归一化为新计划。
- 旧配置没有计划时,默认生成高中轨道,`entryClassCount` 取旧 `classCount`。
升级接口:
| 接口 | 行为 |
| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `GET /term/promotion` | 返回计划、执行时间、应执行学年、是否待执行、最近执行记录、旧字段兼容 |
| `PUT /term/promotion` | 保存执行时间与计划;校验轨道/年级/班数/日期;旧扁平格式继续接受 |
| `POST /term/promotion/preview` | 只读预览:逐轨道、逐班、逐状态计数;阻断项;将新建班级;将删除的 `TermWeek` 行数;计划哈希 |
| `POST /term/promotion/run` | 必须带 `year`、`planHash`、`confirm: true`;只允许当前应执行学年或 ADMIN 明确补跑过去学年;不允许未来学年 |
| `POST /classes/promote` | 目标年级由计划推导,不再接受自由文本;terminal 班拒绝并提示走年度升级 |
预览必须包含:
- 每条轨道:将升级的班、将归档的班、将新建的班(含生成班级码)、学生 `active/inactive/transferred` 计数。
- 阻断项(有任意一项就拒绝执行,返回 409 + 结构化 `details`):
- 在读班级的 `grade` 不在计划内;
- `enrollmentYear` 缺失或同一届已存在 `ArchivedYear`;
- 新班班级码冲突且无法生成唯一码;
- 同一轨道同一届出现重复 `classIndex`;
- 计划哈希不匹配或配置在校验后被修改。
- 警告项:无班主任/无任课的新班、`TermWeek` 将被清空、`resetTermWeeks=false` 时旧周次会继续覆盖新学年。
执行顺序(单事务,快照成功后才开始):
1. 计算并校验计划与预览;写入/更新 `PromotionRun(status='running')`。
2. terminal 学段:按 `enrollmentYear` 分组创建/校验 `ArchivedYear`;班级写 `archivedYearId/archivedAt`;**所有学生**按状态处理并写 `archivedYearId/archivedAt`:`active → graduated`,`transferred` 保持,`inactive` 保持。统计 `studentCount/transferredCount/inactiveCount/classCount/homeworkCount/notificationCount`。
3. 非 terminal 学段:只改 `grade` 为下一学段;清空 `Class.termStartDate`;保留班级/学生/作业/成绩。
4. 每条轨道起始年级:按 `entryClassCount` 新建骨架班;`enrollmentYear=year`、`classIndex=1..N`、`grade=起始年级`、`teacherId=null`、无课程;班级码必须唯一;`termWeeks` 取默认值并受全校上限约束。
5. 学期:`term.startDate` 设为计划日期(默认 `${year}-09-01`)或计划中的 `termStartDate`;`resetTermWeeks=true` 时删除 `TermWeek`(预览已明确行数),否则保留并警告;新班 `termStartDate=null`。
6. `PromotionRun(status='done')` 写操作人、明细 JSON、快照 SHA256;提交后广播课表/班级更新。
幂等与并发:
- `PromotionRun.year` 唯一;已 `done` 直接返回既有记录并标记 `alreadyDone`。
- 并发执行只允许一个事务提交;另一个返回既有结果或 409。
- 失败保留 `failed` 记录、快照路径与错误;允许修复后重试,重试前删除同年的 `failed/running` 记录。
- 快照路径与 SHA256 必须在执行记录和 UI 中可见;不要自动删除升级快照。
毕业/归档:
- 毕业只由年度升级产生,不恢复手动 `POST /archives` 入口;删除 `createArchive` 死代码及其 schema。
- 撤销归档(`DELETE /archives/:id`)只撤销归档标记:`graduated → active`,`transferred/inactive` 保持状态并清 `archivedYearId`;不回滚年级、不删除新建班、不恢复已清空的 `TermWeek`。UI 必须写明这一点。
- 归档详情增加 `inactive` 列表;`getArchivedClassContent` 不变。
手动升级:
- `promoteClasses` 从计划推导下一学段;terminal 拒绝;只允许 `promotion:manage`;单事务;广播;记录操作日志。
- `PATCH /classes/:id` 移除 `grade` 字段(或仅 ADMIN 且走升级逻辑),否则用户可绕过计划任意改年级。
### 4.9 Web 管理端
- `router/index.ts`:按 4.1 改为 `meta.permission`;保留 `database` 等真正仅 ADMIN 的页面的角色门槛;守卫同时检查 `roles` 与 `permission`。
- `PermissionsView.vue`:
- 组表增加“范围”列(`scopeLabel`)、成员数、更新时间;支持查看成员、复制组。
- 编辑弹窗:范围选择器(年级多选/部多选/全校/关联班级),不再手填 `scopeValue`;显示每个键的默认角色与高权限标记;保存前展示影响预览。
- 成员页:搜索/筛选;分配弹窗展示“角色默认模板 vs 所选组”的生效差异;默认勾选同角色默认组;跨角色默认组禁止选择。
- 新增“变更记录”Tab,读取审计接口。
- 所有操作按 `permission:manage` 门控;失败提示用服务端文案。
- `StudentsView.vue`:
- 状态筛选真正可用;默认“在读”;“包含归档”单独开关。
- 操作按钮按 `auth.can('roster:manage')` 显示;只读账号仍能看历史。
- 调班弹窗显示源班/目标班/年级变化;跨年级警告;转出弹窗增加“去向学校/原因”;新增“转入”入口。
- 编辑表单移除班级/状态字段;删除改为“停用/删除”并说明影响。
- 历史抽屉显示类型、批次、学年、原/新班级与年级、操作人、备注;补齐 `restore` 等标签。
- `ClassesView.vue`:升级区改为显示“下一学段”,不再自由输入;terminal 班引导到年度升级/归档页;批量升级可在年度升级页完成。
- `ArchivesView.vue`:显示转入/转出/停用统计;届别详情增加停用学生;撤销归档确认文案写清“不回滚年级与周次”;不显示“新建归档”。
- `PromotionSettings.vue`:重做为“计划 + 预览 + 执行 + 记录”:
- 轨道/学段编辑(增删、排序、标记毕业学段)、起始年级班数、执行时间、学期开始日期、是否重置周次。
- 预览抽屉:逐班表格、状态计数、阻断项/警告、新班班级码、将删除的周次行数。
- 执行前要求确认学年与计划哈希;执行中禁用重复提交;执行后展示快照路径/SHA256/操作人/明细。
- 执行记录表:学年、状态、操作人、班级/学生统计、快照、错误。
- 移动端:保持现有响应式;权限页/升级页的宽表格允许横向滚动,不新造断点体系。
## 5. 实施阶段
### Phase 0:基线与测试骨架
- 运行现有 `pnpm lint`、`pnpm typecheck`、`pnpm verify:v14`,记录实际输出。
- 新增隔离验收脚本骨架(建议 `packages/server/scripts/verify-lifecycle.ts` + 根 `scripts/verify-lifecycle.mjs`,复用 `verify-v14` 的临时 SQLite 模式),先放会失败的用例。
- 更新 `scripts/verify-client-migrations.mjs` 中“最后一条迁移名”的硬编码断言,改为读取实际最后一条;新迁移必须能被旧库升级回归覆盖。
**出口**:基线通过;新测试能跑起来并明确失败原因。
### Phase 1:权限键接线与页面门控
- 新增 `promotion:manage` 键与迁移,并把 `/term/promotion*` 从 `system:manage` 切换过来;旧库 ADMIN 默认组补键。
- 按 4.1 逐键接入路由;补 `homework:write`、读键、`class:read`、`schedule:read`、`homework:read`、`notification:read`;把归档/升级服务里的 `assertCanManageClasses` 换成对应功能键断言。
- 班级端路由改 `requireStaffPermission`,确认桌面客户端调用链。
- Web 路由/按钮按权限门控;移除 `students/classes/archives/term-weeks` 的 ADMIN 硬门槛。
- 修 `import:students` 的角色替代逻辑。
**出口**:权限矩阵中每个键至少有一个入口;取消键后接口 403;班级端既有能力不回归。
### Phase 2:范围、防提权与审计
- 核对 `promotion:manage` 的旧库补键与默认组行为,确保迁移与 `ensureDefaultGroups()` 不会重复/漏键。
- 实现 scope parser、`GET /permissions/scopes`、`teacherScopeWhere` 重写、DTO `scopeLabel`。
- 实现 4.4 的防提权规则;`PermissionAudit` 表/接口/UI。
- 重写 `listMembers` 为一次预取;补组详情/成员接口。
**出口**:跨角色默认组分配 403;无键授权 403;范围非法 400;审计可查;成员列表查询数不随人数线性增长。
### Phase 3:学生状态机与写入口
- 新增 `lib/student-lifecycle.ts`;改造 students/classes 服务。
- 收紧 `PATCH /students`,新增转入/复学;调班/转出/移出全部事务化 + `batchId`。
- 历史表迁移与 DTO 兼容;修 `activeStudentWhere`/状态筛选/转出历史。
- 删除/停用保护;归档学生不可复活。
**出口**:S1–S7 的复现用例全部转为通过;调班失败不会留下半截数据;转出历史可查。
### Phase 4:年度升级计划与归档
- 新增 `PromotionPlan` 类型、schema、默认计划、迁移。
- 实现 preview/run/planHash/年份限制/幂等/快照 SHA256/执行明细。
- 重写周次重置、归档学生范围、新班码预检、手动升级。
- 删除 `createArchive` 死代码;归档详情补 `inactive`。
- 保持 `runPromotion(year, dryRun)` 旧签名可用(供 v14 测试),新增 options 重载;路由层做年份限制。
**出口**:预览不写库;阻断项可复现;转出/停用学生进入归档;同年重复执行幂等;新班码冲突在预览阶段被拦。
### Phase 5:UI 与文档
- 完成 4.9 的页面改造。
- 更新 `docs/dev/permissions.md`、`docs/app/classes.md`、`docs/dev/api.md`、`docs/dev/data-model.md`、`docs/dev/implementation.md`、`docs/dev/testing.md`、`docs/get-started/web-admin.md`;不要手抄键数/测试数。
- 为新增接口补 API 文档表格;为迁移补升级说明。
**出口**:`pnpm docs:check` 通过;UI 构建通过;关键路径有隔离 UI 回归或手工验收记录。
## 6. 验收清单
### 权限组
- [ ] 逐个键验证:有键可用、无键 403;不存在“有键但接口不检查”或“接口检查但键不在目录”。
- [ ] 班级端:本班名单/课表/通知/叫人/作业代录可用;成绩写入 403;个人成绩查询受开关控制。
- [ ] 教师默认组、空自定义组、多个组并集、无组回落默认组四种情况均符合文档。
- [ ] 教师被授予 `permission:manage` 后不能创建/分配超出自身权限的组;不能给自己提权;不能分配 ADMIN 默认组给教师。
- [ ] 非 ADMIN 不能管理 `GRADE`/`DIVISION`/`ALL` 范围组(或按你实现的覆盖规则验证)。
- [ ] 范围空值、未知年级、全角逗号、重复年级均被规范化或 400。
- [ ] 权限变更审计有操作人、目标、前后差异;成员列表不再 N+1。
- [ ] 权限页的“影响预览”与实际生效一致;默认组文案不再误导。
### 学生流程
- [ ] `PATCH /students` 不能改班级/状态;直接构造请求返回 400。
- [ ] 调班:源班/目标班范围校验;跨年级默认拒绝;历史与班级更新同事务;旧班/新班都收到实时事件。
- [ ] 转出:只接受在读/停用;毕业生/已归档 409;写 `transferredAt`、去向、历史;重复转出幂等或 409。
- [ ] 转入/复学:新建与 `transferred` 重入两条路径都正确;学号唯一;归档班不能作为目标。
- [ ] 撤回转出:原班在读可恢复;原班归档时可指定其他在读班;历史保留。
- [ ] 归档学生不可通过 `addStudent` 复活;`isActiveStudent` 不再把 `graduated` 当在读。
- [ ] 状态筛选 `transferred/graduated/inactive` 均能返回正确记录;转出历史在班级/学生维度都可见。
- [ ] 删除保护:有成绩/作业/已读/历史的学生不能硬删;软停用后不在在读名单。
- [ ] 升级预览:逐班、逐状态、阻断项、新班码、周次影响都可见;不写库。
- [ ] 升级执行:毕业班归档;非毕业班只升一级;转出/停用学生挂届别;新班无班主任/无课程;同年重复执行幂等。
- [ ] 计划含初中/多轨道时,各轨道独立升级;未知年级被阻断。
- [ ] `termStartDate` 按计划写入;`resetTermWeeks=false` 时不清空且 UI 有警告;清空时预览行数准确。
- [ ] 未来学年不能执行;过去学年仅 ADMIN 补跑;`planHash` 不匹配 409。
- [ ] 手动升级只接受计划中的下一学段;terminal 班拒绝;`PATCH /classes` 不能绕过。
### 兼容与数据
- [ ] 旧库升级:新列有默认值,旧 `mode` 正确映射 `kind`,旧权限组不丢键,ADMIN 默认组补 `promotion:manage`。
- [ ] `SNAPSHOT_TABLES` 包含所有新表;快照导出/恢复/换库不丢数据。
- [ ] 旧 Web 客户端/PWA 缓存发送旧请求体时,接口给出明确 400 或兼容处理,不静默写错。
- [ ] 桌面客户端离线/在线冒烟通过;未破坏班级端登录、心跳、作业、通知。
## 7. 验证命令
按改动范围至少执行:
```bash
pnpm lint
pnpm typecheck
pnpm verify:v14
pnpm verify:e2e
pnpm verify:lifecycle # 新增脚本;先加 package.json script
pnpm verify:client-migrations
pnpm --filter @classhelper/web-admin build
pnpm verify:web:isolated
pnpm docs:check # 改了 docs 时
pnpm verify:desktop # 改了班级端会调用的路由/契约时
```
注意:
- `verify-client-migrations.mjs` 目前硬编码最后一条迁移名;新增迁移后必须同步改为动态读取,并验证旧库升级。
- 不在真实业务库上跑升级;升级测试用临时 SQLite。
- 单元/接口断言优先直接调用 service + 临时库;UI 回归用隔离服务。
- 数量只记录本次输出,不抄历史数字。
## 8. 交付物与完成定义
每次汇报必须包含:
1. 本阶段改动文件清单与一句话说明。
2. 迁移说明:新表/新列、旧库兼容、`SNAPSHOT_TABLES` 变更、是否需要管理员补权限。
3. 实际执行的验证命令与输出摘要(通过/失败数、失败原因)。
4. 行为变化与兼容性:旧前端、班级端、离线客户端、旧权限组。
5. 风险与未完成项;不允许用“应该可以”代替验证。
完成定义:
- 第 6 节清单全部通过,或明确列出未通过项及原因、影响、后续任务。
- 没有装饰性权限键、没有第二条毕业路径、没有硬编码三个年级、没有绕过生命周期的写入口。
- 所有新增/修改接口在 `docs/dev/api.md` 有边界说明;所有新增权限键在 `docs/dev/permissions.md` 有归属说明。
## 9. 如果只能做最小闭环
按以下顺序交付,仍须保证每步可运行:
1. 修 S1–S7(状态机、事务、历史查询、复活、删除保护)。
2. 接通 P1–P5(权限键、班级端守卫、页面门控、`promotion:manage`)。
3. 升级预览/执行正确性(S8–S13),暂不做多轨道 UI 的完整编辑,只保留默认高中轨道 + 只读计划展示。
4. 最后做 P6–P11(防提权、审计、权限页预览)。
不要为了“最小闭环”保留已知的数据不一致;宁可缩小 UI 范围,也不要保留两条写路径。
@@ -0,0 +1,145 @@
# 权限组治理与学生年度生命周期交付记录
日期:2026-10-23(Asia/Shanghai)。工作目录:`D:\classhelper`。版本保持 `1.4.1`。
任务源是 `prompts/permission-and-student-lifecycle.prompt.md`;用户消息中的路径缺少 prompts 后的目录分隔符,已按仓库实际文件定位。
工作目录未包含 `.git`,无法分阶段提交或提供 Git diff;没有初始化仓库、发布远端或改动 releases。已有无关工作保留。
## 实施与阶段验证
- Phase 0:先阅读规则、指定文档和实现。基线 lint/typecheck 通过;v14 最初因缺测试密钥退出,隔离注入后 37/37 通过。新增专项先复现“资料 PATCH 接受毕业状态”的失败,再修复。
- Phase 1:读写功能键、班级端守卫、独立升级键、页面门控和教师名单导入接通。取消键返回 403;教师全校授权可管理班级、查看归档和升级配置;班级详情不向无名单键者附带学生。
- Phase 2:统一范围解析,阻止跨角色默认组、越键和越范围授权;角色默认组只由 ADMIN 编辑。审计同事务,成员权限预取计算;增加成员不增加权限查询次数。
- Phase 3:资料不接受状态/班级,调班、转入出、撤回、复学、停用及移出共用事务入口。故障触发器证明历史失败时班级修改回滚;转出历史可读,毕业/归档不可复活,业务及历史阻止硬删除。
- Phase 4:按轨道与有序学段升级;未知年级、缺届别、重复班号、已有档案、新班码冲突阻断。预览不写库,执行复核哈希,保存快照/SHA256,归档包含转出与停用,周次按计划保留或重置,同年幂等。
- Phase 5:完成权限、学生、班级、归档、升级界面及指定文档同步。Web 构建和文档严格构建通过;关键页面完成隔离浏览器人工验收。
## 迁移与兼容
1. `20261023000000_promotion_permission`:为已存在的 ADMIN 默认组补 promotion:manage;无默认组时运行时初始模板自然包含。不会覆盖已有默认组的其他键。
2. `20261023010000_student_lifecycle_audit`:增加 PermissionAudit;历史新增 kind/batchId/academicYear/年级与学校快照;ArchivedYear 新增 inactiveCount;PromotionRun 新增操作人、明细、摘要和错误。
3. 旧 single/batch → class-change,transfer-out 保持,restore-transfer → restore,然后删除 mode 列。DTO 中 mode 仅派生兼容,不再双写。
4. SNAPSHOT_TABLES 已包含 PermissionAudit。旧快照没有此表时恢复为空,旧历史 mode 恢复时转换,缺列使用默认值。快照和执行记录不自动删除。
5. 自定义权限组不会自动得到新升级键;已显式分配其他组的管理员或教务账号需要显式授予。旧客户端资料请求携带 classId/status、班级 PATCH 携带 grade 返回明确 400;旧 dryRun 升级请求提示改用预览和哈希确认接口。
6. CLASS_DEVICE 权限集合仍为空。本班名单、课表、通知、叫人及代录作业保留,成绩写入拒绝,个人成绩查询开关保护保留。离线缓存、preload、CSP、设备心跳实现未改。
7. 迁移、升级、种子与生命周期写入只在本次专用临时 SQLite 运行,未对业务库执行迁移、seed、reset 或升级。修正旧 e2e 固定 dev.db 连接探针;其本次创建的 0 字节空文件已核查后移除。
## 实际验证结果
| 命令 | 本次结果 |
| ------------------------------------------ | -------------------------------------------------------------------- |
| pnpm lint | 通过,无错误或警告 |
| pnpm typecheck | 通过,shared/server/web/desktop 全部完成 |
| pnpm verify:lifecycle | 32/32 通过,最近一次进程退出码 0 |
| pnpm verify:v14 | 37/37 通过,进程退出码 0 |
| pnpm verify:e2e | 244/244 通过,进程退出码 0 |
| pnpm verify:client-migrations | 通过:旧默认组补键、旧 mode 映射、旧行默认值、空组保留及重启幂等 |
| pnpm --filter @classhelper/web-admin build | 通过 |
| pnpm build:desktop | 通过,产物凭据检查通过 |
| pnpm docs:check | 通过,docfx 0 warning / 0 error |
| pnpm verify:web:isolated | 未通过:Electron 启动退出 2147483651(0x80000003),尚未进入 UI 用例 |
| pnpm verify:desktop | 未通过:同样在 Electron 启动阶段退出 2147483651,尚未进入客户端用例 |
受限终端最初读取 node_modules 遇到 EPERM;上表运行使用自动审批后的正常权限终端。pnpm 为仓库锁定的 11.8.0。
docfx 原先缺失,已安装到 `.cache/docfx`,NuGet/.NET 工具缓存也限制在仓库 `.cache`,没有改构建脚本的全局查找逻辑。
文档重跑时需将 `.cache/docfx` 加入 PATH。
原先 e2e 中“班级端不得通知/叫人”“名单仅管理员”“固定管理员字段”等旧断言与本次明确要求冲突,已改为验证新行为,并保留无键、跨班、成绩写入等拒绝断言。
导入成功夹具改用合法独立学生,结束时停用保留历史,恢复原在读名单,避免影响后续全班已读/完成统计。
## 隔离浏览器人工验收
在 127.0.0.1:54127 的专用临时库使用临时管理员、临时教师执行,没有连接真实班级。浏览器保持后台。
- 管理员能打开独立“学年升级”页面,看到轨道、执行时间、开学日、周次策略与记录。
- 点击预览后显示逐班/逐状态、哈希、周次影响;测试夹具的未知年级显示阻断,执行按钮禁用。未通过浏览器执行升级。
- 权限页显示范围标签、实际成员数、更新时间及审计 Tab;编辑级长组使用年级下拉,显示服务端年级选项和影响预览,未提交权限变更。
- 学生默认在读;编辑弹窗只有学号、姓名、性别、家长电话,缺少班级/状态字段。
- 切到已转出可返回学生;历史抽屉包含转出记录、批次/学年、原新年级和学校字段。
- 教师菜单可见班级、学生及学期周次;班级页面可读且隐藏创建、编辑、账号、删除等无管理键按钮。
- 目视检查升级预览与名单布局。验收结束后检查 54127 无监听;临时浏览器页由会话清理。
## 已知限制与后续
- Electron 自动 UI 与桌面冒烟仍未通过,需要在可正常启动该 Electron 运行时的 Windows 环境补跑;浏览器验收不能证明桌面离线、桥接或原生窗口运行正确。本次没有关闭安全保护来绕过启动问题。
- 一次生命周期专项在全部断言通过后的连接释放阶段出现原生退出 0xC0000005;相同命令重跑 32/32 且正常退出。根因未确认,不把这次异常解释为断言失败,也不宣称已根治依赖层退出问题。
- SQLite 新库与旧库升级、快照恢复已实测;未配置可用的隔离 MySQL 实例,未做 MySQL 真机读写回归。schema 没有加入 Prisma enum 或 @db.*。
- 并发同年升级及幂等已由 v14 回归;没有另行穷举不同数据库进程、所有权限/岗位/接口组合。业务唯一键与事务仍是跨进程保护边界。
- 旧库如果已有毕业标记与归档不一致等异常数据,预览会阻断并提示先核对,迁移不擅自猜测或复活学生。
## 修改文件
- [docs/app/classes.md](D:/classhelper/docs/app/classes.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/dev/api.md](D:/classhelper/docs/dev/api.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/dev/data-model.md](D:/classhelper/docs/dev/data-model.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/dev/implementation.md](D:/classhelper/docs/dev/implementation.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/dev/permissions.md](D:/classhelper/docs/dev/permissions.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/dev/testing.md](D:/classhelper/docs/dev/testing.md):同步现行 API、权限、数据模型或操作/验收说明。
- [docs/get-started/web-admin.md](D:/classhelper/docs/get-started/web-admin.md):同步现行 API、权限、数据模型或操作/验收说明。
- [package.json](D:/classhelper/package.json):登记 verify:lifecycle,不修改版本。
- [packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql](D:/classhelper/packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql):新增迁移;不修改任何历史迁移。
- [packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql](D:/classhelper/packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql):新增迁移;不修改任何历史迁移。
- [packages/server/prisma/schema.prisma](D:/classhelper/packages/server/prisma/schema.prisma):增加审计与生命周期字段,保持字符串状态和单一跨库 schema。
- [packages/server/prisma/seed.ts](D:/classhelper/packages/server/prisma/seed.ts):演示历史写入新 kind 字段;本次只在隔离库执行。
- [packages/server/scripts/verify-e2e.mjs](D:/classhelper/packages/server/scripts/verify-e2e.mjs):读取真实快照表注册表,探测当前临时库,并对齐新授权行为及测试数据收尾。
- [packages/server/scripts/verify-lifecycle.ts](D:/classhelper/packages/server/scripts/verify-lifecycle.ts):覆盖授权、防提权、事务故障、状态机、多轨道、哈希和快照恢复。
- [packages/server/scripts/verify-v14.ts](D:/classhelper/packages/server/scripts/verify-v14.ts):结束初中范围夹具后再执行默认高中计划,不放宽升级断言。
- [packages/server/src/lib/access.ts](D:/classhelper/packages/server/src/lib/access.ts):统一功能键、目标范围和业务关系断言;授权员工可管理对应班级。
- [packages/server/src/lib/mappers.ts](D:/classhelper/packages/server/src/lib/mappers.ts):输出生命周期字段并从 kind 派生旧 mode。
- [packages/server/src/lib/permission-scope.ts](D:/classhelper/packages/server/src/lib/permission-scope.ts):默认组与显式组均按功能键过滤,再使用共享解析器生成范围。
- [packages/server/src/lib/permissions.ts](D:/classhelper/packages/server/src/lib/permissions.ts):新增服务断言,保留每请求实时生效和管理员必要入口。
- [packages/server/src/lib/settings.ts](D:/classhelper/packages/server/src/lib/settings.ts):登记 term.promotionPlan。
- [packages/server/src/lib/snapshot.ts](D:/classhelper/packages/server/src/lib/snapshot.ts):加入 PermissionAudit,兼容缺审计表的旧快照并转换旧 mode。
- [packages/server/src/lib/student-lifecycle.ts](D:/classhelper/packages/server/src/lib/student-lifecycle.ts):集中学生状态/班级/历史的事务写入,提交后广播相关班级。
- [packages/server/src/lib/students.ts](D:/classhelper/packages/server/src/lib/students.ts):在读统一为 active 且未归档,排除毕业、转出和停用。
- [packages/server/src/middleware/auth.ts](D:/classhelper/packages/server/src/middleware/auth.ts):在当前请求复用权限和功能范围上下文,保留班级端显式例外。
- [packages/server/src/modules/archives/archives.module.ts](D:/classhelper/packages/server/src/modules/archives/archives.module.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
- [packages/server/src/modules/archives/archives.schemas.ts](D:/classhelper/packages/server/src/modules/archives/archives.schemas.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
- [packages/server/src/modules/archives/archives.service.ts](D:/classhelper/packages/server/src/modules/archives/archives.service.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
- [packages/server/src/modules/archives/transferred.service.ts](D:/classhelper/packages/server/src/modules/archives/transferred.service.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
- [packages/server/src/modules/calls/calls.module.ts](D:/classhelper/packages/server/src/modules/calls/calls.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/calls/calls.service.ts](D:/classhelper/packages/server/src/modules/calls/calls.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/class-plans/class-plans.module.ts](D:/classhelper/packages/server/src/modules/class-plans/class-plans.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/class-plans/profile-import.service.ts](D:/classhelper/packages/server/src/modules/class-plans/profile-import.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
- [packages/server/src/modules/classes/classes.module.ts](D:/classhelper/packages/server/src/modules/classes/classes.module.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
- [packages/server/src/modules/classes/classes.schemas.ts](D:/classhelper/packages/server/src/modules/classes/classes.schemas.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
- [packages/server/src/modules/classes/classes.service.ts](D:/classhelper/packages/server/src/modules/classes/classes.service.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
- [packages/server/src/modules/courses/courses.module.ts](D:/classhelper/packages/server/src/modules/courses/courses.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/homeworks/homeworks.module.ts](D:/classhelper/packages/server/src/modules/homeworks/homeworks.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/homeworks/homeworks.service.ts](D:/classhelper/packages/server/src/modules/homeworks/homeworks.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/imports/class-plan.service.ts](D:/classhelper/packages/server/src/modules/imports/class-plan.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
- [packages/server/src/modules/imports/imports.module.ts](D:/classhelper/packages/server/src/modules/imports/imports.module.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
- [packages/server/src/modules/imports/table-import.service.ts](D:/classhelper/packages/server/src/modules/imports/table-import.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
- [packages/server/src/modules/imports/time-layout.service.ts](D:/classhelper/packages/server/src/modules/imports/time-layout.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
- [packages/server/src/modules/notifications/notifications.module.ts](D:/classhelper/packages/server/src/modules/notifications/notifications.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/notifications/notifications.service.ts](D:/classhelper/packages/server/src/modules/notifications/notifications.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/permissions/permissions.module.ts](D:/classhelper/packages/server/src/modules/permissions/permissions.module.ts):接入规范范围、防越权授权、批量成员计算与事务审计。
- [packages/server/src/modules/permissions/permissions.service.ts](D:/classhelper/packages/server/src/modules/permissions/permissions.service.ts):接入规范范围、防越权授权、批量成员计算与事务审计。
- [packages/server/src/modules/schedules/schedules.module.ts](D:/classhelper/packages/server/src/modules/schedules/schedules.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
- [packages/server/src/modules/students/students.module.ts](D:/classhelper/packages/server/src/modules/students/students.module.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
- [packages/server/src/modules/students/students.schemas.ts](D:/classhelper/packages/server/src/modules/students/students.schemas.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
- [packages/server/src/modules/students/students.service.ts](D:/classhelper/packages/server/src/modules/students/students.service.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
- [packages/server/src/modules/term/promotion.service.ts](D:/classhelper/packages/server/src/modules/term/promotion.service.ts):实现多轨道计划、预览、阻断、哈希、快照摘要、事务执行、幂等和失败记录。
- [packages/server/src/modules/term/term.module.ts](D:/classhelper/packages/server/src/modules/term/term.module.ts):独立升级权限、确认与哈希接口,学期写入按授权而非角色判断。
- [packages/server/src/modules/term/term.service.ts](D:/classhelper/packages/server/src/modules/term/term.service.ts):独立升级权限、确认与哈希接口,学期写入按授权而非角色判断。
- [packages/shared/src/index.ts](D:/classhelper/packages/shared/src/index.ts):导出范围和升级计划契约。
- [packages/shared/src/lifecycle.ts](D:/classhelper/packages/shared/src/lifecycle.ts):提供唯一范围解析器及多轨道升级计划契约和默认计划。
- [packages/shared/src/permissions.ts](D:/classhelper/packages/shared/src/permissions.ts):登记 promotion:manage、默认角色、标签、分类与矩阵。
- [packages/shared/src/types.ts](D:/classhelper/packages/shared/src/types.ts):扩展权限、学生历史与归档 DTO,收紧资料和班级更新契约。
- [packages/web-admin/src/api/index.ts](D:/classhelper/packages/web-admin/src/api/index.ts):同步收紧后的写入参数,移除无服务端入口的手工创建归档 API。
- [packages/web-admin/src/components/PromotionSettings.vue](D:/classhelper/packages/web-admin/src/components/PromotionSettings.vue):提供计划编辑、只读影响预览、学年确认执行和快照明细记录。
- [packages/web-admin/src/components/TransferredStudents.vue](D:/classhelper/packages/web-admin/src/components/TransferredStudents.vue):按名单管理键撤回转出,允许选择其他在读班。
- [packages/web-admin/src/config/navigation.ts](D:/classhelper/packages/web-admin/src/config/navigation.ts):将学年升级加入班级与人员菜单,权限仍来自路由。
- [packages/web-admin/src/router/index.ts](D:/classhelper/packages/web-admin/src/router/index.ts):移除班级、学生、周次和归档的固定 ADMIN 门槛,新增独立升级页面。
- [packages/web-admin/src/views/ArchivesView.vue](D:/classhelper/packages/web-admin/src/views/ArchivesView.vue):展示转入、转出、停用及停用名单,明确撤销归档的实际边界。
- [packages/web-admin/src/views/ClassesView.vue](D:/classhelper/packages/web-admin/src/views/ClassesView.vue):按键控制写按钮,年级只读并展示计划推导的下一学段。
- [packages/web-admin/src/views/PermissionsView.vue](D:/classhelper/packages/web-admin/src/views/PermissionsView.vue):范围选择、真实成员、复制、差异确认、成员搜索和审计分页。
- [packages/web-admin/src/views/StudentsView.vue](D:/classhelper/packages/web-admin/src/views/StudentsView.vue):状态/归档筛选、专用调转入出、复学、停用/空记录删除与历史明细。
- [packages/web-admin/src/views/TermWeeksView.vue](D:/classhelper/packages/web-admin/src/views/TermWeeksView.vue):按 term:write 显示写按钮,升级区按独立键显示。
- [scripts/ui-smoke/main.cjs](D:/classhelper/scripts/ui-smoke/main.cjs):同步教师按权限显示菜单与新版升级页面断言。
- [scripts/verify-client-migrations.mjs](D:/classhelper/scripts/verify-client-migrations.mjs):动态读取最新迁移,并验证旧默认组补键、旧历史映射与重启幂等。
- [scripts/verify-e2e-isolated.mjs](D:/classhelper/scripts/verify-e2e-isolated.mjs):明确隔离环境,保持随机端口、临时账号和临时库。
- [scripts/verify-lifecycle.mjs](D:/classhelper/scripts/verify-lifecycle.mjs):创建专用临时 SQLite 与测试环境,回放迁移后运行专项。
- [scripts/verify-v14.mjs](D:/classhelper/scripts/verify-v14.mjs):为隔离回归提供独立测试密钥和环境。
本记录的日志位于 `.cache/lifecycle-*.log`,变更清单位于 `.cache/lifecycle-changed-files.json`。
+97 -41
View File
@@ -36,6 +36,9 @@ const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
*/
const MENU_ITEMS = [
{ label: '仪表盘', path: '/dashboard' },
{ label: '班级管理', path: '/classes' },
{ label: '学生管理', path: '/students' },
{ label: '学期周次', path: '/term-weeks' },
{ label: '课表档案', path: '/plan-editor' },
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
@@ -46,7 +49,7 @@ const MENU_ITEMS = [
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理', '数据库管理'];
const HIDDEN_MENU_LABELS = ['教师管理', '数据库管理'];
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
@@ -558,10 +561,10 @@ async function main() {
// 等视图渲染
const viewDeadline = Date.now() + 3000;
while (Date.now() < viewDeadline) {
if (document.querySelector('.page-title')) break;
if (document.querySelector('.page-title, .archive-titlebar > span')) break;
await new Promise((resolve) => setTimeout(resolve, 60));
}
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
const title = (document.querySelector('.page-title, .archive-titlebar > span')?.textContent ?? '').trim();
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
})()`);
@@ -796,7 +799,7 @@ async function main() {
return { labels, leaked };
})()`);
record(
'教师端隐藏无权入口(班级管理/学生管理/教师管理)',
'教师端隐藏未授权教师管理与数据库入口',
(hiddenCheck?.leaked ?? ['?']).length === 0,
`可见菜单=[${(hiddenCheck?.labels ?? []).join(',')}] 越权入口=[${(hiddenCheck?.leaked ?? []).join(',')}]`,
);
@@ -1013,7 +1016,10 @@ async function main() {
await sleep(600);
const buttons = Array.from(document.querySelectorAll('button'));
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入档案');
const transfer = buttons.find((node) => (node.textContent ?? '').trim() === '导入/导出');
transfer?.click();
await sleep(200);
const openButton = Array.from(document.querySelectorAll('button')).find((node) => (node.textContent ?? '').trim() === '导入 ClassIsland 档案');
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
// 旧入口必须已经下线(需求 ⑩)
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
@@ -1357,57 +1363,107 @@ async function runPlanEditorChecks(win) {
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
const ready = await waitFor(
win,
`document.body.innerText.includes('课表编辑') && Boolean(document.querySelector('.course-row'))`,
);
record('统一课表档案工作区与课程时段编辑可用', ready);
const save = await win.webContents.executeJavaScript(
`(() => {const b=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='保存课表');b?.click();return Boolean(b&&!b.disabled);})()`,
);
const saved = save && (await waitFor(win, `document.body.innerText.includes('整张日课表已保存')`));
record('日课表整表保存可以从页面提交', saved);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.editor-nav button')].find(e=>e.textContent.trim()==='时间表')?.click();})()`,
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
);
record(
'时间表页四种时间点与管理员保存入口可见',
'ClassIsland 风格档案工作区默认展示周课表和选科侧栏',
ready &&
(await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4`,
)),
);
const assignment = await win.webContents.executeJavaScript(`(() => {
const cell = document.querySelector('.lesson-cell');
if (!cell) return null;
const original = cell.querySelector('strong')?.textContent.trim();
cell.click();
const button = [...document.querySelectorAll('.subject-palette button')].find(b => !b.disabled && b.title !== original);
button?.click();
return { original, editable: Boolean(button) };
})()`);
const dirty =
assignment?.editable && (await waitFor(win, `document.body.innerText.includes('有未保存的修改')`));
record('点击周课表并从右侧选科生成可保存的课程草稿', dirty);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
);
record(
'课表草稿可通过现有接口保存',
dirty &&
(await waitFor(
win,
`document.body.innerText.includes('档案已保存') && !document.querySelector('.draft-label')`,
)),
);
// 恢复隔离验收班级的原科目。
if (assignment?.original) {
await win.webContents.executeJavaScript(`(() => {
document.querySelector('.lesson-cell')?.click();
const original = ${JSON.stringify(assignment.original)};
[...document.querySelectorAll('.subject-palette button')].find(b => b.title === original)?.click();
})()`);
await sleep(150);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
);
await waitFor(win, `!document.querySelector('.draft-label')`);
}
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
record(
'列表视图保留课时编辑和批量操作',
await waitFor(
win,
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表')`,
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`,
),
);
await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:nth-child(2)').click()`,
);
record(
'时间表页四种时间点与保存入口可见',
await waitFor(
win,
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动')`,
),
);
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
const rows = () => document.querySelectorAll('.time-row');
const before = rows().length;
const before = document.querySelectorAll('.time-row').length;
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click();
await new Promise((resolve) => setTimeout(resolve, 180));
const after = rows().length;
const last = rows()[after - 1];
const values = last ? [...last.querySelectorAll('.el-date-editor input')].map((input) => input.value) : [];
const [start, end] = values;
const diff = (() => {
if (!/^\\d{2}:\\d{2}$/.test(start ?? '') || !/^\\d{2}:\\d{2}$/.test(end ?? '')) return -1;
const [h1, m1] = start.split(':').map(Number), [h2, m2] = end.split(':').map(Number);
return h2 * 60 + m2 - (h1 * 60 + m1);
})();
return { before, after, start, end, diff };
await new Promise(resolve => setTimeout(resolve, 180));
const rows = document.querySelectorAll('.time-row');
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value);
const minutes = (value) => { const [h, m] = (value || '').split(':').map(Number); return h * 60 + m; };
return { before, after: rows.length, diff: minutes(values[1]) - minutes(values[0]) };
})()`);
record(
'时间表新建课时按快速时长生成起止时间',
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
`行数 ${quickAdd.before}→${quickAdd.after} 时间=${quickAdd.start ?? '-'}–${quickAdd.end ?? '-'} 分钟=${quickAdd.diff}`,
);
await win.webContents.executeJavaScript(
`document.querySelector('.editor-nav button:nth-child(5)').click()`,
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`,
);
await waitFor(win, `Boolean(document.querySelector('.el-message-box'))`);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.el-message-box button')].find(b => b.textContent.trim() === '放弃修改')?.click();})()`,
);
await waitFor(win, `!document.querySelector('.draft-label')`);
await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:first-child').click()`,
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="月视图"]').click()`);
record(
'整周预览与当前生效课表保持一致',
await waitFor(
win,
`document.body.innerText.includes('整周课表预览') && document.querySelector('.week-grid .el-table__body')`,
),
'月视图提供日期导航',
await waitFor(win, `document.querySelectorAll('.calendar-grid button').length === 42`),
);
await win.webContents.executeJavaScript(`document.querySelector('.editor-nav button:first-child').click()`);
await sleep(300);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`));
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(`document.querySelector('.return-button').click()`);
await waitFor(win, `location.pathname === '/dashboard'`);
const collapsedBefore = await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.layout-aside.is-collapsed'))`,
);
@@ -1425,7 +1481,7 @@ async function runPlanEditorChecks(win) {
await sleep(700);
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-workspace.png'),
path.join(__dirname, '../../docs/screenshots/plan-editor-shell.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
@@ -1441,7 +1497,7 @@ async function runPlanEditorChecks(win) {
'学年升级设置与待补跑提示可见且不执行升级',
await waitFor(
win,
`document.body.innerText.includes('每年自动学年升级') && document.body.innerText.includes('总周数')`,
`document.body.innerText.includes('学年升级计划') && document.body.innerText.includes('总周数')`,
),
);
await win.loadURL(new URL('/archives', TARGET_URL).href);
+81 -4
View File
@@ -20,7 +20,7 @@ try {
.readdirSync(migrationRoot)
.filter((name) => fs.existsSync(path.join(migrationRoot, name, 'migration.sql')))
.sort();
assert.equal(names.at(-1), '20261017090000_permission_read_keys');
assert.ok(names.length > 0, '必须存在可回放的迁移');
const baseline = names.indexOf('20261016090000_retire_integration_and_schedule_updates');
await client.execute(
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
@@ -46,7 +46,7 @@ try {
await client.execute(
`INSERT INTO "PermissionGroup" (id,name,updatedAt) VALUES ('custom','教师默认权限',CURRENT_TIMESTAMP)`,
);
const run = (expected) => {
const run = (expected, targetUrl = databaseUrl) => {
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
@@ -56,7 +56,13 @@ try {
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
{
cwd: root,
env: { ...process.env, DATABASE_URL: databaseUrl, DATABASE_PROVIDER: 'sqlite' },
env: {
...process.env,
NODE_ENV: 'test',
JWT_SECRET: 'migrations-isolated-verification-secret-2026',
DATABASE_URL: targetUrl,
DATABASE_PROVIDER: 'sqlite',
},
encoding: 'utf8',
timeout: 30_000,
},
@@ -85,11 +91,82 @@ try {
).rows[0].n,
0,
);
// 第二个夹具模拟本次重构前已初始化的默认组和旧 mode 历史。
const legacyUrl = `file:${path.join(dir, 'lifecycle-legacy.db').replaceAll('\\', '/')}`;
const legacy = createClient({ url: legacyUrl });
try {
const split = names.indexOf('20261023000000_promotion_permission');
assert(split > 0);
await legacy.execute(
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
);
for (const name of names.slice(0, split)) {
await legacy.executeMultiple(fs.readFileSync(path.join(migrationRoot, name, 'migration.sql'), 'utf8'));
await legacy.execute({
sql: 'INSERT INTO "_prisma_migrations" VALUES (?, CURRENT_TIMESTAMP, NULL)',
args: [name],
});
}
await legacy.execute(
`INSERT INTO "PermissionGroup" (id,name,defaultRole,scope,updatedAt) VALUES ('legacy-admin','旧管理员默认组','ADMIN','ALL',CURRENT_TIMESTAMP)`,
);
await legacy.execute(
`INSERT INTO "PermissionGroupItem" (id,groupId,key) VALUES ('legacy-key','legacy-admin','roster:read')`,
);
await legacy.execute(
`INSERT INTO "Student" (id,studentNo,name,updatedAt) VALUES ('legacy-student','LEGACY','旧学生',CURRENT_TIMESTAMP)`,
);
for (const [id, mode] of [
['old-single', 'single'],
['old-out', 'transfer-out'],
['old-restore', 'restore-transfer'],
])
await legacy.execute({
sql: "INSERT INTO \"StudentClassTransfer\" (id,studentId,studentNo,studentName,mode) VALUES (?,'legacy-student','LEGACY','旧学生',?)",
args: [id, mode],
});
legacy.close();
run(names.length - split, legacyUrl);
const upgraded = createClient({ url: legacyUrl });
try {
const keys = (
await upgraded.execute(`SELECT key FROM "PermissionGroupItem" WHERE groupId='legacy-admin'`)
).rows.map((row) => row.key);
assert(keys.includes('roster:read') && keys.includes('promotion:manage'));
const kinds = (await upgraded.execute('SELECT id,kind,batchId FROM "StudentClassTransfer" ORDER BY id'))
.rows;
assert.deepEqual(
kinds.map((row) => row.kind),
['transfer-out', 'restore', 'class-change'],
);
assert(kinds.every((row) => row.batchId === ''));
assert(
!(await upgraded.execute('PRAGMA table_info("StudentClassTransfer")')).rows.some(
(row) => row.name === 'mode',
),
);
assert(
(await upgraded.execute('PRAGMA table_info("ArchivedYear")')).rows.some(
(row) => row.name === 'inactiveCount',
),
);
} finally {
upgraded.close();
}
run(0, legacyUrl);
} finally {
legacy.close();
}
console.log(
'数据库升级回归通过:接纳 Prisma 迁移记录、保留业务课表、移除联动、避开默认组重名、重启保留空权限组。',
'数据库升级回归通过:保留业务课表与空权限组、默认组补升级键、旧 mode 映射 kind、旧行默认值与重启幂等。',
);
} finally {
client.close();
if (
path.dirname(path.resolve(dir)) !== path.resolve(os.tmpdir()) ||
!path.basename(dir).startsWith('classhelper-upgrade-')
)
throw new Error('拒绝清理临时目录以外的路径');
await fsp
.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
.catch((error) => console.error('清理临时测试库失败:', error.message));
+2
View File
@@ -10,7 +10,9 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
JWT_SECRET: 'e2e-isolated-verification-secret-2026',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dir = path.join(root, '.cache', 'lifecycle-verification', `run-${process.pid}-${Date.now()}`);
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
JWT_SECRET: 'lifecycle-isolated-verification-secret-2026',
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
LOG_LEVEL: 'warn',
};
const require = createRequire(path.join(root, 'packages/server/package.json'));
const database = require('@libsql/client').createClient({ url: env.DATABASE_URL });
await database.execute('PRAGMA user_version=0');
database.close();
async function run(args) {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { cwd: root, env, stdio: 'inherit', windowsHide: true });
child.once('error', reject);
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`隔离验收失败:${code}`))));
});
}
await run([
'packages/server/node_modules/prisma/build/index.js',
'migrate',
'deploy',
'--config',
'packages/server/prisma.config.ts',
]);
await run(['packages/server/node_modules/tsx/dist/cli.mjs', 'packages/server/scripts/verify-lifecycle.ts']);
console.log(`生命周期隔离数据库:${dir}`);
+2
View File
@@ -8,7 +8,9 @@ const dir = path.join(root, '.cache', 'v14-verification', `run-${process.pid}-${
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
JWT_SECRET: 'v14-isolated-verification-secret-2026',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
+1 -1
View File
@@ -18,7 +18,7 @@
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
<meta name="ch-version" content="1.4.1" />
<meta name="ch-version" content="1.4.2" />
<link rel="icon" href="assets/icon.png" />
<link rel="apple-touch-icon" href="assets/icon.png" />
+3 -3
View File
@@ -44,7 +44,7 @@
<a class="brand" href="#top" data-press="0.96">
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
<span class="brand-name">ClassHelper</span>
<span class="brand-ver">1.4.1</span>
<span class="brand-ver">1.4.2</span>
</a>
<nav class="topnav" aria-label="页内导航">
<span class="nav-pill" aria-hidden="true"></span>
@@ -93,7 +93,7 @@
</div>
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
<li>v1.4.1</li>
<li>v1.4.2</li>
<li>Windows 10/11</li>
<li>Linux · Docker</li>
<li>触摸屏可用</li>
@@ -733,7 +733,7 @@
ClassHelper
<span>Class Helper</span>
</p>
<p class="foot-ver">版本 1.4.1 · 源码公开 · 装在你自己的机器上</p>
<p class="foot-ver">版本 1.4.2 · 源码公开 · 装在你自己的机器上</p>
</div>
</div>
<nav class="foot-nav" aria-label="相关链接">