From ead66f0b93382dd205245c3288817d59ef9588e5 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 13:04:22 +0800 Subject: [PATCH] =?UTF-8?q?test(docs):=20=E8=A1=A5=E9=BD=90=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E9=AA=8C=E6=94=B6=E4=B8=8E=E5=8A=9F=E8=83=BD=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 61 +-- docs/dev/20261017-implementation.md | 74 ++++ docs/dev/permissions.md | 31 +- docs/dev/testing.md | 8 +- docs/dev/toc.yml | 3 + docs/reference.md | 31 +- package.json | 3 +- packages/desktop-client/scripts/smoke.mjs | 3 +- packages/server/scripts/verify-e2e.mjs | 27 +- packages/server/scripts/verify-v14.ts | 480 ++++++++++++++++++++++ scripts/ui-smoke/main.cjs | 50 +++ scripts/verification-server.mts | 18 + scripts/verify-e2e-isolated.mjs | 113 +++++ scripts/verify-v14.mjs | 37 ++ 14 files changed, 899 insertions(+), 40 deletions(-) create mode 100644 docs/dev/20261017-implementation.md create mode 100644 packages/server/scripts/verify-v14.ts create mode 100644 scripts/verification-server.mts create mode 100644 scripts/verify-e2e-isolated.mjs create mode 100644 scripts/verify-v14.mjs diff --git a/AGENTS.md b/AGENTS.md index bad8ea8..fde2d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,17 @@ **2026-10-16 更新**:当前仓库位于 `F:\ClassHelper`,F 盘可用。ClassIsland 联动插件、设备令牌、提醒通道与镜像接口已退役;离线课表 JSON 导入和界面设计参考保留。下文历史联动部署记录不再适用。 客户端维护只走 `/api/clients/*`,页面为 `ClientsView.vue`;默认权限按数据库中对应角色的默认组生效。 +**2026-10-17 本批功能约定**:课表业务真值为 `ClassPlan / ClassPlanEntry / TimeLayoutItem`, +旧 `Schedule` 仅保留迁移审计来源,`/api/schedules/*` 是新模型的兼容门面。统一解析器在 +`lib/class-plan.ts`,不得在客户端另写在线启用规则。自定义岛方案在本机 `config.json.islandLayout`, +默认 `layout=fixed`;新尺寸上报仅在 custom 模式执行。详见 `docs/dev/20261017-implementation.md`。 + **2026-10-17 更新**:日常 `verify:desktop` / `verify:packaged` 默认是后台快速检查,不创建可见灵动岛或托盘; 完整桌面动画、透明度与截图验收必须显式加 `--visual`(`pnpm verify:desktop:visual`)。 下文关于置顶验证窗口、鼠标穿透与截图时序的历史约定仅适用于视觉模式。不要为了日常修改反复占用用户桌面。 联网冒烟显式启用,使用临时教师和班级;禁止回退到真实班级、重置其密码或向其发送测试通知。 +本次 PowerShell 会话的 PATH 不含 pnpm;受限工具进程读取启动器可能需要工具级提权,不能据此判断未安装。 本次 PowerShell 会话的 PATH 不含 pnpm:需要先把 `C:\Users\hck16\AppData\Local\deepseek-harness\bin` 加到 PATH 前面,再按下文设置 `DSH_NODE` 和 `DSH_PNPM_BIN`。Git Bash 外层找不到 pnpm 时可执行: @@ -132,7 +138,7 @@ website/ 产品官网(**纯静态、无构建步骤**, -x64-server-setup.exe),它们是离线兜底链接的唯一依据。 packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts packages/server/ - prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) + prisma/schema.prisma 数据模型(29 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是 2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query / add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks; @@ -287,7 +293,7 @@ ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js | `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 | | `pnpm db:studio` | Prisma Studio | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) | -| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-06 实测 205 项全过) | +| `pnpm verify:e2e` | 后端端到端验收(自动创建隔离库与临时账号,禁止外网;243 项) | | `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) | | `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | | `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) | @@ -568,11 +574,10 @@ CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublish 不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。 **只有一份 `schema.prisma`**:切库是改写它的 `provider`(`pnpm db:switch:mysql`),没有 `schema.mysql.prisma`。 改模型要同步 `prisma/migrations/`(本机用 `pnpm --filter @classhelper/server db:deploy` 应用)。 -8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`, - `MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20,**上限也是 30**)。别在别处重新实现周次换算。 - 周次上限三处必须一致:`MAX_TERM_WEEK`、`lib/schemas.ts` 的 `weekNumberSchema`、`classes.schemas.ts` 的 `termWeeks` - (曾经班级能设到 40 而课表只到 30,等于对外承诺了系统兑现不了的学期长度)。 - `Class.termWeeks` 只有**管理员**能改(`assertCanManageClasses`),班主任不行。 +8. **周次口径唯一。** 上限来自 `AppSetting.term.maxWeeks`(管理员设置,默认 30、范围 1–60)。 + 服务端统一 `await getMaxTermWeeks()`;shared 的同名函数负责数值归一化与离线默认值。 + 当前周仍走 `loadTermContext / resolveCurrentWeek`,优先逐周区间;班级 `termWeeks` 不得超出全校上限, + 仅管理员能改。取消固定 30 的原因:学校需要不同长度的学期,不能只放开某一个输入框。 9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**; 主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。 @@ -1286,24 +1291,13 @@ CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublish 在线判定窗口 `CLIENT_ONLINE_WINDOW_MS`=90 秒。三条边界:**没有 token 就不上报**(未登录静默跳过)、 **401/403 退避 5 分钟**、**冒烟默认不上报**(要验这条链路才设 `ELECTRON_SMOKE_CLIENT_AGENT=1`)。 -69. **远控命令要两端各确认一次,而且客户端**必须**回执。** 服务端 `POST /api/clients/commands` 只对 - `remote:control`(默认仅 ADMIN)开放,动作限 `shutdown`/`restart`/`logoff`/`lock`,有效期 - `CLIENT_COMMAND_EXPIRE_MS`=10 分钟;客户端收到后弹**默认选中「取消」**的对话框(60 秒未确认按取消处理), - 执行只支持 Windows(`shutdown.exe /s|/r /t 0`、`/l`、`rundll32 user32.dll,LockWorkStation`), - 然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。 - 同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。 - - **`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修): - `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 —— - 只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。 - 现在等待进程退出并检查退出码,失败如实回执;关机/重启延迟 5 秒留出回执时间。 - - **「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白, - 而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。 - 所以 `GET /clients/devices` 一并返回 `lastCommand`(最近一条,不论状态), - 「客户端」页把它翻成一句人话(`ClientHubView` 的 `commandStateText`)。 - 排查这类反馈时先看这一列,再看那台机器的**客户端版本**是否 ≥ 1.3.0 —— - 远控命令的处理(`client-agent.ts`)是 1.3.0 才有的,旧客户端连心跳都不发。 +69. **远控收到即执行,而且客户端必须回执。** 2026-10-17 按产品要求取消本机二次确认, + 使管理员操作不再因教室无人确认而停住。服务端仍须 `remote:control`,动作仅限 + `shutdown/restart/logoff/lock`,有效期仍为 `CLIENT_COMMAND_EXPIRE_MS`(10 分钟)。 + 客户端持久去重,失败只重试回执;回执带 `deviceKey` 验证归属、重复幂等。 + `executeCommand` 仍监听子进程 error、等待 exit 并检查退出码,关机/重启延迟 5 秒留出回执时间。 + 冒烟始终使用假执行器,不得执行真实关机/重启/注销/锁屏。`lastCommand` 与「上次指令」保留, + 用于显示执行结果。 70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP (`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。 @@ -1377,12 +1371,29 @@ CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublish 原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支), 等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了, 只显示状态)。成绩查询开关仅教师/管理员可改。 + 2026-10-17 改为关闭后页面仍正常打开,`/grades/overview` 只返回服务端集体聚合;个人明细、 + 全班原始成绩与排名仍拒绝读取。不得把原始成绩发给班级端后再聚合。少于 5 名学生的聚合、 + 稀疏分段及其补集均抑制;不提供最高/最低分,避免小样本反推个人。原因是关闭个人查询不应关闭教学反馈。 82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。** 原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。 现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」, 逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。 +83. **自动学年升级替代手动归档入口。** 每年时间存 `term.promotionAt`,事务幂等键为 `PromotionRun.year`; + 升级前复用 snapshot 导出到 `data/promotion-snapshots`,失败回滚并留审计。运行中到点自动执行; + 启动发现漏跑先提示管理员预览并补跑,不能在开发机启动时擅自升级演示数据。 + 高三归档,高一/高二升一级;新高一仅建班级骨架,`Class.teacherId` 可以为空,不复制任课关系。 + 取消手动归档的原因是统一年度时序并保证快照与幂等,归档结果和转出记录仍可查看。 + +84. **转出过滤唯一入口是 `lib/students.ts`。** 名单、成绩、作业提交、通知已读、叫人及导入匹配 + 共用 activeStudentWhere/isActiveStudent,不能只在前端藏一行。学号永不复用;归档页可撤回转出并保留历史。 + +85. **岗位组不新增 User.role。** 班主任/教师/级长/部长均为 TEACHER,内置组的 defaultRole 为空。 + PermissionGroup.scope/scopeValue 表示 SELF_CLASS/GRADE/DIVISION/ALL,部以逗号分隔年级,空范围拒绝。 + 查询范围在服务端统一收口;设备写操作按对应权限键的分组范围判定,不能借别的只读组扩权。 + client:update 与 timetable:manage 默认只给 ADMIN,科目写权限继续以 Course.teacherId 为准。 + ## 6. 代码风格 - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** diff --git a/docs/dev/20261017-implementation.md b/docs/dev/20261017-implementation.md new file mode 100644 index 0000000..2ecb580 --- /dev/null +++ b/docs/dev/20261017-implementation.md @@ -0,0 +1,74 @@ +# 2026-10-17 实施与验收记录 + +## 阶段 + +1. 数据与共享契约:关系化时间点、日课表、群组、预定层;权限范围与内置组;周数设置。 +2. 服务端:唯一课表解析器与兼容门面、事务编辑;转出过滤、聚合成绩、自动升级、下载状态。 +3. Web:时间表/日课表编辑、只读名单、日期归档、设置与进度。 +4. 客户端:心跳重置、立即远控(冒烟假执行)、更新进度、本机组件方案与编辑器。 +5. 验证与文档:临时数据库验证、静态检查、后台冒烟;明确记录未通过与未执行项目。 + +## 架构选择 + +- 不引入 Profile:共享时间表与班级日课表替代单机档案。 +- 旧 Schedule 保留迁移来源,通过兼容门面投影新模型;迁移保留起止周、单双周与地点,无法从旧数据恢复原来的群组、临时层或时间点名称。 +- 范围保存在 PermissionGroup,多个分组取范围并集;部用逗号分隔的年级字符串,空范围不放行。 +- 周次上限取 AppSetting,默认 30、允许 1–60;离线取默认。 +- 岛方案保存在本机 config.json;天气无离线数据源,本轮不做。 +- 自动升级以年份作事务幂等键,升级前存全库快照;新学年保留周数上限、开学日重置为当年 9 月 1 日,清除旧逐周区间。新班不复制任课关系。 + +## 实测记录 + +- `pnpm lint`:0 error、0 warning;全仓类型检查、完整构建、图标门禁与客户端产物凭据检查通过。 +- `pnpm verify:e2e`:243/243。根入口改为全新临时 SQLite 库与带前缀账号,禁止外网;不会连到本机真实服务。 +- `pnpm verify:v14`:35/35。覆盖四周轮换、四种时间点、外键排序与下一节时间、跨班课程、换课不改原表、预定优先级、集体成绩与实时隐私、转出学生的名单/成绩/叫人/未交入口、四岗位越权、41 周生成、兼容 CRUD、上游导入幂等、自启动假适配器。年度升级另在专用临时库并发运行两次,确认同年幂等、快照存在、其余班只升一级、新班无班主任与任课关系。 +- `pnpm verify:web`:43/43。包含新增日课表整表保存、时间表编辑入口、自启动不可用原因、升级设置与转出分区;全程隐藏、无焦点。 +- 桌面快速检查 24/24;视觉检查 99/99,并通过退出后的第二次快速启动 24/24。均使用独立 userData,远控与安装器不会执行系统操作。 +- 文档站严格构建通过,0 warning、0 error。 +- 原库迁移前备份:`.cache/dev-before-v14-20261017092353.db`。先在副本验证,再应用本机迁移。用户 3、班级 5、学生 18、课程 53、旧课表 89、成绩 98、作业 16、通知 23 的行数全部保留;89 条旧课表均有关系化映射,生成 450 张按周日课表,外键检查 0 异常。历史 JSON 时间表的 3 个时间点也全部补齐。 +- 视觉截图:`.cache/v14-island-shots/`(固定模式各形态、窗口全图、`custom-editor.png`、`custom-alert.png`);Web 手机截图:`.cache/v14-web-shots/`。它们是本次验收留档,不作为官网素材。 +- 未打包、未改版本、未改 releases;未真实修改注册表/systemd,未在业务库或演示库执行年度升级,未真实执行关机/重启/注销/锁屏。自启动安装形态使用可注入的系统适配器检查开关与重建控制器后回读。 + +## 两处 bug 的定位 + +### 换服务端后迟迟不上报 + +旧 `client-agent.ts` 在收到 401/403 后把唯一计时器排到五分钟后,配置保存/登录成功没有事件重排,旧 token 又可能继续保留。用两个本机假服务端复现:第一个明确返回 401,切换第二个后必须清空旧 token,登录新 token 后自动恢复心跳。修复为四个显式配置事件、状态清空与 epoch 隔离;迟到响应不能覆盖新实例状态。设备 key 不重建,命令回执固定发回接收命令时的地址。 + +### 自启动入口与状态不一致 + +旧 Web 没有调用现有自启动 API 的可达设置页。Windows 服务端还从 `dirname(process.execPath)` 找 `start.cmd`,安装包里的 Node 在 runtime 子目录,而安装器把脚本放在安装根,导致错误地识别为开发环境。旧状态只检查 Run 项存在,没有核对值。现在从服务端目录定位安装根,并校验安装器的 InstallDir,比较完整 Run 值;写入后重新读取并校验。开发/免安装/容器环境明确返回不可用原因。Web 实测开发环境按钮禁用且原因可见,安装形态开关与回读由假系统适配器验证。 + +## 迁移与兼容的取舍 + +- 采用新模型投影的兼容门面,不双写业务表。旧 Schedule 与 TimeLayout.items 只作迁移审计来源,后续编辑不会再读取它们复活被删除的课程。种子初始化另有一次物化过程。 +- 旧行按实际教学周展开,因此不同周次范围可以共存;同班同日、同周、完全相同起止时间的旧冲突记录只取最小 id。旧数据没有的群组/临时层/时间点名称无法恢复;冲突原行保留可追溯。 +- 老时间配置导入同样写关系化时间点,并要求 timetable:manage;同起点但不同类型的课间和课程保留独立外键。整套 ClassIsland 课表导入仍可由本班班主任使用,只建立班级自有作息,不把 TeacherName 当成任课授权。 +- 上游导入直接保留 1–9 周三元组、四种时间点、空课、停用、换课标记、群组、预定和临时选择,不经单双周中间格式。稳定导入 id 使 merge 重复导入不复制整套课表。 +- 全校周数默认 30、上限 60,由 shared 取值函数统一归一化;旧 MAX_TERM_WEEK 仅保留为弃用的外部兼容导出,内部不再使用。 +- 不引入 Profile、天气、.NET 或插件依赖;部使用 PermissionGroup.scopeValue 的逗号分隔年级,空范围不放行。范围并集不会替代 Course.teacherId 的科目写权限。 + +## 对两项架构判断的答复 + +本机方案存 config.json 的决定予以保留:这是设备个体偏好,不增加服务端配置或同步通道。通知/作业/叫人使用同一个 IslandApp.vue 实例及原状态机,custom 模式仅通过 Teleport 把它放到组件槽;不是固定层盖在自定义层上,也没有第二个岛窗口。主要行禁用提醒时回落到允许提醒的第一行;全部禁用时不展示提醒槽。多种内容仍服从原队列/列表规则,不会在多个位置重复投递。 + +## 十项自检 + +1. 第三周周三第二节:ClassPlan 的规则与 ClassPlanEntry 的 timeLayoutItemId → `resolveActiveClassPlan` → `loadTermContext/resolveCurrentWeek` → `projectPlan` → `/schedules/active-week` → `refreshIslandSchedule` 的整周 IndexedDB 缓存 → `recomputeClassState` 发送当天课程 → `ComponentNode.vue` 课程表组件。 +2. 编辑时间点起止时,所有引用该 TimeLayoutItem 外键的课随之改变;只排序改变 position,不改变外键。旧单节时间编辑会先克隆班级日作息,避免误改共享时间表。 +3. 拖到第二行后,保存到 `/config.json` 的 `islandLayout.schemes[].rows[].nodes`;仍由 config.ts 的内存缓存、120ms 合并写盘和退出 flush 管理,重启经 getConfig/白名单桥读回。 +4. 新提醒优先进入 main 且 allowNotifications 的行,否则第一条允许提醒的行;在该行对应内容组件的槽展示,没有对应组件时使用该行默认提醒槽。 +5. fixed 不挂载 CustomIsland,主进程 reportCustomSize 也先检查 customLayout;原固定窗口、形变路径和默认外观值保留。 +6. 关闭个人查询后 overview 直接返回聚合 DTO:没有学号、姓名、个人 id、排名或极值;个人明细/排名接口被服务端拒绝。实时成绩广播同样只发 classId 与 collective 刷新标记,避免经 Socket.IO 绕过 HTTP 闸门。少于五人隐藏均值,均值取整,小于三条的分段及可反推补集的比率不披露;前端也清空已有个人数据。 +7. 8 月 3 日启动时识别最近应执行学年缺记录,写警告并在学期周次页显示预览/补跑;不会静默升级业务库。运行中的服务跨过计划时间才自动执行。年份唯一键与事务保证重入只提交一次,执行前先完成快照。 +8. 一处 activeStudentWhere/isActiveStudent 规则,应用于名单/班级详情/成绩/作业状态/叫人/通知已读/导入匹配/班级会话与 AI 叫人选择。历史归档/转出专页显式读取历史状态。漏一个入口会造成历史身份重新被选中或历史个人成绩外露,因此回归有服务端负断言。 +9. 级长的班级列表与接口断言先经 access.ts → permission-scope.ts 取 GRADE/DIVISION 范围,越界接口返回 403;前端菜单和班级筛选只用于展示。科目内容仍由任课关系独立裁决。 +10. 地址变化 → server-changed 清 token/失败/退避与待排计时器 → token-changed/login 立即排心跳 → 新服务器鉴权成功后按稳定 deviceKey 登记 → lastHeartbeatAt 写入并进入在线列表。epoch 丢弃旧请求的迟到结果。 + +## 验收中修复的边界 + +- 兼容投影保留单双周元数据;清理测试导入时删除整张测试日课表,空课表不会被隐式回退。 +- Windows libsql 同库连接探测复用当前活跃连接;此前完整回归在跨适配器探测处出现 0xC0000005,修复后完整离线回归通过。临时库目录额外带时间戳,避免进程号复用撞到此前的验收库。 +- 新旧课表界面与岛桥接共用的缓存仍保存整周数据,岛组件只接收当天投影,避免当天响应覆盖整周离线缓存。 +- 最终复跑曾遇到本机冷启动超时:桌面第一次界面无响应,原样重跑在已通过的检查之后被旧 45 秒总限时中止;临时后端也超过了原 10 秒就绪等待。外层预算分别调整为 90 秒与 60 秒,心跳 30 秒等单项业务断言不变。 + 调整后最终复跑通过后端 243/243 与桌面 24/24(含二次启动),桌面整套实际耗时 20.6 秒。 diff --git a/docs/dev/permissions.md b/docs/dev/permissions.md index 8378288..33a17b1 100644 --- a/docs/dev/permissions.md +++ b/docs/dev/permissions.md @@ -74,7 +74,7 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏 ## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板) 从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是 -`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(37 个,形如 `domain:action`, +`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(形如 `domain:action`, 中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是 ```ts @@ -177,7 +177,34 @@ assertCanManageGrades / assertCanManageSubjectContent 离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。 客户端关闭自动更新时,任何下发策略都不会强制安装。 -远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。 +远控由管理端确认下发;客户端立即执行白名单动作并回传真实执行结果。 联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。 服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。 + +## 日课表与岗位范围(2026-10-17) + +| 能力 | 新权限键 | 默认角色 | +| -------------------------- | ------------------ | -------- | +| 全校时间表与时间点管理 | `timetable:manage` | ADMIN | +| 安装包、目标版本与安装策略 | `client:update` | ADMIN | + +`client:update-push` 只保留旧配置兼容标识;下发与安装包写入口统一检查 `client:update`。 +新增权限同步出现在 shared 的键目录、标签、类别与默认角色表;升级迁移为管理员默认组补齐新键。 + +| 内置教师岗位组 | 默认功能 | 数据范围 | +| -------------- | ---------------------------------------- | -------------------------------- | +| 班主任 | 教学内容 + 查看/管理设备,不含版本下发 | 自己担任班主任的班级 | +| 教师 | 作业、成绩、通知、叫人、课表与名单读取 | 班主任与任教班级 | +| 级长 | 设备、远控、名单/班级/课表/成绩/看板读取 | 管理员配置的年级 | +| 部长 | 同级长 | 管理员配置的一组年级(逗号分隔) | + +四组 `builtin=true`、`defaultRole=null`;账号仍是 TEACHER。实际范围来自分组并集, +空年级/部范围不授权。通用查询通过 `teacherScopeWhere/resolveClassScope`, +设备写入还只合并含对应功能键的分组范围。管理员的 `permission:manage/system:manage` 不可剥夺。 +范围从不赋予科目编辑权,仍须 `assertCanManageSubjectContent` 检查 Course 任课关系。 + +教师名单页复用学生管理视图与导出,路由 `roster:read`,隐藏编辑/调班/导入操作;服务端过滤范围和转出学生。 +成绩关闭个人查询时只返回服务端聚合;班级端无个人明细、排名或极值,稀疏样本抑制,教师/管理员不受该个人查询开关影响。 + +旧时间表导入接口的写入/删除也要求 `timetable:manage`,避免绕过统一时间表权限;离线整套课表导入仍可由本班班主任使用,创建班级自有时间点。 diff --git a/docs/dev/testing.md b/docs/dev/testing.md index cc5e4f4..b13f839 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -36,8 +36,8 @@ description: 验收验收脚本怎么跑、覆盖什么、哪些用例是脆弱 pnpm lint # ESLint,0 error 是底线 pnpm typecheck # 全仓库类型检查(含 vue-tsc) -pnpm dev:server # 另开一个终端 -pnpm verify:e2e +pnpm verify:e2e # 自建全新隔离库与临时账号,不依赖真实服务 +pnpm verify:v14 # 新课表/权限/年度升级专项隔离验收 pnpm build:desktop && pnpm verify:desktop pnpm build && pnpm verify:web @@ -125,3 +125,7 @@ ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \ > 采集脚本会把窗口真的弹在屏幕上。客户端留档会**临时停掉 CSS 动画**再截图 —— > 窗口被遮挡时 Chromium 冻结动画,而页面入场动画的起始帧是 `opacity: 0`, > 不停掉的话截出来是**整片空白**(DOM 里文本齐全,只有图是空的)。 + +## V1.4 专项验收 + +`verify:e2e` 由根启动器创建独立数据库、带前缀的临时账号与服务;服务禁止外网,请勿改回真实班级。`verify:v14` 在专用临时库验证新模型与自动升级幂等。详细结果、迁移保留情况和十项自检见 [实施记录](20261017-implementation.md)。 diff --git a/docs/dev/toc.yml b/docs/dev/toc.yml index d0df582..c97d38e 100644 --- a/docs/dev/toc.yml +++ b/docs/dev/toc.yml @@ -20,3 +20,6 @@ href: review-2026-10-17.md - name: 版本迭代与发布 href: release.md + +- name: 2026-10-17 实施与验收 + href: 20261017-implementation.md diff --git a/docs/reference.md b/docs/reference.md index 409f547..5ccf76c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -675,7 +675,7 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) - **课表来源字段**:统一由管理端维护;历史同步课表在升级时保留并归为 `manual`。 > **学期周数(管理员可调)**:`Class.termWeeks`(默认 20,上限 30)由管理员在「班级管理 → 编辑班级」设置, -> 上限与 `MAX_TERM_WEEK`、`weekNumberSchema` 三处一致;`GET /api/dashboard/term?classId=` 返回该班的 `maxWeek`。 +> 上限统一经 `getMaxTermWeeks()` 读取全校配置(默认 30、允许 1–60),服务端动态校验;`GET /api/dashboard/term?classId=` 返回该班的 `maxWeek`。 > > 手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补列: > `node packages/server/scripts/apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"`; @@ -1555,7 +1555,34 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列 离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。 客户端关闭自动更新时,任何下发策略都不会强制安装。 -远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。 +远控仍由管理端确认下发;客户端收到后立即执行白名单动作,并按系统退出码回执。 联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。 服务端兼容旧客户端领取修复包;第一次升级仍按旧版空闲条件安装,新策略需修复版客户端。 + +## 2026-10-17 课表与组件机制对照 + +上游现场参照:[ClassPlan 模型](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Shared/Models/Profile/ClassPlan.cs)、 +[LessonsService 的启用解析](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Services/LessonsService.cs)、 +[组件文档](https://docs.classisland.tech/app/component/)。复刻能力与语义,不引入 .NET 或已退役插件。 + +| 需求 | 实现入口 | 验收入口 | +| -------------------------------------------- | --------------------------------------------- | --------------------------------------- | +| 日课表、1–9 周轮换、四种时间点、显式位置外键 | 时间表与课表编辑页、class-plans 模块 | `packages/server/scripts/verify-v14.ts` | +| 换课/临时层/预定/群组,原表不变 | schedules 与 class-plan-groups | 同上 | +| 本机多行、多方案、组件独立设置与触摸拖动 | 设置→灵动岛→编辑布局;托盘同名入口 | `verify:desktop` 的组件布局用例 | +| 容器、规则隐藏、四档字体与外观覆盖 | 编辑器右侧设置 | 同上 + 显式视觉回归 | +| 通知/作业/叫人组件 | 主要且允许通知的行,复用 IslandApp 卡片与操作 | 原有固定模式回归 + 组件布局回归 | +| 换服务端重置退避与令牌 | config-events / client-agent | 本地假服务端心跳用例 | +| 即时远控与回执 | clients / client-agent | 冒烟假执行器;不调用系统命令 | +| 下载与安装阶段进度 | 客户端管理页/桌面关于页 | 进度 GET、ADMIN 房间事件、IPC | +| 集体成绩与转出过滤 | grades / lib/students | 临时数据库隐私与范围负断言 | +| 自动学年升级与可配周数 | 学期周次页 | dry-run、快照与事务幂等 | +| 自启动按钮与日期归档 | 系统设置页/通知日期选择器 | 注入系统适配器、Web 回归 | +| 四种内置岗位组与只读名单 | 权限组页/学生名单页 | 跨年级、跨部 403 | + +天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 +迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, +迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。 + +实施选择、问题根因与实测结果见 [实施记录](dev/20261017-implementation.md)。 diff --git a/package.json b/package.json index 4a18288..6ab3920 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "db:studio": "pnpm --filter @classhelper/server db:studio", "db:switch:mysql": "node scripts/use-database.mjs mysql", "db:switch:sqlite": "node scripts/use-database.mjs sqlite", - "verify:e2e": "pnpm --filter @classhelper/server verify:e2e", + "verify:e2e": "node scripts/verify-e2e-isolated.mjs", + "verify:v14": "node scripts/verify-v14.mjs", "verify:client-agent": "node scripts/verify-client-agent.mjs", "verify:review-client": "node scripts/verify-review-client.mjs", "verify:review-api": "node scripts/verify-review-api.mjs", diff --git a/packages/desktop-client/scripts/smoke.mjs b/packages/desktop-client/scripts/smoke.mjs index 2a3e1f5..cc7ac6d 100644 --- a/packages/desktop-client/scripts/smoke.mjs +++ b/packages/desktop-client/scripts/smoke.mjs @@ -84,7 +84,8 @@ function launchSmoke(extraEnv) { child.kill(); resolve(124); }, - mode === 'visual' ? 360_000 : 45_000, + // 快速套件已包含本地 HTTP、校验下载和布局编辑;保留冷启动余量,单项断言不放宽。 + mode === 'visual' ? 360_000 : 90_000, ); child.once('exit', (code) => { clearTimeout(deadline); diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 58ff209..a5fec90 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -8,8 +8,7 @@ * 5. 课表按周查询可用 * * 用法: - * 1) 另开终端启动后端:pnpm dev:server - * 2) 执行:pnpm verify:e2e + * 执行 pnpm verify:e2e;根启动器自动创建隔离数据库和带前缀账号 * * 环境变量: * VERIFY_BASE_URL 默认 http://127.0.0.1:4000 @@ -423,6 +422,12 @@ async function main() { const removed = await api(`/schedules/${item.id}`, { method: 'DELETE', token: teacherToken }); if (removed.status === 200) removedSchedules += 1; } + // 日课表为空仍会生效;清理本次导入的整张日课表,不能靠空表自动回落。 + const importedPlans = await api(`/class-plans?classId=${classId}`, { token: teacherToken }); + for (const plan of importedPlans.payload?.data ?? []) { + if (['周一-单周', '周一-双周'].includes(plan.name)) + await api(`/class-plans/${plan.id}`, { method: 'DELETE', token: teacherToken }); + } const allCourses = await api(`/courses?classId=${classId}`, { token: teacherToken }); let removedImportedCourses = 0; for (const course of allCourses.payload?.data ?? []) { @@ -2132,12 +2137,12 @@ async function main() { const layoutImport = await api('/imports/time-layout', { method: 'POST', - token: teacherToken, + token: adminToken, body: { classId, name: '默认时间表', mode: 'replace', payload: sampleLayout }, }); const layoutId = layoutImport.payload?.data?.layout?.id ?? null; record( - '班主任导入本班时间配置(replace)', + '管理员导入班级时间配置(replace)', layoutImport.status === 200 && layoutImport.payload?.data?.layout?.items?.length === 3, `status=${layoutImport.status} 节数=${layoutImport.payload?.data?.layout?.items?.length}`, ); @@ -2151,7 +2156,7 @@ async function main() { const badImport = await api('/imports/time-layout', { method: 'POST', - token: teacherToken, + token: adminToken, body: { classId, name: '默认时间表', @@ -2169,7 +2174,7 @@ async function main() { const mergeImport = await api('/imports/time-layout', { method: 'POST', - token: teacherToken, + token: adminToken, body: { classId, name: '默认时间表', @@ -2630,7 +2635,7 @@ async function main() { if (layoutId) { const layoutDeleted = await api(`/imports/time-layout/${layoutId}`, { method: 'DELETE', - token: teacherToken, + token: adminToken, }); record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`); } @@ -3220,6 +3225,14 @@ async function main() { 'ClientCommand', 'ClientRelease', 'AiAudit', + 'TimeLayoutItem', + 'ClassPlanGroup', + 'ClassPlan', + 'ClassPlanEntry', + 'ScheduledClassPlan', + 'PromotionRun', + 'TermWeek', + 'Exam', ]; const dbStatus = await api('/database/status', { token: adminToken }); const dbStatusData = dbStatus.payload?.data ?? {}; diff --git a/packages/server/scripts/verify-v14.ts b/packages/server/scripts/verify-v14.ts new file mode 100644 index 0000000..49dffd5 --- /dev/null +++ b/packages/server/scripts/verify-v14.ts @@ -0,0 +1,480 @@ +import { registerEmitter } from '../src/realtime/bus.js'; +import { existsSync as awaitedSnapshotPath } from 'node:fs'; +/** 独立临时数据库上的新增能力验收;不导入 index.ts,不运行自动升级或系统命令。 */ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { prisma } from '../src/lib/db.js'; +import { createApp } from '../src/app.js'; +import { signToken } from '../src/lib/jwt.js'; +import { ensureDefaultGroups } from '../src/lib/permissions.js'; +import { runPromotion } from '../src/modules/term/promotion.service.js'; +import { exportSnapshot } from '../src/lib/snapshot.js'; +import { createAutostartController } from '../src/modules/system/autostart.js'; +import { getMaxTermWeeks } from '../src/lib/term.js'; +import { setSetting, SETTING_KEYS } from '../src/lib/settings.js'; +import { normalizeIslandLayouts, defaultIslandLayouts, buildAutoTermWeeks } from '@classhelper/shared'; +if (!process.env.DATABASE_URL?.includes('v14-verification')) throw new Error('只能对专用临时验证数据库运行'); +let count = 0; +function check(name: string, fn: () => void) { + fn(); + count++; + console.log(`通过 ${count}:${name}`); +} +const prefix = `v14-${Date.now()}`; +const admin = await prisma.user.create({ + data: { + username: `${prefix}-admin`, + name: '临时管理员', + passwordHash: '无登录口令,仅本次签发测试令牌', + role: 'ADMIN', + }, +}); +const teacher = await prisma.user.create({ + data: { + username: `${prefix}-teacher`, + name: '临时教师', + passwordHash: '无登录口令,仅本次签发测试令牌', + role: 'TEACHER', + }, +}); +const lead = await prisma.user.create({ + data: { + username: `${prefix}-lead`, + name: '临时级长', + passwordHash: '无登录口令,仅本次签发测试令牌', + role: 'TEACHER', + }, +}); +const division = await prisma.user.create({ + data: { + username: `${prefix}-division`, + name: '临时部长', + passwordHash: '无登录口令,仅本次签发测试令牌', + role: 'TEACHER', + }, +}); +const head = await prisma.user.create({ + data: { + username: `${prefix}-head`, + name: '临时班主任', + passwordHash: '无登录口令,仅本次签发测试令牌', + role: 'TEACHER', + }, +}); +const token = (u: { id: string; username: string; name: string; role: string }) => + signToken({ + sub: u.id, + username: u.username, + name: u.name, + role: u.role as 'ADMIN' | 'TEACHER', + classId: null, + }); +await ensureDefaultGroups(); +await setSetting(SETTING_KEYS.termMaxWeeks, '60'); +const cls = await prisma.class.create({ + data: { + name: '临时高一班', + grade: '高一', + enrollmentYear: 2090, + classIndex: 1, + code: `V${Date.now()}A`, + teacherId: teacher.id, + termWeeks: 60, + termStartDate: '2026-09-07', + }, +}); +const other = await prisma.class.create({ + data: { + name: '临时高二班', + grade: '高二', + enrollmentYear: 2089, + classIndex: 1, + code: `V${Date.now()}B`, + teacherId: head.id, + termWeeks: 60, + termStartDate: '2026-09-07', + }, +}); +const third = await prisma.class.create({ + data: { name: '临时其他部班', grade: '初一', code: `V${Date.now()}C`, teacherId: head.id }, +}); +const course = await prisma.course.create({ data: { name: '语文', classId: cls.id, teacherId: teacher.id } }), + math = await prisma.course.create({ data: { name: '数学', classId: cls.id, teacherId: teacher.id } }); +const outsider = await prisma.course.create({ + data: { name: '英语', classId: other.id, teacherId: head.id }, +}); +for (const [userId, id, scopeValue] of [ + [lead.id, 'builtin-grade', '高一'], + [division.id, 'builtin-division', '高一,高二'], + [head.id, 'builtin-head', 'HEAD'], + [teacher.id, 'builtin-teacher', ''], +] as const) { + await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } }); + await prisma.userPermissionGroup.create({ data: { userId, groupId: id } }); +} +const server = createServer(createApp()); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const port = (server.address() as { port: number }).port; +async function request(path: string, method = 'GET', data?: unknown, auth = token(admin), expected = 200) { + const res = await fetch(`http://127.0.0.1:${port}/api${path}`, { + method, + headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/json' }, + ...(data === undefined ? {} : { body: JSON.stringify(data) }), + }); + const value = await res.json(); + assert.equal(res.status, expected, `${method} ${path}:${JSON.stringify(value)}`); + return value.data; +} +try { + const points = [ + { name: '第一节', startTime: '08:00', endTime: '08:45', timeType: 'LESSON' }, + { name: '课间', startTime: '08:45', endTime: '09:00', timeType: 'BREAK' }, + { name: '分隔', startTime: '09:00', endTime: '09:00', timeType: 'DIVIDER' }, + { + name: '行动', + startTime: '09:00', + endTime: '09:05', + timeType: 'ACTION', + actionSet: '{"测试":"不执行"}', + }, + { name: '第二节', startTime: '09:05', endTime: '09:50', timeType: 'LESSON' }, + ]; + await request('/time-layouts', 'POST', { name: '越权', points }, token(teacher), 403); + check('教师不能管理全校时间表', () => {}); + const layout = await request('/time-layouts', 'POST', { name: `${prefix}作息`, points }, undefined, 201); + check('四种时间点均保存', () => + assert.equal(new Set(layout.points.map((p: { timeType: string }) => p.timeType)).size, 4), + ); + const plan = await request( + '/class-plans', + 'POST', + { + classId: cls.id, + name: '四周轮换第3周周三', + timeLayoutId: layout.id, + weekday: 3, + weekCountDiv: 3, + weekCountDivTotal: 4, + weekEnd: 60, + }, + undefined, + 201, + ); + const entryPoints = layout.points.filter((p: { timeType: string }) => p.timeType === 'LESSON'); + await request(`/class-plans/${plan.id}/entries`, 'PUT', { + entries: entryPoints.map((p: { id: string }, i: number) => ({ + timeLayoutItemId: p.id, + courseId: i ? math.id : course.id, + })), + }); + const at = encodeURIComponent('2026-09-23T08:10:00+08:00'); + let status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); + check('第3周周三唯一解析器上课', () => { + assert.equal(status.week, 3); + assert.equal(status.current.courseName, '语文'); + assert.equal(status.inClass, true); + }); + const week2 = await request( + `/schedules/current?classId=${cls.id}&at=${encodeURIComponent('2026-09-16T08:10:00+08:00')}`, + ); + check('4周轮换不误命中第2周', () => assert.equal(week2.inClass, false)); + await request(`/time-layouts/${layout.id}`, 'PUT', { ...layout, points: [...layout.points].reverse() }); + status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); + check('排序后显式外键保持语文时段', () => assert.equal(status.current.courseName, '语文')); + const beforeFirst = await request( + `/schedules/current?classId=${cls.id}&at=${encodeURIComponent('2026-09-23T07:00:00+08:00')}`, + ); + check('时间点排序后下一节仍按实际时间选择', () => assert.equal(beforeFirst.next.courseName, '语文')); + await request( + `/class-plans/${plan.id}/entries`, + 'PUT', + { entries: [{ timeLayoutItemId: entryPoints[0].id, courseId: outsider.id }] }, + undefined, + 400, + ); + check('跨班课程拒绝写入', () => {}); + await request( + '/schedules/swap', + 'POST', + { + classId: cls.id, + classPlanId: plan.id, + date: '2026-09-23', + first: entryPoints[0].id, + second: entryPoints[1].id, + }, + undefined, + 201, + ); + status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); + check('换课叠加层生效', () => assert.equal(status.current.courseName, '数学')); + const original = await request(`/class-plans/${plan.id}/export`); + check('换课不修改原表', () => + assert.equal( + original.plan.entries.find( + (e: { timeLayoutItemId: string }) => e.timeLayoutItemId === entryPoints[0].id, + ).courseId, + course.id, + ), + ); + await request( + '/schedules/scheduled', + 'POST', + { classId: cls.id, classPlanId: plan.id, date: '2026-09-23' }, + undefined, + 201, + ); + status = await request(`/schedules/current?classId=${cls.id}&at=${at}`); + check('预定课表优先于临时层', () => assert.equal(status.current.courseName, '语文')); + await request( + '/schedules/overlay', + 'POST', + { classId: cls.id, classPlanId: plan.id, date: '2026-09-24' }, + undefined, + 201, + ); + check('临时课表可以预定到指定日期', () => {}); + for (let i = 0; i < 6; i++) { + const student = await prisma.student.create({ + data: { + studentNo: `${prefix}-${i}`.toUpperCase(), + name: `临时学生${i}`, + classId: cls.id, + status: i === 5 ? 'transferred' : 'active', + }, + }); + await prisma.grade.create({ + data: { + studentId: student.id, + classId: cls.id, + courseId: course.id, + examName: '临时月考', + score: 60 + i, + totalScore: 100, + }, + }); + } + const classToken = signToken({ + sub: cls.id, + classId: cls.id, + name: cls.name, + username: cls.code, + role: 'CLASS_DEVICE', + classSession: true, + }); + await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: false } }); + const overview = await request('/grades/overview', 'GET', undefined, classToken); + check('关闭个人成绩仍可看集体分析且不含身份/极值', () => { + assert.equal(overview.mode, 'collective'); + assert(!/studentNo|studentId|studentName|rank|highest|lowest|临时学生/.test(JSON.stringify(overview))); + }); + let gradeEvent: unknown; + registerEmitter((_rooms, event, payload) => { + if (event === 'grade:updated') gradeEvent = payload; + }); + const gradeProbe = await prisma.grade.findFirstOrThrow({ + where: { classId: cls.id, student: { status: 'active' } }, + }); + await request(`/grades/${gradeProbe.id}`, 'PATCH', { score: 65 }); + check('个人查询关闭时实时广播也不含任何个人成绩', () => + assert.deepEqual(gradeEvent, { classId: cls.id, collective: true }), + ); + await request('/grades/my', 'GET', undefined, classToken, 403); + await request(`/grades/student/${prefix}-0`, 'GET', undefined, classToken, 403); + await request(`/grades/ranking?classId=${cls.id}&examName=临时月考`, 'GET', undefined, classToken, 403); + check('个人成绩三个入口均不可绕过关闭开关', () => {}); + const students = await request(`/classes/${cls.id}/students`, 'GET', undefined, classToken); + check('班级端名单不含转出学生', () => assert.equal(students.length, 5)); + const roster = await request(`/students?classId=${cls.id}`, 'GET', undefined, token(teacher)); + check('教师名单服务端排除转出', () => assert.equal(roster.length, 5)); + const transferred = await prisma.student.findUniqueOrThrow({ + where: { studentNo: (prefix + '-5').toUpperCase() }, + }); + const visibleGrades = await request(`/grades?classId=${cls.id}`); + assert(!visibleGrades.some((r: { studentId: string }) => r.studentId === transferred.id)); + await request( + '/calls', + 'POST', + { classId: cls.id, studentId: transferred.id, message: '仅验证转出过滤' }, + undefined, + 404, + ); + const homework = await request( + '/homeworks', + 'POST', + { classId: cls.id, courseId: course.id, title: '转出过滤验收', content: '仅临时库,不向真实班级发布' }, + undefined, + 201, + ); + const submissions = await request(`/homeworks/${homework.id}/submissions`, 'GET', undefined, classToken); + assert.equal(submissions.total, 5); + assert(!submissions.students.some((r: { studentId: string }) => r.studentId === transferred.id)); + await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: true } }); + await request(`/grades/student/${transferred.studentNo}`, 'GET', undefined, classToken, 404); + await prisma.class.update({ where: { id: cls.id }, data: { studentGradeQueryEnabled: false } }); + check('转出学生不可录入叫人、查询成绩或进入作业未交名单', () => {}); + await request(`/classes/${other.id}/students`, 'GET', undefined, token(teacher), 403); + check('普通教师不可跨班读名单', () => {}); + await request(`/grades?classId=${other.id}`, 'GET', undefined, token(lead), 403); + await request(`/classes/${cls.id}/students`, 'GET', undefined, token(lead)); + check('级长只可读本年级', () => {}); + await request(`/classes/${other.id}/students`, 'GET', undefined, token(division)); + await request(`/classes/${third.id}/students`, 'GET', undefined, token(division), 403); + check('部长只可读配置部范围', () => {}); + for (const u of [head, teacher, lead, division]) { + await request('/system/settings', 'GET', undefined, token(u), 403); + await request('/permissions/groups', 'GET', undefined, token(u), 403); + await request('/update/client/fetch', 'POST', { version: '9.9.9' }, token(u), 403); + } + check('四岗位组均无系统/权限/下发更新权限', () => {}); + check('总周数可到60,生成41周不再截成30', () => + assert.equal(buildAutoTermWeeks('2026-09-07', 41).length, 41), + ); + assert.equal(await getMaxTermWeeks(), 60); + const dry = await runPromotion(2099, true); + check('升级预览不写执行记录、不改变年级', () => assert.equal(dry.status, 'dry-run')); + assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: cls.id } })).grade, '高一'); + const snapshot = await exportSnapshot(prisma, 'sqlite'); + check('快照包含日课表/时间点/升级记录与周次', () => + assert( + snapshot.data.ClassPlan && + snapshot.data.TimeLayoutItem && + snapshot.data.PromotionRun && + snapshot.data.TermWeek, + ), + ); + check('岛默认固定;非法配置有界回落', () => { + assert.equal(defaultIslandLayouts().layout, 'fixed'); + const invalid = normalizeIslandLayouts({ + layout: 'custom', + schemes: [ + { id: 'x', rows: [{ nodes: [], main: true }], scale: 999, color: 'url(https://example.com)' }, + ], + }); + assert.equal(invalid.schemes[0]!.scale, 3); + assert.equal(invalid.schemes[0]!.color, '#202024'); + }); + + const legacy = await request( + '/schedules', + 'POST', + { + classId: cls.id, + courseId: math.id, + dayOfWeek: 1, + startTime: '10:00', + endTime: '10:45', + weekStart: 1, + weekEnd: 3, + }, + undefined, + 201, + ); + check('旧 POST 兼容门面写入日课表', () => assert.equal(legacy.courseId, math.id)); + await request(`/schedules/${legacy.id}`, 'PATCH', { weekStart: 2, weekEnd: 4 }); + const firstWeek = await request(`/schedules?classId=${cls.id}&week=1`), + fourthWeek = await request(`/schedules?classId=${cls.id}&week=4`); + check('旧 PATCH 原子调整单节范围,不改变整日其它课程', () => { + assert(!firstWeek.some((r: { id: string }) => r.id === legacy.id)); + assert(fourthWeek.some((r: { id: string }) => r.id === legacy.id)); + }); + await request(`/schedules/${legacy.id}`, 'PATCH', { startTime: '11:00', endTime: '11:45' }); + const moved = await request(`/schedules?classId=${cls.id}&week=4`); + check('旧时间编辑投影新时间点', () => + assert.equal(moved.find((r: { id: string }) => r.id === legacy.id).startTime, '11:00'), + ); + await request(`/schedules/${legacy.id}`, 'DELETE'); + check('旧 DELETE 不从遗留 Schedule 复活', () => {}); + const payload = { + Subjects: { subject: { Name: '语文' } }, + TimeLayouts: { + layout: { + Name: '上游测试', + Layouts: [ + { StartTime: '08:00:00', EndTime: '08:45:00', TimeType: 0 }, + { StartTime: '08:45:00', EndTime: '09:00:00', TimeType: 1 }, + { StartTime: '09:00:00', EndTime: '09:00:00', TimeType: 2 }, + { StartTime: '09:00:00', EndTime: '09:01:00', TimeType: 3 }, + ], + }, + }, + ClassPlans: { + plan: { + Name: '上游四周', + TimeLayoutId: 'layout', + TimeRule: { WeekDay: 4, WeekCountDiv: 3, WeekCountDivTotal: 4 }, + Classes: [{ SubjectId: 'subject' }], + }, + }, + }; + await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload }, undefined, 201); + await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload }, undefined, 201); + const imported = await request(`/class-plans?classId=${cls.id}`); + check('ClassIsland 导入保留4周轮换,merge重复导入不复制课表', () => { + const rows = imported.filter((p: { name: string }) => p.name === '上游四周'); + assert.equal(rows.length, 1); + assert.equal(rows[0].weekCountDivTotal, 4); + assert.equal(rows[0].layout.points.length, 4); + }); + let enabled = false; + const os = { + platform: 'win32', + root: 'F:\\fake-installed-server', + exists: () => true, + run: (_file: string, args: string[]) => { + if (args.includes('InstallDir')) + return { ok: true, output: 'InstallDir REG_SZ F:\\fake-installed-server' }; + if (args[0] === 'add') { + enabled = true; + return { ok: true, output: '' }; + } + if (args[0] === 'delete') { + enabled = false; + return { ok: true, output: '' }; + } + return { + ok: enabled, + output: enabled ? 'ClassHelperServer REG_SZ "F:\\fake-installed-server\\start.cmd"' : '', + }; + }, + }; + const autostart = createAutostartController(os); + check('安装形态自启动开启/关闭且回读一致(假系统适配器)', () => { + assert.equal(autostart.read().supported, true); + assert.equal(autostart.write(true).enabled, true); + assert.equal(createAutostartController(os).read().enabled, true); + assert.equal(autostart.write(false).enabled, false); + }); + check('非安装形态返回不可用原因', () => { + const value = createAutostartController({ ...os, exists: () => false }).read(); + assert.equal(value.supported, false); + assert(value.detail.length > 10); + }); + // 此文件开头已强制临时库,实际升级不会触及演示或业务数据。 + await prisma.class.create({ + data: { + name: '临时高三班', + grade: '高三', + enrollmentYear: 2088, + classIndex: 1, + code: prefix + 'G', + teacherId: head.id, + }, + }); + const [promotion, repeated] = await Promise.all([runPromotion(2099), runPromotion(2099)]); + check('临时库并发升级同年幂等且执行前快照存在', () => { + assert.equal(promotion.status, 'done'); + assert.equal(repeated.snapshotPath, promotion.snapshotPath); + assert.equal(awaitedSnapshotPath(promotion.snapshotPath), true); + }); + const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2099, grade: '高一' } }); + check('新高一骨架不复制班主任或任课授权', () => assert.equal(skeleton.teacherId, null)); + assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0); + check('临时高三已归档,其余只升一级', () => assert.equal(repeated.classCount, 3)); + assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: cls.id } })).grade, '高二'); + assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: other.id } })).grade, '高三'); + console.log(`=== V1.4 新增能力:${count}/${count} 项通过 ===`); +} finally { + await new Promise((resolve) => server.close(() => resolve())); + await prisma.$disconnect(); +} diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs index 026dc14..77f3856 100644 --- a/scripts/ui-smoke/main.cjs +++ b/scripts/ui-smoke/main.cjs @@ -1188,6 +1188,7 @@ async function main() { // 7.9 管理员专属:教师录入(本轮新增「教师管理」页,仅管理员可见可用) await runAdminTeacherChecks(win); + await runPlanEditorChecks(win); // 8. 手机小屏适配(390×844,1Panel 风格:侧边栏收进抽屉 + 卡片内横向滚动) await runMobileChecks(win); @@ -1435,6 +1436,55 @@ async function runMobileChecks(win) { win.hide(); } +/** 新页面用真实点击进入,仅保存临时验收班级的可逆字段。 */ +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('.editor-table tbody tr'))`, + ); + 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('[role=tab]')].find(e=>e.textContent.includes('时间表'))?.click();})()`, + ); + record( + '时间表页四种时间点与管理员保存入口可见', + await waitFor( + win, + `document.body.innerText.includes('添加时间点') && document.body.innerText.includes('保存时间表')`, + ), + ); + await win.loadURL(new URL('/system-settings', TARGET_URL).href); + record( + '开发环境自启动明确显示不可用原因', + await waitFor( + win, + `document.body.innerText.includes('开发环境') && Boolean(document.querySelector('.el-switch.is-disabled'))`, + ), + ); + await win.loadURL(new URL('/term-weeks', TARGET_URL).href); + record( + '学年升级设置与待补跑提示可见且不执行升级', + await waitFor( + win, + `document.body.innerText.includes('每年自动学年升级') && document.body.innerText.includes('总周数')`, + ), + ); + await win.loadURL(new URL('/archives', TARGET_URL).href); + record( + '归档页有转出分区且无手动归档入口', + await waitFor( + win, + `document.body.innerText.includes('已转出') && ![...document.querySelectorAll('button')].some(e=>e.textContent.trim()==='新建归档')`, + ), + ); +} + async function finish(win) { record('全程后台结束(窗口隐藏且未聚焦)', !win.isVisible() && !win.isFocused()); const failed = results.filter((item) => !item.ok); diff --git a/scripts/verification-server.mts b/scripts/verification-server.mts new file mode 100644 index 0000000..ba7cf6e --- /dev/null +++ b/scripts/verification-server.mts @@ -0,0 +1,18 @@ +/** 仅供隔离验收:不启动年度升级、备份、PID 或其它后台运维任务。 */ +import { createServer } from 'node:http'; +import { createApp } from '../packages/server/src/app.js'; +import { initRealtime } from '../packages/server/src/realtime/socket.js'; +if (!process.env.DATABASE_URL?.includes('v14-verification')) + throw new Error('验收服务只能使用专用临时数据库'); +const localFetch = globalThis.fetch; +globalThis.fetch = (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input : input.url); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) + return Promise.reject(new Error('隔离验收禁止访问外网')); + return localFetch(input, init); +}; +const server = createServer(createApp()); +initRealtime(server); +server.listen(Number(process.env.PORT || 4105), '127.0.0.1', () => + console.log(`验收服务已就绪:${process.env.PORT || 4105}`), +); diff --git a/scripts/verify-e2e-isolated.mjs b/scripts/verify-e2e-isolated.mjs new file mode 100644 index 0000000..9a4d0b8 --- /dev/null +++ b/scripts/verify-e2e-isolated.mjs @@ -0,0 +1,113 @@ +/** 旧回归也必须隔离:临时库、带前缀的临时教师与班级,绝不回退真实实例。 */ +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), + dir = path.join(root, '.cache', 'v14-verification', `e2e-${process.pid}-${Date.now()}`); +fs.mkdirSync(dir, { recursive: true }); +const env = { + ...process.env, + DATABASE_PROVIDER: 'sqlite', + DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`, + TZ: 'Asia/Shanghai', + RATE_LIMIT_ENABLED: 'false', + LOG_LEVEL: 'warn', + TERM_START_DATE: '2026-09-07', +}; +const require = createRequire(path.join(root, 'packages/server/package.json')), + { createClient } = require('@libsql/client'); +const db = createClient({ url: env.DATABASE_URL }); +await db.execute('PRAGMA user_version=0'); +db.close(); +function run(args, logName) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: root, + env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const log = fs.createWriteStream(path.join(dir, logName)); + for (const stream of [child.stdout, child.stderr]) + stream.on('data', (chunk) => { + log.write(chunk); + process.stdout.write(chunk); + }); + child.once('error', reject); + child.once('exit', (code) => { + log.end(); + if (code === 0) resolve(); + else reject(new Error(`${logName} 退出码 ${code},日志 ${dir}`)); + }); + }); +} +const seedFile = path.join(root, 'packages/server/prisma', `.verification-seed-${process.pid}.ts`), + testFile = path.join(root, 'packages/server/scripts', `.verification-e2e-${process.pid}.mjs`); +const prefix = `temporary${process.pid}_`; +function namespace(code) { + for (const name of ['admin', 'teacher1', 'teacher2']) + code = code.replaceAll(`'${name}'`, `'${prefix}${name}'`); + return code; +} +let server; +try { + await run( + [ + 'packages/server/node_modules/prisma/build/index.js', + 'migrate', + 'deploy', + '--config', + 'packages/server/prisma.config.ts', + ], + 'migrate.log', + ); + fs.writeFileSync( + seedFile, + namespace(fs.readFileSync(path.join(root, 'packages/server/prisma/seed.ts'), 'utf8')), + { flag: 'wx' }, + ); + await run(['packages/server/node_modules/tsx/dist/cli.mjs', seedFile], 'seed.log'); + const probe = net.createServer(); + await new Promise((r) => probe.listen(0, '127.0.0.1', r)); + env.PORT = String(probe.address().port); + await new Promise((r) => probe.close(r)); + env.VERIFY_BASE_URL = `http://127.0.0.1:${env.PORT}`; + server = spawn( + process.execPath, + ['--import', './packages/server/node_modules/tsx/dist/loader.mjs', 'scripts/verification-server.mts'], + { cwd: root, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const log = fs.createWriteStream(path.join(dir, 'server.log')); + server.stdout.pipe(log); + server.stderr.pipe(log); + server.once('exit', (code, signal) => console.log(`临时服务退出:code=${code} signal=${signal}`)); + let ready = false; + const startupDeadline = Date.now() + 60_000; + while (Date.now() < startupDeadline) { + if (server.exitCode !== null) throw new Error('临时服务启动失败'); + try { + ready = (await fetch(`${env.VERIFY_BASE_URL}/healthz`, { signal: AbortSignal.timeout(1500) })).ok; + } catch { + /* 等待进程启动 */ + } + if (ready) break; + await new Promise((r) => setTimeout(r, 100)); + } + if (!ready) throw new Error('临时服务未就绪'); + fs.writeFileSync( + testFile, + namespace(fs.readFileSync(path.join(root, 'packages/server/scripts/verify-e2e.mjs'), 'utf8')), + { flag: 'wx' }, + ); + await run([testFile], 'e2e.log'); +} finally { + if (server && server.exitCode === null) { + server.kill(); + await new Promise((r) => server.once('exit', r)); + } + for (const file of [seedFile, testFile]) if (fs.existsSync(file)) fs.unlinkSync(file); +} +console.log(`隔离端到端日志:${dir}`); diff --git a/scripts/verify-v14.mjs b/scripts/verify-v14.mjs new file mode 100644 index 0000000..ca35474 --- /dev/null +++ b/scripts/verify-v14.mjs @@ -0,0 +1,37 @@ +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', 'v14-verification', `run-${process.pid}-${Date.now()}`); +fs.mkdirSync(dir, { recursive: true }); +const env = { + ...process.env, + DATABASE_PROVIDER: 'sqlite', + DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`, + TZ: 'Asia/Shanghai', + RATE_LIMIT_ENABLED: 'false', + LOG_LEVEL: 'warn', +}; +const require = createRequire(path.join(root, 'packages/server/package.json')); +const { createClient } = require('@libsql/client'); +const database = createClient({ url: env.DATABASE_URL }); +await database.execute('PRAGMA user_version=0'); +database.close(); +function run(args) { + return 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-v14.ts']); +console.log(`隔离验收数据库:${dir}`);