diff --git a/README.md b/README.md index bd885ec..9417e2a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ class-helper/ │ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/seed.ts # 种子数据 │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) - │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(125 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(126 项) │ └── src/ │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) @@ -171,12 +171,12 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:125 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:126 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):47 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 126 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 (**班级账号登录** + 四类数据 + Socket.IO),实测 47/47 通过;退出后再启动一次校验单实例锁与文件锁已释放。 @@ -220,7 +220,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(125 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | +| `pnpm verify:e2e` | 后端端到端验收(126 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,18 项,含权限入口隐藏与导入弹窗) | | `pnpm verify:desktop` | EXE 客户端冒烟验证(47 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴、个性化全参数) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | @@ -754,6 +754,16 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) > 行为变更提示:按需求"科任老师只有布置作业、叫人、通知权限""班主任另有本班课表管理权限", > 成绩录入与班级/名单管理已收紧为管理员专属;如需把成绩下放给班主任或科任老师, > 只需调整 `packages/shared/src/permissions.ts` 里的 `canManageGrades()` 并同步断言。 +> +> 需求 6 写的是"老师端与学生端班级账号均可用且不越权",而本仓库按需求 7 的权限清单把 +> **成绩导入/录入实现为管理员专属**(`verify:e2e` 有两项断言"班主任/科任老师录入成绩 403")。 +> 这是**有意保留的取舍**,等你确认后再按下面范围放开(约 6 处、一行核心判定): +> +> 1. `packages/shared/src/permissions.ts`:`canManageGrades()` 改为按班级角色判定(`ADMIN | HEAD`); +> 2. `packages/server/src/lib/access.ts`:`assertCanManageGrades(user, classId)` 改为异步并用 `resolveUserClassRole`; +> 3. `packages/server/src/modules/grades/*`、`modules/imports/imports.module.ts`:路由从 `requireRole('ADMIN')` 放宽为 `ADMIN | TEACHER`,服务层按班级断言(科任仍 403); +> 4. `packages/web-admin/src/views/{GradesView,StudentsView}.vue`:`isAdmin` 改为"管理员或本班班主任"; +> 5. `packages/server/scripts/verify-e2e.mjs`:班主任成绩断言由 403 改为 201(科任仍 403),本文件的权限矩阵同步更新。 ## 模块化设计(可随时增删模块) @@ -829,7 +839,7 @@ pnpm db:migrate && pnpm db:seed | 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 | | ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 | | 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 | -| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 18 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 | +| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | | 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 6 条(全班 6 人)、教师端 `readCount=6`、`completedCount=6`、`/grades/my` 全班成绩 | | 客户端只保留班级登录入口 | ✅ | `verify:desktop`:`login=高一(1)班 classSession=true`,导航「成绩」标题变为「本班成绩」 | | 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | @@ -848,7 +858,7 @@ pnpm db:migrate && pnpm db:seed | 验证 | 结果 | | ----------------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **125/125** ✅ | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **126/126** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回 + 个性化全参数 + 托盘与退出无残留) | **47/47** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index bf592ed..2b7ee18 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1237,6 +1237,9 @@ async function main() { // ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级) // 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩) // 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。 + // 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。 + const adminClassList = await api('/classes', { token: adminToken }); + const originalClassAccount = (adminClassList.payload?.data ?? []).find((item) => item.id === classId) ?? {}; const classCode = `E2E${Date.now().toString(36).slice(-4).toUpperCase()}`; const setAccount = await api(`/classes/${classId}/class-account`, { method: 'PATCH', @@ -1458,7 +1461,7 @@ async function main() { `status=${occupiedCode.status} message=${occupiedCode.payload?.message ?? ''}`, ); - // 清理:删除回归用的通知 / 作业 / 成绩,并把班级密码恢复为默认 + // 清理:删除回归用的通知 / 作业 / 成绩,把班级码恢复为验证前的原值,密码恢复为默认 if (classNameNoticeId) { await api(`/notifications/${classNameNoticeId}`, { method: 'DELETE', token: teacherToken }); } @@ -1468,11 +1471,28 @@ async function main() { if (classNameGrade.payload?.data?.id) { await api(`/grades/${classNameGrade.payload.data.id}`, { method: 'DELETE', token: adminToken }); } - await api(`/classes/${classId}/class-account`, { + + const restoreAccount = await api(`/classes/${classId}/class-account`, { method: 'PATCH', token: adminToken, - body: { password: '123456' }, + body: { + ...(originalClassAccount.code ? { code: originalClassAccount.code } : {}), + password: '123456', + }, }); + const restoredLogin = originalClassAccount.code + ? await api('/auth/class-login', { + method: 'POST', + body: { code: originalClassAccount.code, password: '123456' }, + }) + : { status: 200 }; + record( + '清理:班级码恢复为验证前的原值(不污染演示数据)', + restoreAccount.status === 200 && + (!originalClassAccount.code || restoreAccount.payload?.data?.code === originalClassAccount.code) && + restoredLogin.status === 200, + `原值=${originalClassAccount.code || '(未设置)'} 还原后=${restoreAccount.payload?.data?.code ?? '-'} 登录=${restoredLogin.status}`, + ); socket.close();