From 30e4c3680915f04af75ccba9d0e91596d1bc3c2f Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 12 Sep 2026 15:02:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E8=90=BD=E5=9C=B0=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=9D=83=E9=99=90=E6=A8=A1=E5=9E=8B=EF=BC=88=E9=9C=80?= =?UTF-8?q?=E6=B1=82=207=EF=BC=89=E2=80=94=E2=80=94=E7=8F=AD=E7=BA=A7/?= =?UTF-8?q?=E4=BA=BA=E5=91=98/=E5=90=8D=E5=8D=95/=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=88=B0=E7=AE=A1=E7=90=86=E5=91=98=EF=BC=8C?= =?UTF-8?q?=E7=8F=AD=E4=B8=BB=E4=BB=BB=E7=AE=A1=E6=9C=AC=E7=8F=AD=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E7=A7=91=E4=BB=BB=E4=BB=85=E4=BD=9C=E4=B8=9A?= =?UTF-8?q?=C2=B7=E5=8F=AB=E4=BA=BA=C2=B7=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 权限矩阵(前后端共用一份) - 新增 `packages/shared/src/permissions.ts`:`ClassRole`(ADMIN/HEAD/SUBJECT/STUDENT/NONE)、 `resolveClassRole()`、`canManageClasses/AssignTeachers/Roster/Schedule/Courses/PublishContent/ManageGrades/Import` 与 `PERMISSION_MATRIX`(README 与验收脚本据此展开)。 - 后端 `lib/access.ts`:新增 `resolveUserClassRole()`(`Class.teacherId` → 班主任,`ClassTeacher` → 科任) 与 `assertCanManageClasses/AssignTeachers/Roster/Schedule/PublishContent/ManageGrades`; 各 service 在**服务层**断言(路由仅做角色过滤),越权统一 403。 - 收紧项:班级增删改、班主任/科任分配、学生名单与账号 → 仅 ADMIN; 课表/课程增删改 → ADMIN 或本班班主任;成绩录入/修改/批量 → 仅 ADMIN(按需求清单); 作业/通知/叫人 → ADMIN、班主任、科任皆可。 - 读权限保持不变:教师仍可查看自己班级的课表/作业/通知/成绩列表与统计。 ## 前端按同一矩阵隐藏入口 - 侧边栏:教师端不再显示「班级管理 / 学生管理 / 成绩录入」(管理员仍是 7 项)。 - 班级页:新建/编辑/删除按钮仅管理员可见;课表页:增删改按钮仅"管理员或本班班主任"可见; 成绩页:录入/批量/删除按钮仅管理员可见。 - **叫人入口调整**:教师需要叫人,而「学生管理」是管理员专属,因此教师入口移到 「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息;名单走 `/classes/:id/students`, 不使用管理员专属的 `/students`);管理员在学生管理页仍保留逐行「叫人」。 ## 顺手修掉的两个真实缺陷 - `NotificationsView.openCreate()`:班级列表异步加载期间点「发布通知」会导致「目标班级」为空、 校验失败且**静默不发布**;现在打开弹窗前会确保班级已加载(冒烟同步等待下拉就绪)。 - `desktop-client/scripts/smoke.mjs`:上一次冒烟被强杀后配置目录仍被句柄占用,`rmSync` 报 ENOTEMPTY 直接让冒烟启动失败;改为按进程号使用独立目录并容错清理。 ## 验证 - `pnpm verify:e2e` **82/82** ✅:新增 14 项角色权限断言(班主任/科任建班被拒、班主任删班被拒、 班主任/科任分配人员被拒、管理员分配与取消成功、科任管理课表被拒、科任可布置作业、 科任/班主任录入成绩被拒、管理员录入成功),并把历史用例中"仅管理员可执行"的操作切到管理员令牌。 - `pnpm verify:web` **17/17** ✅:新增「教师端隐藏无权入口」断言(可见菜单=仪表盘/课表/作业/通知, 越权入口=[]),叫人入口改到通知页后仍验证弹窗、8 条快捷短语、自定义输入与可选中态。 - `pnpm verify:desktop` **34/34** ✅;`lint` / `format:check` / `typecheck` 全绿。 - 数据清理:清掉历史遗留的测试班级与 4 条探针课表,演示数据恢复为 3 个班级。 --- README.md | 119 ++++++----- packages/desktop-client/scripts/smoke.mjs | 12 +- packages/server/scripts/verify-e2e.mjs | 190 ++++++++++++++++-- packages/server/src/lib/access.ts | 88 +++++++- .../server/src/modules/calls/calls.service.ts | 6 +- .../src/modules/classes/classes.module.ts | 4 +- .../src/modules/classes/classes.service.ts | 19 +- .../src/modules/courses/courses.service.ts | 10 +- .../src/modules/grades/grades.module.ts | 38 ++-- .../src/modules/grades/grades.service.ts | 12 +- .../modules/homeworks/homeworks.service.ts | 10 +- .../notifications/notifications.service.ts | 8 +- .../src/modules/schedules/schedules.module.ts | 2 +- .../modules/schedules/schedules.service.ts | 10 +- .../src/modules/students/students.module.ts | 59 ++---- .../src/modules/students/students.service.ts | 14 +- .../src/modules/teachers/teachers.module.ts | 15 +- packages/shared/src/index.ts | 1 + packages/shared/src/permissions.ts | 93 +++++++++ .../web-admin/src/layouts/AdminLayout.vue | 30 ++- packages/web-admin/src/views/ClassesView.vue | 13 +- packages/web-admin/src/views/GradesView.vue | 13 +- .../web-admin/src/views/NotificationsView.vue | 126 +++++++++++- .../web-admin/src/views/SchedulesView.vue | 33 ++- scripts/ui-smoke/main.cjs | 63 ++++-- 25 files changed, 760 insertions(+), 228 deletions(-) create mode 100644 packages/shared/src/permissions.ts diff --git a/README.md b/README.md index bcd3ca1..104bda2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 班级小助手(Class Helper) +# 班级小助手(Class Helper) 班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与 **桌面客户端**(学生)三端。核心链路: @@ -171,8 +171,8 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:68 项 -pnpm verify:web # Web 管理端真实点击(含手机小屏适配、叫人入口、紧急通知全屏警告):16 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项 +pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项 ``` @@ -202,30 +202,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 ## 常用命令 -| 命令 | 说明 | -| ------------------------------------------- | ---------------------------------------------------------------------------- | -| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | -| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | -| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | -| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | -| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | -| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | -| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | -| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | -| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | -| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(68 项,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,16 项,含手机小屏适配与叫人入口) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | -| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | -| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | -| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | -| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | -| `pnpm db:migrate` | 创建并应用迁移 | -| `pnpm db:seed` | 写入种子数据 | -| `pnpm db:reset` | 重置数据库并重新执行 seed | -| `pnpm db:studio` | 打开 Prisma Studio | -| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | +| 命令 | 说明 | +| ------------------------------------------- | ------------------------------------------------------------------------- | +| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | +| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | +| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | +| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | +| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | +| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | +| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | +| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | +| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | +| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | +| `pnpm verify:e2e` | 后端端到端验收(82 项,含角色权限矩阵,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,17 项,含权限入口隐藏) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) | +| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | +| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | +| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | +| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | +| `pnpm db:migrate` | 创建并应用迁移 | +| `pnpm db:seed` | 写入种子数据 | +| `pnpm db:reset` | 重置数据库并重新执行 seed | +| `pnpm db:studio` | 打开 Prisma Studio | +| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | ## Web 管理端(含手机小屏适配) @@ -261,7 +261,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、 `src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。 -`pnpm verify:web` 的 16 项里,最后 8 项专门验证手机端与叫人入口(Electron 真实点击 + 真实尺寸窗口): +`pnpm verify:web` 的 17 项里,最后 9 项专门验证权限入口隐藏、手机端与叫人入口(Electron 真实点击): | 校验项 | 实测结果 | | -------------------------------------- | -------------------------------------------------------------------- | @@ -561,20 +561,47 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) `class:{classId}`、`user:{userId}`、`role:{role}`、`students`、`teachers`。 客户端断线由 Socket.IO 自动重连(Web 端 store 与后续 EXE 端共用同一套事件名)。 -## 权限模型(RBAC) +## 角色与权限模型(RBAC) -| 角色 | 可见范围 | 写权限 | -| --------- | ------------------------------------------------------------- | --------------------------------------- | -| `ADMIN` | 全部班级 | 全部 | -| `TEACHER` | 自己创建(`Class.teacherId`)或被分配(`ClassTeacher`)的班级 | 仅上述班级 | -| `STUDENT` | 仅自己所在班级(`User.classId` + `Enrollment`) | 无(仅能标记自己的作业状态 / 已读通知) | +班级内的角色由"账号角色 + 与该班级的关系"共同决定,前后端共用同一份矩阵 +(`packages/shared/src/permissions.ts`,后端 `lib/access.ts` 复用,前端用它隐藏入口): + +| 角色 | 判定方式 | 可见范围 | +| ------------------ | ------------------------------ | -------------------------- | +| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | +| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | +| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 | +| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) | + +**权限矩阵**(`PERMISSION_MATRIX`,验收脚本按此逐条断言): + +| 操作 | 管理员 | 班主任 | 科任老师 | +| -------------------------------------- | :----: | :----------: | :------: | +| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | +| 分配班主任与科任老师 | ✅ | ❌ | ❌ | +| 学生名单管理(增删 / 重置密码 / 导入) | ✅ | ❌ | ❌ | +| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | +| 布置作业 | ✅ | ✅ | ✅ | +| 叫人 | ✅ | ✅ | ✅ | +| 发布通知 | ✅ | ✅ | ✅ | +| 成绩录入 / 修改 / 导入 | ✅ | ❌ | ❌ | 实现要点: -- `src/middleware/auth.ts`:JWT 校验后会**回查数据库**,班级/角色变更立即生效。 -- `src/lib/access.ts`:集中实现 `assertClassAccess` / `assertClassWritable` / `resolveClassScope`, - 列表接口通过 scope 收敛数据(`classId in [...]`),单条接口直接断言。 -- 越权请求统一返回 `403`,未登录返回 `401`(验收脚本已覆盖 10 项越权场景)。 +- `src/middleware/auth.ts`:JWT 校验后**回查数据库**,班级/角色变更立即生效。 +- `src/lib/access.ts`:`resolveUserClassRole()` 解析班级内角色,`assertCanManageClasses` / + `assertCanAssignTeachers` / `assertCanManageRoster` / `assertCanManageSchedule` / + `assertCanPublishContent` / `assertCanManageGrades` 在**服务层**断言(路由只做角色过滤)。 +- `resolveClassScope()` 收敛列表数据(`classId in [...]`),单条接口直接断言;越权一律 `403`。 +- 前端按同一矩阵隐藏入口:教师端侧边栏不再显示「班级管理 / 学生管理 / 成绩录入」, + 课表页的增删改按钮仅对"管理员或本班班主任"显示,成绩页的录入/删除按钮仅对管理员显示。 +- **`叫人`入口位置调整**:班主任与科任老师都需要叫人,而「学生管理」页是管理员专属, + 因此教师的叫人入口放在「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息), + 管理员在学生管理里仍保留逐行「叫人」。 + +> 行为变更提示:按需求"科任老师只有布置作业、叫人、通知权限""班主任另有本班课表管理权限", +> 成绩录入与班级/名单管理已收紧为管理员专属;如需把成绩下放给班主任或科任老师, +> 只需调整 `packages/shared/src/permissions.ts` 里的 `canManageGrades()` 并同步断言。 ## 模块化设计(可随时增删模块) @@ -649,14 +676,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| ---------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **68/68** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 叫人入口 + 紧急通知 + PWA) | **16/16** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| --------------------------------------------------------------------------------- | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) diff --git a/packages/desktop-client/scripts/smoke.mjs b/packages/desktop-client/scripts/smoke.mjs index 44230c4..b805233 100644 --- a/packages/desktop-client/scripts/smoke.mjs +++ b/packages/desktop-client/scripts/smoke.mjs @@ -26,9 +26,15 @@ if (missing.length > 0) { console.log('[smoke] 启动 Electron 冒烟验证...\n'); -// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出) -const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile'); -fs.rmSync(profileDir, { recursive: true, force: true }); +// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)。 +// 目录按进程号区分:上一次冒烟若被强杀,目录可能仍被句柄占用(rmSync 会 ENOTEMPTY), +// 用独立目录可以彻底避免互相干扰。 +const profileDir = path.join(repoRoot, '.cache', `desktop-smoke-profile-${process.pid}`); +try { + fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); +} catch (error) { + console.warn(`[smoke] 复用/清理配置目录失败(忽略):${error?.message ?? error}`); +} console.log(`[smoke] 独立配置目录:${profileDir}`); // 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试 diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 4b3ffc0..ae0b431 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env node /** * 端到端联调验证(对应验收标准): * 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到 @@ -76,6 +76,15 @@ async function main() { record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`); if (!teacherToken) process.exit(1); + // 管理员令牌:按新的角色权限模型,班级增删改/人员分配/学生名单/成绩均仅管理员可执行 + const adminLogin = await api('/auth/login', { + method: 'POST', + body: { username: 'admin', password: 'admin123' }, + }); + const adminToken = adminLogin.payload?.data?.token; + record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`); + if (!adminToken) process.exit(1); + const studentLogin = await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' }, @@ -117,7 +126,7 @@ async function main() { foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级', ); - const studentList = await api(`/students?classId=${classId}`, { token: teacherToken }); + const studentList = await api(`/students?classId=${classId}`, { token: adminToken }); const classmates = studentList.payload?.data ?? []; const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0]; record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`); @@ -225,7 +234,7 @@ async function main() { const gradeWait = waitForEvent(socket, 'grade:updated'); const createdGrade = await api('/grades', { method: 'POST', - token: teacherToken, + token: adminToken, body: { classId, courseId, @@ -270,7 +279,7 @@ async function main() { `学生 ${classDetail.payload?.data?.students?.length ?? 0} 人 · 课程 ${classDetail.payload?.data?.courses?.length ?? 0} 门`, ); - const teacherList = await api('/teachers', { token: teacherToken }); + const teacherList = await api('/teachers', { token: adminToken }); record( '教师列表 /teachers', (teacherList.payload?.data ?? []).length >= 2, @@ -305,20 +314,20 @@ async function main() { const assigned = await api(`/classes/${classId}/teachers`, { method: 'POST', - token: teacherToken, + token: adminToken, body: { teacherId: teacher2Login.payload?.data?.user?.id }, }); record('分配协作教师', assigned.status === 201, `status=${assigned.status}`); const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, { method: 'DELETE', - token: teacherToken, + token: adminToken, }); record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`); const tempUsername = `e2e_student_${Date.now()}`; const addedStudent = await api(`/classes/${classId}/students`, { method: 'POST', - token: teacherToken, + token: adminToken, body: { username: tempUsername, name: '联调学生' }, }); const addedStudentId = addedStudent.payload?.data?.id; @@ -334,14 +343,14 @@ async function main() { const resetResult = await api(`/students/${addedStudentId}/reset-password`, { method: 'POST', - token: teacherToken, + token: adminToken, body: {}, }); record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`); const renamed = await api(`/students/${addedStudentId}`, { method: 'PATCH', - token: teacherToken, + token: adminToken, body: { name: '联调学生(已改名)' }, }); record( @@ -352,7 +361,7 @@ async function main() { const removedStudent = await api(`/classes/${classId}/students/${addedStudentId}`, { method: 'DELETE', - token: teacherToken, + token: adminToken, }); record('班级移出学生', removedStudent.status === 200, `status=${removedStudent.status}`); @@ -360,20 +369,13 @@ async function main() { const teacherDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: teacherToken }); record('教师删除未分班学生被拒绝(403)', teacherDelete.status === 403, `status=${teacherDelete.status}`); - const adminLogin = await api('/auth/login', { - method: 'POST', - body: { username: 'admin', password: 'admin123' }, - }); - const adminToken = adminLogin.payload?.data?.token; - record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`); - const adminDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: adminToken }); record('管理员删除学生账号', adminDelete.status === 200, `status=${adminDelete.status}`); } const bulkResult = await api('/grades/bulk', { method: 'POST', - token: teacherToken, + token: adminToken, body: { classId, courseId, @@ -424,7 +426,7 @@ async function main() { const updatedGrade = await api(`/grades/${createdGrade.payload?.data?.id}`, { method: 'PATCH', - token: teacherToken, + token: adminToken, body: { score: 95 }, }); record( @@ -619,6 +621,156 @@ async function main() { record('清理叫人测试数据', true, '已删除 3 条叫人通知'); } + // ---------------------------------------------------------------- 6.4 班级角色权限矩阵 + // 依据需求:班主任/科任老师不能增删改班级;仅管理员可分配人员; + // 科任老师仅作业/叫人/通知;班主任另可管理本班课表;成绩录入仅管理员。 + { + record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录'); + + // 科任老师:teacher2 在高二(3)班是协作(科任)老师 + const subjectClass = teacher2Classes.find( + (item) => item.teacherId !== teacher2Login.payload?.data?.user?.id, + ); + const subjectClassId = subjectClass?.id; + record( + '存在"科任老师"场景的班级', + Boolean(subjectClassId) && subjectClassId !== foreignClass?.id, + `classId=${subjectClassId ?? '-'} name=${subjectClass?.name ?? '-'}`, + ); + + if (adminToken) { + const headCreate = await api('/classes', { + method: 'POST', + token: teacherToken, + body: { name: '班主任越权班', grade: '高一' }, + }); + record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`); + + const subjectCreate = await api('/classes', { + method: 'POST', + token: teacher2Token, + body: { name: '科任越权班', grade: '高一' }, + }); + record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`); + + const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken }); + record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`); + + const headAssign = await api(`/classes/${classId}/teachers`, { + method: 'POST', + token: teacherToken, + body: { teacherId: teacher2Login.payload?.data?.user?.id }, + }); + record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`); + + const subjectAssign = await api(`/classes/${classId}/teachers`, { + method: 'POST', + token: teacher2Token, + body: { teacherId: teacher2Login.payload?.data?.user?.id }, + }); + record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`); + + // 管理员可以分配(用"先加后删"验证) + const adminAssign = await api(`/classes/${classId}/teachers`, { + method: 'POST', + token: adminToken, + body: { teacherId: teacher2Login.payload?.data?.user?.id }, + }); + record( + '管理员分配科任老师成功(201/200)', + [200, 201].includes(adminAssign.status), + `status=${adminAssign.status}`, + ); + const adminUnassign = await api( + `/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, + { + method: 'DELETE', + token: adminToken, + }, + ); + record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`); + + // 课表:班主任可管理本班,科任老师不可 + if (subjectClassId) { + const subjectSchedule = await api('/schedules', { + method: 'POST', + token: teacher2Token, + body: { + classId: subjectClassId, + courseId: courses.payload?.data?.[0]?.id, + dayOfWeek: 3, + startTime: '13:00', + endTime: '13:45', + weekStart: 1, + weekEnd: 20, + }, + }); + record( + '科任老师管理课表被拒绝(403)', + subjectSchedule.status === 403 || subjectSchedule.status === 400, + `status=${subjectSchedule.status}`, + ); + } + + const subjectHomework = await api('/homeworks', { + method: 'POST', + token: subjectClassId ? teacher2Token : teacherToken, + body: { + classId: subjectClassId, + title: '科任老师布置的作业', + content: '权限矩阵校验用', + }, + }); + record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`); + if (subjectHomework.payload?.data?.id) { + await api(`/homeworks/${subjectHomework.payload.data.id}`, { + method: 'DELETE', + token: teacher2Token, + }); + } + + const subjectGrades = await api('/grades', { + method: 'POST', + token: teacher2Token, + body: { classId: classId, userId: studentUser?.id, examName: '越权成绩', score: 90, totalScore: 100 }, + }); + record('科任老师录入成绩被拒绝(403)', subjectGrades.status === 403, `status=${subjectGrades.status}`); + + const headGrades = await api('/grades', { + method: 'POST', + token: teacherToken, + body: { + classId: classId, + userId: studentUser?.id, + examName: '班主任越权成绩', + score: 90, + totalScore: 100, + }, + }); + record( + '班主任录入成绩被拒绝(403,成绩仅管理员)', + headGrades.status === 403, + `status=${headGrades.status}`, + ); + + const adminGrades = await api('/grades', { + method: 'POST', + token: adminToken, + body: { + classId: classId, + userId: studentUser?.id, + examName: '管理员录入成绩', + score: 88, + totalScore: 100, + }, + }); + record('管理员录入成绩成功(201)', adminGrades.status === 201, `status=${adminGrades.status}`); + if (adminGrades.payload?.data?.id) { + await api(`/grades/${adminGrades.payload.data.id}`, { method: 'DELETE', token: adminToken }); + } + } + } + // ---------------------------------------------------------------- 7. 权限隔离 if (foreignClass) { const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken }); diff --git a/packages/server/src/lib/access.ts b/packages/server/src/lib/access.ts index 0e4c711..cdb128e 100644 --- a/packages/server/src/lib/access.ts +++ b/packages/server/src/lib/access.ts @@ -1,12 +1,25 @@ +import { + canManageGrades, + canManageSchedule, + canPublishContent, + canManageClasses, + canAssignTeachers, + canManageRoster, + resolveClassRole, + type ClassRole, +} from '@classhelper/shared'; import { prisma } from './db.js'; import { ApiError } from './http.js'; import type { TokenPayload } from './jwt.js'; /** * 权限(RBAC)规则: - * - ADMIN :可访问全部班级 - * - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级 - * - STUDENT :仅可查看自己所在班级的数据,且无任何写权限 + * - ADMIN :全部班级;班级增删改、人员分配、成绩、课表 + * - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人 + * - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人 + * - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据 + * + * 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。 */ export function isAdmin(user: TokenPayload): boolean { @@ -82,6 +95,75 @@ export async function assertClassWritable(user: TokenPayload, classId: string): await assertClassAccess(user, classId); } +/* ---------------------------------------------------------------- 班级内角色 */ + +/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */ +export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise { + if (user.role === 'ADMIN') return 'ADMIN'; + if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE')); + + const record = await prisma.class.findUnique({ + where: { id: classId }, + select: { + teacherId: true, + teachers: { where: { teacherId: user.sub }, select: { id: true } }, + }, + }); + if (!record) return 'NONE'; + return resolveClassRole({ + role: user.role, + isHeadTeacher: record.teacherId === user.sub, + isSubjectTeacher: record.teachers.length > 0, + }); +} + +/** 班级本身(增删改)与人员分配:仅管理员 */ +export function assertCanManageClasses(user: TokenPayload): void { + if (!canManageClasses(user.role)) { + throw ApiError.forbidden('只有管理员可以创建、修改或删除班级'); + } +} + +/** 班主任 / 科任老师分配:仅管理员 */ +export function assertCanAssignTeachers(user: TokenPayload): void { + if (!canAssignTeachers(user.role)) { + throw ApiError.forbidden('只有管理员可以分配班主任与科任老师'); + } +} + +/** 学生名单管理:仅管理员 */ +export function assertCanManageRoster(user: TokenPayload): void { + if (!canManageRoster(user.role)) { + throw ApiError.forbidden('只有管理员可以管理学生名单与账号'); + } +} + +/** 课表管理(含时间配置导入):管理员或本班班主任 */ +export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise { + const classRole = await resolveUserClassRole(user, classId); + if (!canManageSchedule(classRole)) { + throw ApiError.forbidden( + classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表', + ); + } +} + +/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */ +export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise { + await assertClassAccess(user, classId); + const classRole = await resolveUserClassRole(user, classId); + if (!canPublishContent(classRole)) { + throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限'); + } +} + +/** 成绩录入 / 修改 / 导入:仅管理员 */ +export function assertCanManageGrades(user: TokenPayload): void { + if (!canManageGrades(user.role)) { + throw ApiError.forbidden('只有管理员可以录入、修改或导入成绩'); + } +} + /** 学生的主班级,未分配班级时抛 403 */ export function requireStudentClassId(user: TokenPayload): string { if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任'); diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index 8fe4a33..fd7b80e 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -1,5 +1,5 @@ -import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared'; -import { assertClassWritable } from '../../lib/access.js'; +import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared'; +import { assertCanPublishContent } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -18,7 +18,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co * "叫人"类型,无论是否在上课都立即展开显示。 */ export async function createCall(user: TokenPayload, input: CreateCallInput): Promise { - await assertClassWritable(user, input.classId); + await assertCanPublishContent(user, input.classId); const student = await prisma.user.findUnique({ where: { id: input.studentId }, diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 05d1d76..205c916 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; @@ -38,7 +38,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { }); /** POST /api/classes - 创建班级 */ -router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createClassSchema }), async (req, res) => { +router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => { const user = getAuthUser(req); sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功'); }); diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index 354b26f..f79bf06 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -1,8 +1,10 @@ -import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared'; +import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared'; import { env } from '../../config/env.js'; import { assertClassAccess, - assertClassWritable, + assertCanAssignTeachers, + assertCanManageClasses, + assertCanManageRoster, classScopeIdFilter, resolveClassScope, } from '../../lib/access.js'; @@ -75,6 +77,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi /** 创建班级 */ export async function createClass(user: TokenPayload, input: CreateClassInput): Promise { + assertCanManageClasses(user); let teacherId = user.sub; if (input.teacherId && user.role === 'ADMIN') { const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } }); @@ -101,7 +104,7 @@ export async function updateClass( classId: string, input: UpdateClassInput, ): Promise { - await assertClassWritable(user, classId); + assertCanManageClasses(user); const updated = await prisma.class.update({ where: { id: classId }, @@ -118,7 +121,7 @@ export async function updateClass( /** 删除班级(级联删除课表/作业/通知/成绩) */ export async function deleteClass(user: TokenPayload, classId: string): Promise { - await assertClassWritable(user, classId); + assertCanManageClasses(user); emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' }); await prisma.class.delete({ where: { id: classId } }); } @@ -144,7 +147,7 @@ export async function addStudent( classId: string, input: AddStudentInput, ): Promise { - await assertClassWritable(user, classId); + assertCanManageRoster(user); const existing = await prisma.user.findUnique({ where: { username: input.username } }); let studentId: string; @@ -190,7 +193,7 @@ export async function addStudent( /** 将学生移出班级(保留账号) */ export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise { - await assertClassWritable(user, classId); + assertCanManageRoster(user); const student = await prisma.user.findUnique({ where: { id: studentId } }); if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在'); @@ -206,7 +209,7 @@ export async function removeStudent(user: TokenPayload, classId: string, student /** 分配协作教师 */ export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise { - await assertClassWritable(user, classId); + assertCanAssignTeachers(user); const teacher = await prisma.user.findUnique({ where: { id: teacherId } }); if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) { @@ -222,7 +225,7 @@ export async function assignTeacher(user: TokenPayload, classId: string, teacher /** 取消协作教师(班主任不可被移除) */ export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise { - await assertClassWritable(user, classId); + assertCanAssignTeachers(user); const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } }); if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级'); diff --git a/packages/server/src/modules/courses/courses.service.ts b/packages/server/src/modules/courses/courses.service.ts index 4b51b0d..cab8b27 100644 --- a/packages/server/src/modules/courses/courses.service.ts +++ b/packages/server/src/modules/courses/courses.service.ts @@ -1,5 +1,5 @@ -import type { CourseDto } from '@classhelper/shared'; -import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js'; +import type { CourseDto } from '@classhelper/shared'; +import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -19,7 +19,7 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise } export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise { - await assertClassWritable(user, input.classId); + await assertCanManageSchedule(user, input.classId); const created = await prisma.course.create({ data: { name: input.name, classId: input.classId, teacherId: user.sub }, @@ -35,7 +35,7 @@ export async function updateCourse( ): Promise { const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) throw ApiError.notFound('课程不存在'); - await assertClassWritable(user, course.classId); + await assertCanManageSchedule(user, course.classId); const updated = await prisma.course.update({ where: { id: courseId }, @@ -48,6 +48,6 @@ export async function updateCourse( export async function deleteCourse(user: TokenPayload, courseId: string): Promise { const course = await prisma.course.findUnique({ where: { id: courseId } }); if (!course) throw ApiError.notFound('课程不存在'); - await assertClassWritable(user, course.classId); + await assertCanManageSchedule(user, course.classId); await prisma.course.delete({ where: { id: courseId } }); } diff --git a/packages/server/src/modules/grades/grades.module.ts b/packages/server/src/modules/grades/grades.module.ts index 6f120d3..a3216db 100644 --- a/packages/server/src/modules/grades/grades.module.ts +++ b/packages/server/src/modules/grades/grades.module.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; @@ -52,7 +52,7 @@ router.get( ); /** POST /api/grades - 录入单条成绩(广播 grade:updated) */ -router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => { +router.post('/', requireRole('ADMIN'), validate({ body: createGradeSchema }), async (req, res) => { const user = getAuthUser(req); sendCreated( res, @@ -62,21 +62,16 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc }); /** POST /api/grades/bulk - 批量录入 / 导入成绩 */ -router.post( - '/bulk', - requireRole('ADMIN', 'TEACHER'), - validate({ body: bulkCreateGradeSchema }), - async (req, res) => { - const user = getAuthUser(req); - const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput); - sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`); - }, -); +router.post('/bulk', requireRole('ADMIN'), validate({ body: bulkCreateGradeSchema }), async (req, res) => { + const user = getAuthUser(req); + const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput); + sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`); +}); /** PATCH /api/grades/:id - 修改成绩 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requireRole('ADMIN'), validate({ params: idParamSchema, body: updateGradeSchema }), async (req, res) => { const user = getAuthUser(req); @@ -86,17 +81,12 @@ router.patch( ); /** DELETE /api/grades/:id - 删除成绩 */ -router.delete( - '/:id', - requireRole('ADMIN', 'TEACHER'), - validate({ params: idParamSchema }), - async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - await gradeService.deleteGrade(user, id); - sendOk(res, { id }, '成绩已删除'); - }, -); +router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + await gradeService.deleteGrade(user, id); + sendOk(res, { id }, '成绩已删除'); +}); export const gradesModule = defineModule({ name: 'grades', diff --git a/packages/server/src/modules/grades/grades.service.ts b/packages/server/src/modules/grades/grades.service.ts index 1437193..1afee42 100644 --- a/packages/server/src/modules/grades/grades.service.ts +++ b/packages/server/src/modules/grades/grades.service.ts @@ -1,4 +1,4 @@ -import { +import { SOCKET_EVENTS, gradeLevel, gradePercent, @@ -6,7 +6,7 @@ import { type GradeLevel, type GradeStats, } from '@classhelper/shared'; -import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js'; +import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -98,7 +98,7 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption } export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise { - await assertClassWritable(user, input.classId); + assertCanManageGrades(user); await assertStudentInClass(input.userId, input.classId); if (input.courseId) await assertCourseInClass(input.courseId, input.classId); @@ -119,7 +119,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput): /** 批量录入:同一个考试一次提交整班成绩 */ export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise { - await assertClassWritable(user, input.classId); + assertCanManageGrades(user); if (input.courseId) await assertCourseInClass(input.courseId, input.classId); const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date(); @@ -155,7 +155,7 @@ export async function updateGrade( ): Promise { const current = await prisma.grade.findUnique({ where: { id: gradeId } }); if (!current) throw ApiError.notFound('成绩记录不存在'); - await assertClassWritable(user, current.classId); + assertCanManageGrades(user); if (input.courseId) await assertCourseInClass(input.courseId, current.classId); const updated = await prisma.grade.update({ @@ -181,7 +181,7 @@ export async function updateGrade( export async function deleteGrade(user: TokenPayload, gradeId: string): Promise { const current = await prisma.grade.findUnique({ where: { id: gradeId } }); if (!current) throw ApiError.notFound('成绩记录不存在'); - await assertClassWritable(user, current.classId); + assertCanManageGrades(user); await prisma.grade.delete({ where: { id: gradeId } }); emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, { ...toGradeDto({ ...current, course: null, student: null, class: null }), diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index bea5181..b6197c6 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -1,7 +1,7 @@ -import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared'; +import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared'; import { + assertCanPublishContent, assertClassAccess, - assertClassWritable, classScopeWhere, isStudent, resolveClassScope, @@ -82,7 +82,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi /** 发布作业并实时推送到班级房间 */ export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise { - await assertClassWritable(user, input.classId); + await assertCanPublishContent(user, input.classId); if (input.courseId) await assertCourseInClass(input.courseId, input.classId); const created = await prisma.homework.create({ @@ -110,7 +110,7 @@ export async function updateHomework( ): Promise { const current = await prisma.homework.findUnique({ where: { id: homeworkId } }); if (!current) throw ApiError.notFound('作业不存在'); - await assertClassWritable(user, current.classId); + await assertCanPublishContent(user, current.classId); if (input.courseId) await assertCourseInClass(input.courseId, current.classId); @@ -137,7 +137,7 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr include: { course: courseSelect, creator: creatorSelect }, }); if (!current) throw ApiError.notFound('作业不存在'); - await assertClassWritable(user, current.classId); + await assertCanPublishContent(user, current.classId); await prisma.homework.delete({ where: { id: homeworkId } }); emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, { diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 80ed2ea..39b6424 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -1,7 +1,7 @@ -import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; +import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { assertClassAccess, - assertClassWritable, + assertCanPublishContent, classScopeWhere, resolveClassScope, } from '../../lib/access.js'; @@ -61,7 +61,7 @@ export async function createNotification( user: TokenPayload, input: CreateNotificationInput, ): Promise { - await assertClassWritable(user, input.classId); + await assertCanPublishContent(user, input.classId); const priority = input.priority ?? 'NORMAL'; if (priority === 'URGENT' && input.confirmDuringClass !== true) { @@ -99,7 +99,7 @@ export async function createNotification( export async function deleteNotification(user: TokenPayload, notificationId: string): Promise { const current = await prisma.notification.findUnique({ where: { id: notificationId } }); if (!current) throw ApiError.notFound('通知不存在'); - await assertClassWritable(user, current.classId); + await assertCanPublishContent(user, current.classId); await prisma.notification.delete({ where: { id: notificationId } }); } diff --git a/packages/server/src/modules/schedules/schedules.module.ts b/packages/server/src/modules/schedules/schedules.module.ts index 86e83cb..3c54ed6 100644 --- a/packages/server/src/modules/schedules/schedules.module.ts +++ b/packages/server/src/modules/schedules/schedules.module.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { resolveCurrentWeek } from '@classhelper/shared'; import { env } from '../../config/env.js'; import { sendCreated, sendOk } from '../../lib/http.js'; diff --git a/packages/server/src/modules/schedules/schedules.service.ts b/packages/server/src/modules/schedules/schedules.service.ts index d1992dc..f20c0e0 100644 --- a/packages/server/src/modules/schedules/schedules.service.ts +++ b/packages/server/src/modules/schedules/schedules.service.ts @@ -1,4 +1,4 @@ -import { +import { SOCKET_EVENTS, buildScheduleWeekView, resolveClassStatus, @@ -9,7 +9,7 @@ import { } from '@classhelper/shared'; import { assertClassAccess, - assertClassWritable, + assertCanManageSchedule, classScopeWhere, isStudent, requireStudentClassId, @@ -102,7 +102,7 @@ export async function getClassStatus( } export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise { - await assertClassWritable(user, input.classId); + await assertCanManageSchedule(user, input.classId); await assertCourseInClass(input.courseId, input.classId); const created = await prisma.schedule.create({ @@ -135,7 +135,7 @@ export async function updateSchedule( ): Promise { const current = await prisma.schedule.findUnique({ where: { id: scheduleId } }); if (!current) throw ApiError.notFound('课表记录不存在'); - await assertClassWritable(user, current.classId); + await assertCanManageSchedule(user, current.classId); if (input.courseId) await assertCourseInClass(input.courseId, current.classId); @@ -173,7 +173,7 @@ export async function updateSchedule( export async function deleteSchedule(user: TokenPayload, scheduleId: string): Promise { const current = await prisma.schedule.findUnique({ where: { id: scheduleId } }); if (!current) throw ApiError.notFound('课表记录不存在'); - await assertClassWritable(user, current.classId); + await assertCanManageSchedule(user, current.classId); await prisma.schedule.delete({ where: { id: scheduleId } }); emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, { diff --git a/packages/server/src/modules/students/students.module.ts b/packages/server/src/modules/students/students.module.ts index aa1974c..e1ae0bf 100644 --- a/packages/server/src/modules/students/students.module.ts +++ b/packages/server/src/modules/students/students.module.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; @@ -19,36 +19,26 @@ const router = Router(); router.use(authenticate()); /** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */ -router.get( - '/', - requireRole('ADMIN', 'TEACHER'), - validate({ query: listStudentsQuerySchema }), - async (req, res) => { - const user = getAuthUser(req); - const query = validatedQuery<{ classId?: string; keyword?: string }>(req); - sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功'); - }, -); +router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery<{ classId?: string; keyword?: string }>(req); + sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功'); +}); /** POST /api/students - 新建学生账号 */ -router.post( - '/', - requireRole('ADMIN', 'TEACHER'), - validate({ body: createStudentSchema }), - async (req, res) => { - const user = getAuthUser(req); - sendCreated( - res, - await studentService.createStudent(user, req.body as CreateStudentInput), - '学生账号创建成功', - ); - }, -); +router.post('/', requireRole('ADMIN'), validate({ body: createStudentSchema }), async (req, res) => { + const user = getAuthUser(req); + sendCreated( + res, + await studentService.createStudent(user, req.body as CreateStudentInput), + '学生账号创建成功', + ); +}); /** PATCH /api/students/:id - 修改学生信息 / 调班 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requireRole('ADMIN'), validate({ params: idParamSchema, body: updateStudentSchema }), async (req, res) => { const user = getAuthUser(req); @@ -64,7 +54,7 @@ router.patch( /** POST /api/students/:id/reset-password - 重置密码 */ router.post( '/:id/reset-password', - requireRole('ADMIN', 'TEACHER'), + requireRole('ADMIN'), validate({ params: idParamSchema, body: resetPasswordSchema }), async (req, res) => { const user = getAuthUser(req); @@ -75,17 +65,12 @@ router.post( ); /** DELETE /api/students/:id - 删除学生账号 */ -router.delete( - '/:id', - requireRole('ADMIN', 'TEACHER'), - validate({ params: idParamSchema }), - async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - await studentService.deleteStudent(user, id); - sendOk(res, { id }, '学生账号已删除'); - }, -); +router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + await studentService.deleteStudent(user, id); + sendOk(res, { id }, '学生账号已删除'); +}); export const studentsModule = defineModule({ name: 'students', diff --git a/packages/server/src/modules/students/students.service.ts b/packages/server/src/modules/students/students.service.ts index 59a2578..18fcacf 100644 --- a/packages/server/src/modules/students/students.service.ts +++ b/packages/server/src/modules/students/students.service.ts @@ -1,6 +1,6 @@ -import type { StudentDto } from '@classhelper/shared'; +import type { StudentDto } from '@classhelper/shared'; import { env } from '../../config/env.js'; -import { assertClassWritable, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js'; +import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -37,7 +37,7 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio /** 新建学生账号(可选直接分班) */ export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise { - if (input.classId) await assertClassWritable(user, input.classId); + if (input.classId) assertCanManageRoster(user); const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword); const created = await prisma.user.create({ @@ -75,14 +75,14 @@ export async function updateStudent( if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在'); if (student.classId) { - await assertClassWritable(user, student.classId); + assertCanManageRoster(user); } else if (!isAdmin(user)) { throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改'); } const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null); if (nextClassId && nextClassId !== student.classId) { - await assertClassWritable(user, nextClassId); + assertCanManageRoster(user); } const updated = await prisma.user.update({ @@ -119,7 +119,7 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在'); if (student.classId) { - await assertClassWritable(user, student.classId); + assertCanManageRoster(user); } else if (!isAdmin(user)) { throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除'); } @@ -137,7 +137,7 @@ export async function resetPassword( if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在'); if (student.classId) { - await assertClassWritable(user, student.classId); + assertCanManageRoster(user); } else if (!isAdmin(user)) { throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码'); } diff --git a/packages/server/src/modules/teachers/teachers.module.ts b/packages/server/src/modules/teachers/teachers.module.ts index 22b6d9c..4cf0f57 100644 --- a/packages/server/src/modules/teachers/teachers.module.ts +++ b/packages/server/src/modules/teachers/teachers.module.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; import { validate, validatedQuery } from '../../middleware/validate.js'; @@ -10,15 +10,10 @@ const router = Router(); router.use(authenticate()); /** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */ -router.get( - '/', - requireRole('ADMIN', 'TEACHER'), - validate({ query: listTeachersQuerySchema }), - async (req, res) => { - const { keyword } = validatedQuery<{ keyword?: string }>(req); - sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); - }, -); +router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => { + const { keyword } = validatedQuery<{ keyword?: string }>(req); + sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); +}); /** POST /api/teachers - 新建教师账号(仅管理员) */ router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1c1fcdc..e3afab6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,5 +3,6 @@ * 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。 */ export * from './constants.js'; +export * from './permissions.js'; export * from './utils.js'; export type * from './types.js'; diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts new file mode 100644 index 0000000..d191d4c --- /dev/null +++ b/packages/shared/src/permissions.ts @@ -0,0 +1,93 @@ +import type { UserRole } from './types.js'; + +/** + * 班级内的角色(相对"某一个班级"而言)。 + * + * - `ADMIN` 系统管理员:班级增删改、人员分配、全部数据 + * - `HEAD` 班主任(`Class.teacherId`):本班课表 + 作业/通知/叫人 + * - `SUBJECT` 科任老师(`ClassTeacher`):仅作业/通知/叫人 + * - `STUDENT` 学生(个人账号或班级账号):只读 + * - `NONE` 与该班级无关 + */ +export type ClassRole = 'ADMIN' | 'HEAD' | 'SUBJECT' | 'STUDENT' | 'NONE'; + +export interface ClassRoleContext { + role: UserRole; + /** 是否是该班班主任(Class.teacherId === 当前用户) */ + isHeadTeacher?: boolean; + /** 是否被分配为该班科任/协作老师(ClassTeacher 中存在当前用户) */ + isSubjectTeacher?: boolean; +} + +/** 依据账号角色与该班归属关系,解析出班级内角色 */ +export function resolveClassRole(context: ClassRoleContext): ClassRole { + if (context.role === 'ADMIN') return 'ADMIN'; + if (context.role === 'TEACHER') { + if (context.isHeadTeacher) return 'HEAD'; + if (context.isSubjectTeacher) return 'SUBJECT'; + return 'NONE'; + } + if (context.role === 'STUDENT') return 'STUDENT'; + return 'NONE'; +} + +/** 班级本身(创建/修改/删除)与人员分配:仅管理员 */ +export function canManageClasses(role: UserRole): boolean { + return role === 'ADMIN'; +} + +/** 班主任与科任老师名单的编辑:仅管理员 */ +export function canAssignTeachers(role: UserRole): boolean { + return role === 'ADMIN'; +} + +/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */ +export function canManageRoster(role: UserRole): boolean { + return role === 'ADMIN'; +} + +/** 课表管理:管理员或本班班主任(科任老师不可以) */ +export function canManageSchedule(classRole: ClassRole): boolean { + return classRole === 'ADMIN' || classRole === 'HEAD'; +} + +/** 课程(课表的前置数据):与课表同权限 */ +export function canManageCourses(classRole: ClassRole): boolean { + return canManageSchedule(classRole); +} + +/** 布置作业 / 叫人 / 发通知:管理员、班主任、科任老师皆可(学生不可) */ +export function canPublishContent(classRole: ClassRole): boolean { + return classRole === 'ADMIN' || classRole === 'HEAD' || classRole === 'SUBJECT'; +} + +/** 成绩录入/修改/导入:仅管理员(按需求 7 的权限清单) */ +export function canManageGrades(role: UserRole): boolean { + return role === 'ADMIN'; +} + +/** 可导入的数据类型 */ +export type ImportKind = 'grades' | 'students' | 'scheduleTimes'; + +/** 导入权限:成绩=管理员;名单=管理员;课表时间=管理员或本班班主任 */ +export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind): boolean { + if (kind === 'scheduleTimes') return canManageSchedule(classRole); + return canManageGrades(role); +} + +/** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */ +export const PERMISSION_MATRIX: readonly { + action: string; + admin: boolean; + head: boolean; + subject: 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 }, + { action: '布置作业', admin: true, head: true, subject: true }, + { action: '叫人', admin: true, head: true, subject: true }, + { action: '发布通知', admin: true, head: true, subject: true }, + { action: '成绩录入 / 修改 / 导入', admin: true, head: false, subject: false }, +]; diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 9fc75de..2de4757 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -14,16 +14,28 @@ const auth = useAuthStore(); const realtime = useRealtimeStore(); const { isMobile } = useResponsive(); +/** + * 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致): + * - 班级管理 / 学生管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限) + * - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮) + * - 作业 / 通知:所有教师 + * - 成绩录入:仅管理员(成绩写入仅管理员;教师仍可在仪表盘查看汇总) + */ const menuItems = [ - { path: '/dashboard', title: '仪表盘', icon: 'Odometer' }, - { path: '/classes', title: '班级管理', icon: 'School' }, - { path: '/students', title: '学生管理', icon: 'User' }, - { path: '/schedules', title: '课表管理', icon: 'Calendar' }, - { path: '/homeworks', title: '作业发布', icon: 'Notebook' }, - { path: '/notifications', title: '通知发布', icon: 'Bell' }, - { path: '/grades', title: '成绩录入', icon: 'Trophy' }, + { path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] }, + { path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] }, + { path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] }, + { path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] }, + { path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] }, + { path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] }, + { path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN'] }, ]; +/** 当前账号可见的菜单(教师看不到班级/学生/成绩录入等无权入口) */ +const visibleMenuItems = computed(() => + menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)), +); + /** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */ const activeMenu = computed(() => route.path); @@ -129,7 +141,7 @@ async function submitPassword(): Promise { 班级小助手 - + {{ item.title }} @@ -217,7 +229,7 @@ async function submitPassword(): Promise { 班级小助手 - + {{ item.title }} diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index 858b0fa..8148291 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -1,4 +1,4 @@ -