44 KiB
ClassHelper 权限组治理与学生年度生命周期重构提示词
用法:在仓库根目录(含
AGENTS.md)把本文件整体交给编码代理。 这不是“加几个按钮”的任务,而是把权限键、数据范围、写入口、年度升级收敛成单一路径。 如果当前会话支持计划确认,先输出差距清单与分阶段计划;确认后逐阶段实现。若为自动执行模式,按阶段直接实现,但每阶段结束必须停下来汇报实际验证输出。
0. 你的角色与工作方式
你是本仓库的高级全栈工程师,熟悉 TypeScript、Express、Prisma、Vue 3、Element Plus 与 pnpm monorepo。
开始前必须按顺序阅读:
AGENTS.md(最高优先级工程约束)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- 本提示词第 2 节列出的源码文件与符号
工作规则:
- 先只读调研,不要先改代码;先给出“已核实差距 + 实施方案 + 风险 + 测试计划”。
- 分阶段提交;每阶段结束运行对应验证命令,汇报本次实际输出,不要引用历史报告数字。
- 不要留下
TODO、空实现、只改前端不改服务端、只改服务端不改 UI 的半截功能。 - 发现本文与代码冲突时,以代码和
AGENTS.md为准,并在汇报中列出冲突点与你的处理方式。 - 代码注释、文档、界面文案、提交描述一律中文;提交使用中文 Conventional Commits。
- 只格式化本次改动文件,不要全仓库跑 Prettier;换行用 LF。
1. 硬约束(不可违反)
- 单一
packages/server/prisma/schema.prisma;保持 SQLite/MySQL 通用,不加 Prismaenum、不加@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始终 ANDactiveStudentWhere();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 目标
权限组:
- 每个权限键至少有一个真实服务端入口;每个受控入口至少有一个权限键。
- 数据范围可配置、可校验、可展示;不再用自由文本猜年级。
- 防止权限管理员自我提权、跨角色分配默认组、越范围授权。
- 权限变更可审计、可预览影响;成员列表不再 N+1。
- Web 菜单/页面/按钮按生效权限门控,而不是继续写死 ADMIN。
学生年度流程:
- 学生状态、班级变更、转出、转入、毕业、升级只有一条受控路径。
- 所有写操作在事务内完成,并写可追溯的历史(操作人、批次、学年、原/新班级与年级)。
- 在读/归档/转出查询语义一致;转出历史必须查得到。
- 年度升级支持可配置学段、逐班预览、阻断项、幂等执行、快照与执行记录。
- 升级不丢学生、不清空未确认的数据;毕业归档包含转出/停用学生。
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 |
现有守卫 | 不变 |
强制规则:
- 新增键必须同步:
PERMISSION_KEYS、PERMISSION_LABELS、PERMISSION_CATEGORIES、PERMISSION_DEFAULT_ROLES、ROLE_DEFAULT_PERMISSIONS(派生即可)、必要的PERMISSION_MATRIX。 - 旧库迁移:
ensureDefaultGroups()的update: {}不会给旧库默认组补新键;promotion:manage必须通过迁移插入已有的 ADMIN 默认组(用INSERT ... SELECT ... WHERE defaultRole='ADMIN',旧库尚未创建默认组时允许 0 行)。新库由ROLE_DEFAULT_PERMISSIONS.ADMIN自然包含;随后ensureDefaultGroups()新建的 ADMIN 默认组也会包含该键。 - 删除装饰键:如果某个键最终找不到任何真实入口,就删除它并更新迁移/文档,不要保留“看起来能用”的键。
- 班级端兼容:凡是班级端会调的读/写路由,用
requireStaffPermission或显式isClassDevice分支;不要因为加了requirePermission把班级端挡死。改完必须跑桌面客户端相关验收。 - 页面门控:
students用permission: 'roster:read',classes用class:read,archives用archive:manage,term-weeks用term:read;写按钮再用auth.can('...:manage')隐藏。路由守卫与服务端都要检查,前端隐藏不能代替鉴权。
4.2 服务端断言分层
统一为三层,顺序不能反:
- 路由层:
requirePermission(员工必须持有键,班级端必被挡)、requireStaffPermission(班级端放行,员工查键)、requireAnyPermission(读或写任一即可)。 - 服务层权限断言:
assertPermission(user, key)/assertAnyPermission(user, keys);不要在 service 里继续写user.role === 'ADMIN'来代替已有键。 - 业务关系断言:
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 防提权与审计
必须实现:
- 默认组只能给同角色:
PermissionGroup.defaultRole非空的组,只能分配给相同User.role的账号;ADMIN 默认组不能分配给 TEACHER。显式分配教师默认组给教师是允许的(用于“保留默认 + 追加权限”)。 - 不能授予自己没有的键:非 ADMIN 的
permission:manage账号,创建/修改组时提交的键集合必须是其生效权限的子集;修改已有组时,组内现有键也必须是其子集;否则 403。 - 不能分配超出自己的组:非 ADMIN 只能把“组内所有键都在自己权限内”的组分配给别人;不能给自己分配会新增权限的组。
- 范围提权:非 ADMIN 不得创建/修改/分配
scope=ALL;GRADE/DIVISION组只允许 ADMIN,或范围覆盖目标年级的账号(最简实现:仅 ADMIN 可管理非SELF_CLASS组)。 - 默认组编辑:ADMIN 默认组、TEACHER 默认组影响所有未分组账号,只有 ADMIN 可改;非 ADMIN 的权限管理员只能改自定义组。
- 内置组:仍不可删除;改名/改键按上述规则。
- 审计:新增
PermissionAudit表(id、actorId、actorName、action、targetType、targetId、beforeJson、afterJson、createdAt),记录组的增删改、成员分配、默认组修改。同步 migration 与SNAPSHOT_TABLES。提供分页读取接口(permission:manage)和权限页“变更记录”Tab。 - 成员列表:
listMembers一次预取所有组 + 默认组,内存计算每个账号的生效键;PermissionMemberDto增加permissions: PermissionKey[]与scopeLabels,fixed要么删除,要么只对真正不可分配的账号为 true;不要保留永远 false 的字段。 - 组详情:新增
GET /permissions/groups/:id/members或在组列表 DTO 带成员摘要;权限页可展开查看成员,不再只有数量。 - 保存前预览:前端在编辑组/分配组时展示“将影响 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 年度升级计划
不要把 高一/高二/高三 写死在服务里。使用“轨道 + 有序学段”:
{
"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时旧周次会继续覆盖新学年。
执行顺序(单事务,快照成功后才开始):
- 计算并校验计划与预览;写入/更新
PromotionRun(status='running')。 - terminal 学段:按
enrollmentYear分组创建/校验ArchivedYear;班级写archivedYearId/archivedAt;所有学生按状态处理并写archivedYearId/archivedAt:active → graduated,transferred保持,inactive保持。统计studentCount/transferredCount/inactiveCount/classCount/homeworkCount/notificationCount。 - 非 terminal 学段:只改
grade为下一学段;清空Class.termStartDate;保留班级/学生/作业/成绩。 - 每条轨道起始年级:按
entryClassCount新建骨架班;enrollmentYear=year、classIndex=1..N、grade=起始年级、teacherId=null、无课程;班级码必须唯一;termWeeks取默认值并受全校上限约束。 - 学期:
term.startDate设为计划日期(默认${year}-09-01)或计划中的termStartDate;resetTermWeeks=true时删除TermWeek(预览已明确行数),否则保留并警告;新班termStartDate=null。 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重写、DTOscopeLabel。 - 实现 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. 验证命令
按改动范围至少执行:
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. 交付物与完成定义
每次汇报必须包含:
- 本阶段改动文件清单与一句话说明。
- 迁移说明:新表/新列、旧库兼容、
SNAPSHOT_TABLES变更、是否需要管理员补权限。 - 实际执行的验证命令与输出摘要(通过/失败数、失败原因)。
- 行为变化与兼容性:旧前端、班级端、离线客户端、旧权限组。
- 风险与未完成项;不允许用“应该可以”代替验证。
完成定义:
- 第 6 节清单全部通过,或明确列出未通过项及原因、影响、后续任务。
- 没有装饰性权限键、没有第二条毕业路径、没有硬编码三个年级、没有绕过生命周期的写入口。
- 所有新增/修改接口在
docs/dev/api.md有边界说明;所有新增权限键在docs/dev/permissions.md有归属说明。
9. 如果只能做最小闭环
按以下顺序交付,仍须保证每步可运行:
- 修 S1–S7(状态机、事务、历史查询、复活、删除保护)。
- 接通 P1–P5(权限键、班级端守卫、页面门控、
promotion:manage)。 - 升级预览/执行正确性(S8–S13),暂不做多轨道 UI 的完整编辑,只保留默认高中轨道 + 只读计划展示。
- 最后做 P6–P11(防提权、审计、权限页预览)。
不要为了“最小闭环”保留已知的数据不一致;宁可缩小 UI 范围,也不要保留两条写路径。