From 1073436ad63a7c136b6c8648edf480ff51541a26 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 12 Sep 2026 15:51:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(class-account):=20=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E7=AB=AF=E4=B8=BB=E4=BD=93=E6=94=B9=E4=B8=BA=E7=8F=AD=E7=BA=A7?= =?UTF-8?q?=EF=BC=88=E7=8F=AD=E7=BA=A7=E7=A0=81=20+=20=E7=8F=AD=E7=BA=A7?= =?UTF-8?q?=E5=AF=86=E7=A0=81=EF=BC=89+=20=E6=95=B0=E6=8D=AE=E6=8C=89?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端: - Class 新增 code(唯一)/passwordHash 与迁移(老数据回填 C00001 形式, 未设密码禁止登录) - POST /auth/class-login: 班级码 + 班级密码 -> classSession 会话(sub=classId) - PATCH /classes/:id/class-account: 管理员改班级码/重置密码(唯一性、格式校验) - auth 中间件/socket 握手支持班级会话(sub 为 classId, 回查 Class 表, 加入 user:{classId} 房间) - lib/session.ts: 班级设备代全班操作(通知已读/作业完成/成绩总览按全班读写), 普通学生账号行为不变 - 叫人消息同时广播到 user:{classId}, 班级设备立即上岛 - 班级列表/详情按权限返回班级码与密码状态(哈希不外泄) 客户端: - 登录页改为班级码 + 班级密码(个人学生入口移除, /auth/login 接口保留兼容) - 作业按钮/成绩标题按班级会话显示(全班标记完成 / 本班成绩) - 冒烟脚本自动准备班级账号凭据(兼容全新与升级安装) Web 管理端: - 班级管理新增「班级账号」列与设置弹窗(改班级码/重置密码/随机生成) - 新建班级可选自定义班级码, 默认由服务端生成 验证: verify:e2e 125/125(新增班级账号 18 项) verify:web 18/18 verify:desktop 40/40 (login=高一(1)班 classSession=true) 文档: README 新增「学生端班级账号」章节、API/迁移/验收/默认账号更新; 安装包 README 同步 --- README.md | 137 +++++++--- deploy/package.runtime.json | 1 + packages/desktop-client/scripts/smoke.mjs | 53 ++++ packages/desktop-client/src/main/smoke.ts | 5 +- .../desktop-client/src/renderer/api/index.ts | 4 + .../src/renderer/cache/self-test.ts | 15 +- .../src/renderer/stores/auth.ts | 28 +- .../src/renderer/views/GradeView.vue | 6 +- .../src/renderer/views/HomeworkView.vue | 24 +- .../src/renderer/views/LoginView.vue | 31 ++- .../desktop-client/src/types/desktop.d.ts | 5 +- .../migration.sql | 20 ++ packages/server/prisma/schema.prisma | 4 + packages/server/prisma/seed.ts | 19 +- packages/server/scripts/verify-e2e.mjs | 240 ++++++++++++++++++ packages/server/src/config/env.ts | 4 + packages/server/src/lib/class-account.ts | 134 ++++++++++ packages/server/src/lib/http.ts | 2 + packages/server/src/lib/jwt.ts | 6 + packages/server/src/lib/mappers.ts | 26 +- packages/server/src/lib/session.ts | 53 ++++ packages/server/src/middleware/auth.ts | 25 ++ .../server/src/modules/auth/auth.module.ts | 27 +- .../server/src/modules/auth/auth.schemas.ts | 7 + .../server/src/modules/auth/auth.service.ts | 110 ++++++-- .../server/src/modules/calls/calls.service.ts | 9 +- .../src/modules/classes/classes.module.ts | 23 +- .../src/modules/classes/classes.schemas.ts | 21 ++ .../src/modules/classes/classes.service.ts | 76 +++++- .../modules/dashboard/dashboard.service.ts | 20 +- .../src/modules/grades/grades.service.ts | 18 +- .../modules/homeworks/homeworks.service.ts | 58 ++++- .../notifications/notifications.service.ts | 64 +++-- packages/server/src/realtime/socket.ts | 6 +- packages/shared/src/types.ts | 34 ++- packages/web-admin/src/api/index.ts | 4 + packages/web-admin/src/views/ClassesView.vue | 124 ++++++++- scripts/dist-server.mjs | 3 +- 38 files changed, 1289 insertions(+), 157 deletions(-) create mode 100644 packages/server/prisma/migrations/20260912130000_add_class_account/migration.sql create mode 100644 packages/server/src/lib/class-account.ts create mode 100644 packages/server/src/lib/session.ts diff --git a/README.md b/README.md index f4b97b7..da24800 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ pnpm build:desktop pnpm verify:desktop ``` -客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,可用学生账号 `student01 / student123` 登录。 +客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**(种子数据:`G101` / `123456`)即可进入班级。 若要验证"断网可查看缓存",登录并浏览过程序后关闭后端(`Ctrl+C` 停掉 `pnpm dev:server`), 客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。 @@ -171,33 +171,39 @@ pnpm verify:desktop ```bash # 另开一个终端先启动后端:pnpm dev:server -pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入:107 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:125 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚**等 107 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(登录 + 四类数据 + Socket.IO),实测 26/26 通过。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 40/40 通过。 ## 默认账号与种子数据 `pnpm db:seed` 会清空业务表并写入一套完整演示数据(1 管理员 / 2 教师 / 3 班级 / 15 学生 / 15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。 -| 角色 | 用户名 | 密码 | 说明 | -| ------ | ------------------------- | ------------ | ------------------------------------------ | -| 管理员 | `admin` | `admin123` | 可访问全部班级 | -| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | -| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | -| 学生 | `student01` | `student123` | 高一(1)班 | -| 学生 | `student02` … `student05` | `student123` | 高一(1)班 | -| 学生 | `student06` … `student10` | `student123` | 高一(2)班 | -| 学生 | `student11` … `student15` | `student123` | 高二(3)班 | +| 角色 | 用户名 | 密码 | 说明 | +| ------ | ------------------------- | ------------ | ------------------------------------------- | +| 管理员 | `admin` | `admin123` | 可访问全部班级 | +| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | +| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | +| 学生 | `student01` | `student123` | 高一(1)班 | +| 学生 | `student02` … `student05` | `student123` | 高一(1)班 | +| 学生 | `student06` … `student10` | `student123` | 高一(2)班 | +| 学生 | `student11` … `student15` | `student123` | 高二(3)班 | +| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端班级登录**,非个人账号) | +| 班级 | 班级码 `G102` | `123456` | 高一(2)班 | +| 班级 | 班级码 `G203` | `123456` | 高二(3)班 | - 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`), 教师也可在「学生管理」里一键重置为默认密码。 +- **班级账号**(学生端主入口)用班级码 + 班级密码登录,默认密码由 `DEFAULT_CLASS_PASSWORD` 配置(默认 `123456`); + 管理员可在「班级管理 → 班级账号」里改班级码或重置密码,详见 + [学生端班级账号](#学生端班级账号学生端主体--班级)。 - 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。 ## 常用命令 @@ -531,6 +537,49 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) 回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); `verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。 +## 学生端班级账号(学生端主体 = 班级) + +需求:学生端不再以"个人学生"为登录主体,改为**班级账号(班级设备)**登录,数据按班级隔离。 + +### 账号模型 + +| 项目 | 说明 | +| -------- | ------------------------------------------------------------------------------------ | +| 登录凭据 | 班级码(`Class.code`,唯一、4~16 位字母数字、大小写不敏感)+ 班级密码(bcrypt 哈希) | +| 会话形态 | JWT `{ sub: , classId: , role: 'STUDENT', classSession: true }` | +| 未设密码 | `passwordHash = null` → 班级登录被拒(403 `CLASS_PASSWORD_NOT_SET`),提示管理员设置 | +| 管理入口 | Web 管理端「班级管理」→ 行内「班级账号」按钮(改班级码 / 重置密码),仅管理员 | +| 向后兼容 | 个人学生账号(`student01`…)与 `/auth/login` 完全保留,接口层不受影响 | + +### 数据按班级隔离 + +- `GET /classes`:班级会话只返回自己所在班级(跨班请求一律 403); +- 作业 / 通知 / 课表 / 成绩:全部按班级收敛,班级会话无法访问其他班级; +- 发布类接口(通知、作业、成绩、课表、叫人):班级会话是 `STUDENT`,一律 403; +- 成绩:`GET /grades/my` 对班级会话返回**全班成绩总览**(客户端标题自动变为「本班成绩」)。 + +### 班级设备代全班操作(个人数据语义) + +班级设备代表整个班级,因此"个人记账"类操作由服务端一次性写入**全班学生**: + +| 操作 | 行为 | 教师端看到的结果 | +| ----------------------------- | ------------------------------------------------- | --------------------- | +| 通知「标为已读」/「全部已读」 | 为全班学生写入 `NotificationRead`(已存在的跳过) | 已读人数 = 班级学生数 | +| 作业「全班标记完成」 | 为全班学生 upsert `HomeworkStatus.completed` | 完成人数 = 班级学生数 | +| 叫人「收到」 | 复用通知已读机制(同上) | 该生已读 | + +读状态判定统一使用 `userId in 全班学生`(`packages/server/src/lib/session.ts` 的 `resolvePersonalIds()`), +因此普通学生账号仍然只影响自己,行为与改造前完全一致。 + +### 实现位置 + +- `packages/server/src/lib/class-account.ts`:班级码校验/生成、密码设置与重置、登录校验、自助改密码; +- `packages/server/src/lib/session.ts`:`isClassSession` / `resolvePersonalIds` / `personalIdWhere`; +- `packages/server/src/middleware/auth.ts`:班级会话回查 `Class` 表(而不是 `User` 表); +- `packages/server/src/realtime/socket.ts`:班级会话以班级 id 加入 `user:{classId}` 房间,叫人消息直达班级设备; +- `packages/web-admin/src/views/ClassesView.vue`:班级账号列 + 设置/重置弹窗; +- `packages/desktop-client/src/renderer/views/LoginView.vue`:班级码 + 班级密码登录页(不再展示个人账号入口)。 + ## 导入(成绩 / 名单表格 + ClassIsland 时间配置) 三条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果, @@ -586,13 +635,15 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) | 方法 | 路径 | 权限 | 说明 | | --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- | -| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | +| POST | `/auth/login` | 公开 | 登录(教师 / 管理员 / 个人学生),返回 token + 用户信息 | +| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | | PATCH | `/auth/password` | 登录 | 修改自己的密码 | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | -| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | +| POST / PATCH / DELETE | `/classes` `/classes/:id` | 管理员 | 班级增删改(创建时自动生成班级码 = 班级账号) | +| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | | GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | | DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | | POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | @@ -732,7 +783,7 @@ pnpm db:migrate && pnpm db:seed 数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade` / -`TimeLayout`(共 12 张)。 +`TimeLayout`(共 12 张);`Class.code` / `Class.passwordHash` 是班级账号字段。 ### 启动时迁移(安装版自动升级) @@ -747,25 +798,28 @@ pnpm db:migrate && pnpm db:seed ## 验收标准对照 -| 验收项 | 结果 | 证据 | -| ------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------- | -| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | -| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | -| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | -| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | -| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | -| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | -| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | -| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | -| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | -| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | -| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | -| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") | -| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 | -| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 | -| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 | -| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------------- | +| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | +| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | +| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | +| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | +| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | +| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | +| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | +| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | +| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | +| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | +| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | +| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") | +| 成绩 / 名单表格导入(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 / 密码重置 | +| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 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`(见下表) | +| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | 打包产物验证(对最终 EXE 实测,非仅开发产物): @@ -780,7 +834,7 @@ pnpm db:migrate && pnpm db:seed | 验证 | 结果 | | ----------------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **107/107** ✅ | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **125/125** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | @@ -897,6 +951,17 @@ pnpm db:migrate && pnpm db:seed | `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) | | `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 | +阶段 10(学生端班级账号)新增文件: + +| 路径 | 说明 | +| --------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `packages/server/src/lib/class-account.ts` | 班级码校验/生成、班级密码设置与重置、登录校验、班级账号自助改密码 | +| `packages/server/src/lib/session.ts` | `isClassSession` / `resolvePersonalIds` / `personalIdWhere`(全班范围) | +| `packages/server/prisma/migrations/20260912130000_add_class_account/` | `Class.code` / `Class.passwordHash` 迁移(老数据回填 `C00001` 形式) | +| `packages/desktop-client/src/renderer/views/LoginView.vue` | 班级码 + 班级密码登录页(不再展示个人学生账号入口) | +| `packages/web-admin/src/views/ClassesView.vue`(班级账号列与弹窗) | 管理员设置班级码 / 重置班级密码 | +| `packages/desktop-client/scripts/smoke.mjs`(班级账号准备) | 冒烟前通过管理端接口准备可用班级凭据,兼容全新安装与升级安装 | + ## 后续可选增强 1. 代码签名证书(消除 SmartScreen 提示)。 diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index ddf5fed..73dcb44 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -17,6 +17,7 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "socket.io": "^4.8.3", + "xlsx": "0.18.5", "zod": "^4.6.2" } } diff --git a/packages/desktop-client/scripts/smoke.mjs b/packages/desktop-client/scripts/smoke.mjs index b805233..cee0efb 100644 --- a/packages/desktop-client/scripts/smoke.mjs +++ b/packages/desktop-client/scripts/smoke.mjs @@ -57,6 +57,57 @@ if (online === undefined) { ); } +/** + * 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录): + * 用管理员账号登录 → 取第一个班级 → 重置成冒烟专用密码(必要时补一个班级码)。 + * 这样无论被测实例是全新安装还是升级安装,冒烟都能拿到可用凭据。 + */ +async function provisionClassAccount() { + const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api'; + const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin'; + const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123'; + const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456'; + try { + const loginResponse = await fetch(`${base}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password: adminPassword }), + }); + const login = await loginResponse.json(); + const token = login?.data?.token; + if (!token) return null; + + const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; + const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders }); + const classes = (await classesResponse.json())?.data ?? []; + const target = classes[0]; + if (!target) return null; + + const code = target.code || `SMOKE${Math.floor(Math.random() * 9000 + 1000)}`; + const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, { + method: 'PATCH', + headers: authHeaders, + body: JSON.stringify({ code, password }), + }); + if (!patchResponse.ok) return null; + + const patched = (await patchResponse.json())?.data; + return { code: patched?.code ?? code, password, className: target.name }; + } catch { + return null; + } +} + +let classCredentials = null; +if (online === '1') { + classCredentials = await provisionClassAccount(); + console.log( + classCredentials + ? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})` + : '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据', + ); +} + const child = spawn(electronPath, ['.'], { cwd: root, stdio: 'inherit', @@ -64,6 +115,8 @@ const child = spawn(electronPath, ['.'], { ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online, ELECTRON_SMOKE_PROFILE: profileDir, + ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '', + ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '', }), }); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index fea451f..710698f 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -908,10 +908,13 @@ export async function runSmokeTest(win: BrowserWindow): Promise { // 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用) if (process.env.ELECTRON_SMOKE_ONLINE === '1') { + // 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env) + const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? ''; + const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? ''; const online = await win.webContents.executeJavaScript( `(async () => { if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; - return await window.__classhelperSmoke__.onlineScenario(); + return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })}); })()`, ); record( diff --git a/packages/desktop-client/src/renderer/api/index.ts b/packages/desktop-client/src/renderer/api/index.ts index c1a1b46..4a7ec1d 100644 --- a/packages/desktop-client/src/renderer/api/index.ts +++ b/packages/desktop-client/src/renderer/api/index.ts @@ -17,7 +17,11 @@ import { api } from './http.js'; /** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */ export const authApi = { + /** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */ login: (payload: LoginRequest): Promise => api.post(API_PATHS.auth.login, payload), + /** 班级账号登录(学生端主入口):班级码 + 班级密码 */ + classLogin: (payload: { code: string; password: string }): Promise => + api.post('/auth/class-login', payload), logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}), me: (): Promise => api.get(API_PATHS.auth.me), }; diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index ed4c387..92fb606 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -1,4 +1,4 @@ -import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; +import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; import { fetchWithCache } from './index.js'; export interface SmokeCheckResult { @@ -127,7 +127,10 @@ export async function layoutNavigationSelfTest(): Promise { * 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。 * 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。 */ -export async function onlineScenario(): Promise { +export async function onlineScenario(credentials?: { + code: string; + password: string; +}): Promise { const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([ import('../stores/auth.js'), import('../stores/app.js'), @@ -144,8 +147,14 @@ export async function onlineScenario(): Promise { const detail: string[] = []; try { - await auth.login(serverUrl, 'student01', 'student123'); + // 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入 + const code = credentials?.code ?? 'G101'; + const password = credentials?.password ?? '123456'; + if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101'); + + await auth.login(serverUrl, code, password); detail.push(`login=${auth.user?.name ?? '-'}`); + detail.push(`classSession=${auth.isClassSession}`); // 进入主布局(后续的侧边栏点击测试依赖布局已挂载) const { router } = await import('../router/index.js'); diff --git a/packages/desktop-client/src/renderer/stores/auth.ts b/packages/desktop-client/src/renderer/stores/auth.ts index e99069e..e0730f3 100644 --- a/packages/desktop-client/src/renderer/stores/auth.ts +++ b/packages/desktop-client/src/renderer/stores/auth.ts @@ -1,6 +1,6 @@ import { computed, ref } from 'vue'; import { defineStore } from 'pinia'; -import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared'; +import { STORAGE_KEYS, type LoginResponse, type SessionUser } from '@classhelper/shared'; import { authApi } from '../api/index.js'; import { setApiBaseUrl, setTokenProvider } from '../api/http.js'; import { cacheGet, cacheSet } from '../cache/db.js'; @@ -15,13 +15,17 @@ const PROFILE_KEY = 'me'; */ export const useAuthStore = defineStore('auth', () => { const token = ref(null); - const user = ref(null); + const user = ref(null); const loading = ref(false); const offlineSession = ref(false); const isAuthenticated = computed(() => Boolean(token.value)); const displayName = computed(() => user.value?.name ?? '未登录'); const classId = computed(() => user.value?.classId ?? null); + /** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */ + const isClassSession = computed(() => user.value?.classSession === true); + /** 班级码(登录账号,便于设置页展示) */ + const classCode = computed(() => user.value?.classCode ?? ''); setTokenProvider(() => token.value); @@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => { await window.desktop.saveConfig({ serverUrl, username, token: nextToken }); } - /** 登录:写主进程配置 + 缓存资料 */ - async function login(serverUrl: string, username: string, password: string): Promise { + /** + * 班级账号登录(学生端主入口):班级码 + 班级密码。 + * + * 需求 1:学生端以「班级」为主体,个人学生不再是登录主体; + * 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。 + * 保存的 username 字段记录班级码,便于下次预填。 + */ + async function login(serverUrl: string, code: string, password: string): Promise { const normalized = normalizeServerUrl(serverUrl); if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000'); setApiBaseUrl(normalized); loading.value = true; try { - const payload: LoginResponse = await authApi.login({ username, password }); + const payload: LoginResponse = await authApi.classLogin({ code, password }); applyToken(payload.token); - user.value = payload.user as StudentDto; + user.value = payload.user; offlineSession.value = false; await cacheSet('profile', PROFILE_KEY, user.value); - await persistSession(normalized, username, payload.token); + await persistSession(normalized, code, payload.token); } finally { loading.value = false; } @@ -80,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => { } } - const cachedProfile = await cacheGet('profile', PROFILE_KEY).catch(() => null); + const cachedProfile = await cacheGet('profile', PROFILE_KEY).catch(() => null); if (cachedProfile) user.value = cachedProfile.value; // 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据 @@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => { isAuthenticated, displayName, classId, + isClassSession, + classCode, login, restore, refreshProfile, diff --git a/packages/desktop-client/src/renderer/views/GradeView.vue b/packages/desktop-client/src/renderer/views/GradeView.vue index 7dbcd58..5f251f7 100644 --- a/packages/desktop-client/src/renderer/views/GradeView.vue +++ b/packages/desktop-client/src/renderer/views/GradeView.vue @@ -12,9 +12,12 @@ import { import { gradeApi } from '../api/index.js'; import { fetchWithCache } from '../cache/index.js'; import { useAppStore } from '../stores/app.js'; +import { useAuthStore } from '../stores/auth.js'; import { useRealtimeStore } from '../stores/realtime.js'; const appStore = useAppStore(); +/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */ +const auth = useAuthStore(); const realtime = useRealtimeStore(); const loading = ref(false); @@ -130,9 +133,10 @@ onUnmounted(() => {
diff --git a/packages/desktop-client/src/renderer/views/LoginView.vue b/packages/desktop-client/src/renderer/views/LoginView.vue index ec847cf..cce954c 100644 --- a/packages/desktop-client/src/renderer/views/LoginView.vue +++ b/packages/desktop-client/src/renderer/views/LoginView.vue @@ -14,7 +14,7 @@ const auth = useAuthStore(); const realtime = useRealtimeStore(); const formRef = ref(); -const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' }); +const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' }); const testing = ref(false); const testResult = ref<'ok' | 'fail' | null>(null); @@ -29,7 +29,7 @@ const rules: FormRules = { trigger: 'blur', }, ], - username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], + code: [{ required: true, message: '请输入班级码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }], }; @@ -38,7 +38,7 @@ onMounted(async () => { if (window.desktop) { const config = await window.desktop.getConfig(); if (config.serverUrl) form.serverUrl = config.serverUrl; - if (config.username) form.username = config.username; + if (config.username) form.code = config.username; } }); @@ -66,10 +66,10 @@ async function submit(): Promise { if (!valid) return; try { - await auth.login(form.serverUrl, form.username.trim(), form.password); + await auth.login(form.serverUrl, form.code.trim(), form.password); await appStore.init(form.serverUrl); if (auth.token) realtime.connect(appStore.serverUrl, auth.token); - ElMessage.success(`欢迎回来,${auth.displayName}`); + ElMessage.success(`已进入 ${auth.displayName}`); await router.replace('/schedule'); } catch (error) { ElMessage.error(error instanceof Error ? error.message : '登录失败'); @@ -92,7 +92,7 @@ async function enterOffline(): Promise {

班级小助手

- +
@@ -105,19 +105,24 @@ async function enterOffline(): Promise { - - + + - + @@ -156,9 +161,9 @@ async function enterOffline(): Promise { diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index ecb62c1..8599e45 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -86,7 +86,10 @@ declare global { cacheSelfTest(): Promise<{ ok: boolean; detail: string }>; offlineScenario(): Promise<{ ok: boolean; detail: string }>; layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>; - onlineScenario(): Promise<{ ok: boolean; detail: string }>; + onlineScenario(credentials?: { + code: string; + password: string; + }): Promise<{ ok: boolean; detail: string }>; /** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */ islandRealtimeScenario(): Promise<{ ok: boolean; diff --git a/packages/server/prisma/migrations/20260912130000_add_class_account/migration.sql b/packages/server/prisma/migrations/20260912130000_add_class_account/migration.sql new file mode 100644 index 0000000..65625cf --- /dev/null +++ b/packages/server/prisma/migrations/20260912130000_add_class_account/migration.sql @@ -0,0 +1,20 @@ +-- 班级账号(班级码 + 班级密码):学生端以「班级」为主体登录 +-- +-- 说明: +-- 1) code 是新增的必填唯一列。SQLite 无法直接给已有表加 "NOT NULL 且无默认值" 的列, +-- 因此先带默认值 '' 加上,再用 rowid 回填一版可用且唯一的班级码,最后建唯一索引。 +-- 2) passwordHash 允许为 NULL:尚未设置班级密码的班级不允许班级登录, +-- 管理员可在「班级管理」里设置或重置(bcrypt 哈希由服务端生成)。 +-- 3) 迁移逐条执行且忽略"已存在"错误,因此本文件重复执行是安全的。 + +-- AlterTable +ALTER TABLE "Class" ADD COLUMN "code" TEXT NOT NULL DEFAULT ''; + +-- AlterTable +ALTER TABLE "Class" ADD COLUMN "passwordHash" TEXT; + +-- Backfill:老数据按 rowid 生成 C00001 形式的班级码(管理员可随时改) +UPDATE "Class" SET "code" = 'C' || substr('00000' || CAST(rowid AS TEXT), -5) WHERE "code" = ''; + +-- CreateIndex +CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index a3b6ebd..4d1d69f 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -54,6 +54,10 @@ model Class { name String grade String teacherId String + /// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写) + code String @unique + /// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录 + passwordHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts index b86fef6..eb8b65b 100644 --- a/packages/server/prisma/seed.ts +++ b/packages/server/prisma/seed.ts @@ -84,10 +84,12 @@ async function main(): Promise { const random = createRandom(20260901); // 同一角色共用密码,只需各哈希一次 - const [adminHash, teacherHash, studentHash] = await Promise.all([ + const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([ hashPassword('admin123'), hashPassword('teacher123'), hashPassword('student123'), + // 班级账号默认密码(学生端「班级登录」) + hashPassword('123456'), ]); const admin = await prisma.user.create({ @@ -101,16 +103,23 @@ async function main(): Promise { data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash }, }); + // 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置) const classSeeds = [ - { name: '高一(1)班', grade: '高一', teacherId: teacher1.id }, - { name: '高一(2)班', grade: '高一', teacherId: teacher2.id }, - { name: '高二(3)班', grade: '高二', teacherId: teacher1.id }, + { name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' }, + { name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' }, + { name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' }, ]; const classes = []; for (const [index, seed] of classSeeds.entries()) { const created = await prisma.class.create({ - data: { name: seed.name, grade: seed.grade, teacherId: seed.teacherId }, + data: { + name: seed.name, + grade: seed.grade, + teacherId: seed.teacherId, + code: seed.code, + passwordHash: classPasswordHash, + }, }); classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' }); } diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 7fdf7c9..bf592ed 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1234,6 +1234,246 @@ async function main() { record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`); } + // ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级) + // 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩) + // 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。 + const classCode = `E2E${Date.now().toString(36).slice(-4).toUpperCase()}`; + const setAccount = await api(`/classes/${classId}/class-account`, { + method: 'PATCH', + token: adminToken, + body: { code: classCode, password: 'class123' }, + }); + record( + '管理员设置班级账号(班级码 + 班级密码)', + setAccount.status === 200 && setAccount.payload?.data?.code === classCode, + `status=${setAccount.status} code=${setAccount.payload?.data?.code}`, + ); + + const classLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: classCode, password: 'class123' }, + }); + const classToken = classLogin.payload?.data?.token; + const classUser = classLogin.payload?.data?.user; + record( + '班级账号登录成功(主体为班级)', + classLogin.status === 200 && + Boolean(classToken) && + classUser?.classSession === true && + classUser?.id === classId && + classUser?.classId === classId, + `status=${classLogin.status} classSession=${classUser?.classSession} name=${classUser?.name}`, + ); + + const lowerLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: classCode.toLowerCase(), password: 'class123' }, + }); + record('班级码大小写不敏感', lowerLogin.status === 200, `status=${lowerLogin.status}`); + + const wrongClassPassword = await api('/auth/class-login', { + method: 'POST', + body: { code: classCode, password: 'bad-class-password' }, + }); + record( + '班级密码错误被拒绝(401)', + wrongClassPassword.status === 401, + `status=${wrongClassPassword.status}`, + ); + + const unknownCode = await api('/auth/class-login', { + method: 'POST', + body: { code: 'NOSUCHCODE', password: 'class123' }, + }); + record('未知班级码被拒绝(401)', unknownCode.status === 401, `status=${unknownCode.status}`); + + const classMe = await api('/auth/me', { token: classToken }); + record( + '班级会话 /auth/me 返回班级信息', + classMe.status === 200 && + classMe.payload?.data?.classSession === true && + classMe.payload?.data?.id === classId, + `status=${classMe.status} name=${classMe.payload?.data?.name}`, + ); + + const classClasses = await api('/classes', { token: classToken }); + const classVisible = classClasses.payload?.data ?? []; + record( + '班级账号只能看到自己班级', + classVisible.length === 1 && classVisible[0]?.id === classId, + `共 ${classVisible.length} 个(${classVisible.map((item) => item.name).join(',')})`, + ); + + if (foreignClass) { + const classCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: classToken }); + record( + '班级账号跨班查询被拒绝(403)', + classCrossHomework.status === 403, + `status=${classCrossHomework.status}`, + ); + } + + const classPublish = await api('/notifications', { + method: 'POST', + token: classToken, + body: { classId, title: '班级账号越权发布', content: '不应成功' }, + }); + record('班级账号发布通知被拒绝(403)', classPublish.status === 403, `status=${classPublish.status}`); + + const classGradesWrite = await api('/grades', { + method: 'POST', + token: classToken, + body: { classId, userId: studentUser?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 }, + }); + record( + '班级账号录入成绩被拒绝(403)', + classGradesWrite.status === 403, + `status=${classGradesWrite.status}`, + ); + + const classScheduleWrite = await api('/schedules', { + method: 'POST', + token: classToken, + body: { classId, courseId, dayOfWeek: 4, startTime: '14:00', endTime: '14:45' }, + }); + record( + '班级账号修改课表被拒绝(403)', + classScheduleWrite.status === 403, + `status=${classScheduleWrite.status}`, + ); + + // 班级设备代全班操作:通知已读 + const classNameNotice = await api('/notifications', { + method: 'POST', + token: teacherToken, + body: { classId, title: '班级账号已读回归', content: '验证班级设备代全班标记已读' }, + }); + const classNameNoticeId = classNameNotice.payload?.data?.id; + const beforeUnread = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken }); + const classRead = await api(`/notifications/${classNameNoticeId}/read`, { + method: 'POST', + token: classToken, + }); + const afterUnread = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken }); + const unreadBefore = beforeUnread.payload?.data?.count ?? 0; + const unreadAfter = afterUnread.payload?.data?.count ?? -1; + record( + '班级设备标记已读 = 全班已读(未读数 -1)', + classRead.status === 200 && + (classRead.payload?.data?.marked ?? 0) === classmates.length && + unreadBefore > 0 && + unreadAfter === unreadBefore - 1, + `写入 ${classRead.payload?.data?.marked} 条(班级 ${classmates.length} 人)未读 ${unreadBefore} → ${unreadAfter}`, + ); + + const classReadAll = await api('/notifications/read-all', { + method: 'POST', + token: classToken, + body: { classId }, + }); + const unreadAfterAll = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken }); + record( + '班级设备全部已读 = 全班全部已读(未读归零)', + classReadAll.status === 200 && (unreadAfterAll.payload?.data?.count ?? -1) === 0, + `标记 ${classReadAll.payload?.data?.marked} 条,剩余未读 ${unreadAfterAll.payload?.data?.count}`, + ); + + const staffNotice = await api( + `/notifications?classId=${classId}&keyword=${encodeURIComponent('班级账号已读回归')}`, + { token: teacherToken }, + ); + const staffNoticeView = (staffNotice.payload?.data ?? [])[0]; + record( + '教师端看到的已读人数与全班一致', + staffNoticeView?.readCount === classmates.length, + `readCount=${staffNoticeView?.readCount} / 学生数=${classmates.length}`, + ); + + // 班级设备代全班操作:作业完成 + const classHomework = await api('/homeworks', { + method: 'POST', + token: teacherToken, + body: { classId, title: '班级账号完成回归', content: '验证班级设备代全班标记完成' }, + }); + const classHomeworkId = classHomework.payload?.data?.id; + const classDone = await api(`/homeworks/${classHomeworkId}/status`, { + method: 'PATCH', + token: classToken, + body: { completed: true }, + }); + const teacherHomeworkView = await api(`/homeworks/${classHomeworkId}`, { token: teacherToken }); + const classPending = await api(`/homeworks?classId=${classId}&pendingOnly=true`, { token: classToken }); + const stillPending = (classPending.payload?.data ?? []).some((item) => item.id === classHomeworkId); + record( + '班级设备标记完成 = 全班完成(教师端完成人数一致)', + classDone.status === 200 && + teacherHomeworkView.payload?.data?.completedCount === classmates.length && + !stillPending, + `completedCount=${teacherHomeworkView.payload?.data?.completedCount} / 学生数=${classmates.length} 仍在待完成=${stillPending}`, + ); + + // 班级账号的成绩视图 = 全班总览 + const classNameGrade = await api('/grades', { + method: 'POST', + token: adminToken, + body: { classId, userId: studentUser?.id, examName: '班级总览回归', score: 77, totalScore: 100 }, + }); + const classMyGrades = await api('/grades/my', { token: classToken }); + const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId)); + record( + '班级账号查看成绩 = 全班总览', + classMyGrades.status === 200 && + (classMyGrades.payload?.data ?? []).some((item) => item.id === classNameGrade.payload?.data?.id), + `成绩条数=${(classMyGrades.payload?.data ?? []).length} 涉及学生=${gradeStudentIds.size}`, + ); + + // 管理员重置班级密码后:新密码可用、旧密码失效 + const resetClassPassword = await api(`/classes/${classId}/class-account`, { + method: 'PATCH', + token: adminToken, + body: { password: 'class456' }, + }); + const newPasswordLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: classCode, password: 'class456' }, + }); + const oldPasswordLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: classCode, password: 'class123' }, + }); + record( + '重置班级密码后旧密码失效 / 新密码可用', + resetClassPassword.status === 200 && newPasswordLogin.status === 200 && oldPasswordLogin.status === 401, + `重置=${resetClassPassword.status} 新密码=${newPasswordLogin.status} 旧密码=${oldPasswordLogin.status}`, + ); + + const occupiedCode = await api(`/classes/${classId}/class-account`, { + method: 'PATCH', + token: adminToken, + body: { code: classes[1]?.code ?? 'G102' }, + }); + record( + '班级码重复被拒绝(400)', + occupiedCode.status === 400, + `status=${occupiedCode.status} message=${occupiedCode.payload?.message ?? ''}`, + ); + + // 清理:删除回归用的通知 / 作业 / 成绩,并把班级密码恢复为默认 + if (classNameNoticeId) { + await api(`/notifications/${classNameNoticeId}`, { method: 'DELETE', token: teacherToken }); + } + if (classHomeworkId) { + await api(`/homeworks/${classHomeworkId}`, { method: 'DELETE', token: teacherToken }); + } + if (classNameGrade.payload?.data?.id) { + await api(`/grades/${classNameGrade.payload.data.id}`, { method: 'DELETE', token: adminToken }); + } + await api(`/classes/${classId}/class-account`, { + method: 'PATCH', + token: adminToken, + body: { password: '123456' }, + }); + socket.close(); // ---------------------------------------------------------------- 汇总 diff --git a/packages/server/src/config/env.ts b/packages/server/src/config/env.ts index 8a2174b..7f44448 100644 --- a/packages/server/src/config/env.ts +++ b/packages/server/src/config/env.ts @@ -28,6 +28,8 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().default('*'), BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10), DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'), + /** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */ + DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'), TERM_START_DATE: z.string().default(''), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), /** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */ @@ -139,6 +141,8 @@ export const env = { bcryptRounds: raw.BCRYPT_ROUNDS, /** 新增学生时未指定密码时的初始密码 */ defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD, + /** 新建班级时的默认班级密码(班级账号登录) */ + defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD, termStartDate: raw.TERM_START_DATE, logLevel: raw.LOG_LEVEL, trustProxy: resolveTrustProxy(raw.TRUST_PROXY), diff --git a/packages/server/src/lib/class-account.ts b/packages/server/src/lib/class-account.ts new file mode 100644 index 0000000..2512294 --- /dev/null +++ b/packages/server/src/lib/class-account.ts @@ -0,0 +1,134 @@ +import { prisma } from './db.js'; +import { ApiError } from './http.js'; +import { hashPassword, verifyPassword } from './password.js'; + +/** + * 班级账号(学生端「班级」主体): + * - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt); + * - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT; + * - 管理员可在「班级管理」里修改班级码、重置班级密码。 + * + * 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。 + */ + +/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */ +const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CODE_PATTERN = /^[A-Za-z0-9]{4,16}$/; + +/** 归一化班级码:去空格 + 转大写(登录时用户输入大小写都可) */ +export function normalizeClassCode(input: string): string { + return input.trim().toUpperCase(); +} + +export function isValidClassCode(input: string): boolean { + return CODE_PATTERN.test(input.trim()); +} + +/** 随机生成一个未被占用的班级码 */ +export async function generateClassCode(prefix = ''): Promise { + for (let attempt = 0; attempt < 40; attempt += 1) { + const length = Math.max(4, 6 - prefix.length); + let body = ''; + for (let index = 0; index < length; index += 1) { + body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]; + } + const code = `${prefix}${body}`.toUpperCase(); + const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } }); + if (!exists) return code; + } + throw new ApiError(500, 'INTERNAL_ERROR', '班级码生成失败,请重试或手动指定班级码'); +} + +/** 创建班级时调用:生成班级码并写入默认班级密码 */ +export async function buildClassAccountCreateData( + defaultPassword: string, + requestedCode?: string, +): Promise<{ code: string; passwordHash: string }> { + const code = requestedCode ? normalizeClassCode(requestedCode) : await generateClassCode(); + if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字'); + const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } }); + if (taken) throw ApiError.badRequest(`班级码 ${code} 已被占用,请更换`); + return { code, passwordHash: await hashPassword(defaultPassword) }; +} + +/** 管理员:修改班级码 / 重置班级密码 */ +export async function updateClassAccount( + classId: string, + input: { code?: string; password?: string }, +): Promise<{ code: string; hasPassword: boolean }> { + const current = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, code: true } }); + if (!current) throw ApiError.notFound('班级不存在'); + + const data: { code?: string; passwordHash?: string } = {}; + + if (input.code !== undefined) { + const code = normalizeClassCode(input.code); + if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字'); + if (code !== current.code) { + const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } }); + if (taken) throw ApiError.badRequest(`班级码 ${code} 已被其他班级使用`); + data.code = code; + } + } + + if (input.password !== undefined && input.password !== '') { + if (input.password.length < 6) throw ApiError.badRequest('班级密码至少 6 位'); + data.passwordHash = await hashPassword(input.password); + } + + if (Object.keys(data).length === 0) { + throw ApiError.badRequest('没有需要更新的内容(班级码或班级密码)'); + } + + const updated = await prisma.class.update({ + where: { id: classId }, + data, + select: { code: true, passwordHash: true }, + }); + return { code: updated.code, hasPassword: Boolean(updated.passwordHash) }; +} + +/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */ +export async function findClassForLogin(code: string): Promise<{ + id: string; + name: string; + grade: string; + code: string; + passwordHash: string | null; +}> { + const normalized = normalizeClassCode(code); + const record = await prisma.class.findUnique({ + where: { code: normalized }, + select: { id: true, name: true, grade: true, code: true, passwordHash: true }, + }); + if (!record) throw ApiError.unauthorized('班级码或班级密码错误'); + if (!record.passwordHash) { + throw new ApiError( + 403, + 'CLASS_PASSWORD_NOT_SET', + '该班级尚未设置班级密码,请联系管理员在「班级管理」中设置', + ); + } + return record; +} + +export async function verifyClassPassword(plain: string, passwordHash: string): Promise { + return verifyPassword(plain, passwordHash); +} + +/** 班级账号自助修改班级密码 */ +export async function changeClassPassword( + classId: string, + currentPassword: string, + newPassword: string, +): Promise { + const record = await prisma.class.findUnique({ where: { id: classId }, select: { passwordHash: true } }); + if (!record) throw ApiError.notFound('班级不存在'); + if (record.passwordHash && !(await verifyPassword(currentPassword, record.passwordHash))) { + throw ApiError.badRequest('当前班级密码不正确'); + } + await prisma.class.update({ + where: { id: classId }, + data: { passwordHash: await hashPassword(newPassword) }, + }); +} diff --git a/packages/server/src/lib/http.ts b/packages/server/src/lib/http.ts index eaf640b..a26e366 100644 --- a/packages/server/src/lib/http.ts +++ b/packages/server/src/lib/http.ts @@ -28,6 +28,8 @@ export type ApiErrorCode = | 'IMPORT_FORMAT_INVALID' | 'IMPORT_INVALID' | 'IMPORT_EMPTY' + /** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */ + | 'CLASS_PASSWORD_NOT_SET' | 'INTERNAL_ERROR'; /** 业务异常:中间件会把它转换成统一错误响应体 */ diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts index 68d596a..0931c63 100644 --- a/packages/server/src/lib/jwt.ts +++ b/packages/server/src/lib/jwt.ts @@ -11,6 +11,10 @@ export interface TokenPayload { name: string; role: UserRole; classId: string | null; + /** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */ + classSession?: boolean; + /** 班级账号的班级码(便于日志/审计) */ + classCode?: string; } export function signToken(payload: TokenPayload): string { @@ -37,6 +41,8 @@ export function verifyToken(token: string): TokenPayload { name: String(record.name ?? ''), role, classId: record.classId ? String(record.classId) : null, + classSession: record.classSession === true, + classCode: record.classCode ? String(record.classCode) : undefined, }; } catch { throw ApiError.unauthorized('登录状态已失效,请重新登录'); diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index 89c9ea7..a8202ce 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -25,6 +25,16 @@ import type { const toIso = (value: Date): string => value.toISOString(); const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null); +/** + * "个人记录"归属范围解析: + * - 传入 userIds(班级账号 = 全班学生)时优先使用; + * - 否则退化为单个 userId(普通学生账号 = 自己)。 + */ +function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] { + if (options.userIds && options.userIds.length > 0) return options.userIds; + return options.userId ? [options.userId] : []; +} + export interface UserLike { id: string; username: string; @@ -200,14 +210,16 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto /** * @param options.userId 指定当前用户,用于填充 completed / homeworkStatus + * @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态 * @param options.withStatus 是否统计完成人数(教师视角) */ export function toHomeworkDto( item: HomeworkLike, - options: { userId?: string | null; withStatus?: boolean } = {}, + options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {}, ): HomeworkDto { const statuses = item.statuses ?? []; - const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined; + const pool = resolvePool(options); + const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined; return { id: item.id, @@ -248,12 +260,18 @@ export interface NotificationLike { reads?: NotificationReadLike[]; } +/** + * @param options.userId 指定当前用户(普通学生账号) + * @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态 + * @param options.withStatus 是否统计已读人数(教师视角) + */ export function toNotificationDto( item: NotificationLike, - options: { userId?: string | null; withStatus?: boolean } = {}, + options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {}, ): NotificationDto { const reads = item.reads ?? []; - const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined; + const pool = resolvePool(options); + const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined; return { id: item.id, diff --git a/packages/server/src/lib/session.ts b/packages/server/src/lib/session.ts new file mode 100644 index 0000000..f6fccab --- /dev/null +++ b/packages/server/src/lib/session.ts @@ -0,0 +1,53 @@ +import { prisma } from './db.js'; +import type { TokenPayload } from './jwt.js'; + +/** + * 会话主体解析:普通账号 vs 班级账号(班级设备)。 + * + * 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。 + * 班级账号的 JWT 形如: + * { sub: , username: <班级码>, name: <班级名>, role: 'STUDENT', + * classId: , classSession: true } + * + * 语义(由用户确认): + * - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上, + * 因此教师端的"完成人数/已读人数"统计依然准确; + * - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过; + * - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。 + * + * 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口, + * 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。 + */ +export function isClassSession(user: TokenPayload): boolean { + return user.role === 'STUDENT' && user.classSession === true; +} + +/** 班级账号对应的班级 id */ +export function classSessionClassId(user: TokenPayload): string { + return user.classId ?? user.sub; +} + +/** 该班级的全部学生 id(班级账号的"全班"范围) */ +export async function listClassStudentIds(classId: string): Promise { + const students = await prisma.user.findMany({ + where: { classId, role: 'STUDENT' }, + select: { id: true }, + }); + return students.map((item) => item.id); +} + +/** + * 当前会话的"个人记录主体"列表: + * - 普通学生:自己 + * - 班级账号:全班学生 + * - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表) + */ +export async function resolvePersonalIds(user: TokenPayload): Promise { + if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user)); + return [user.sub]; +} + +/** Prisma 过滤片段:`{ userId: { in: ids } }` */ +export function personalIdWhere(ids: string[]): { userId: { in: string[] } } { + return { userId: { in: ids } }; +} diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 189c946..4040076 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -15,6 +15,31 @@ export function authenticate(): RequestHandler { if (!token) throw ApiError.unauthorized('缺少 Authorization 头'); const payload = verifyToken(token); + + // 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表 + if (payload.classSession) { + const record = await prisma.class.findUnique({ + where: { id: payload.classId ?? payload.sub }, + select: { id: true, name: true, code: true }, + }); + if (!record) throw ApiError.unauthorized('班级不存在或已被删除'); + + req.auth = { + token, + user: { + sub: record.id, + username: record.code, + name: record.name, + role: 'STUDENT', + classId: record.id, + classSession: true, + classCode: record.code, + }, + }; + next(); + return; + } + const user = await prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, username: true, name: true, role: true, classId: true }, diff --git a/packages/server/src/modules/auth/auth.module.ts b/packages/server/src/modules/auth/auth.module.ts index 1ad1707..6a24cfe 100644 --- a/packages/server/src/modules/auth/auth.module.ts +++ b/packages/server/src/modules/auth/auth.module.ts @@ -6,30 +6,43 @@ import { validate } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { changePasswordSchema, + classLoginSchema, loginSchema, type ChangePasswordInput, + type ClassLoginInput, type LoginInput, } from './auth.schemas.js'; import * as authService from './auth.service.js'; const router = Router(); -/** POST /api/auth/login - 登录(公开) */ +/** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */ router.post('/login', validate({ body: loginSchema }), async (req, res) => { const result: LoginResponse = await authService.login(req.body as LoginInput); sendOk(res, result, '登录成功'); }); -/** GET /api/auth/me - 当前用户信息 */ -router.get('/me', authenticate(), async (req, res) => { - const user = getAuthUser(req); - sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功'); +/** + * POST /api/auth/class-login - 班级账号登录(公开,学生端主入口) + * + * 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。 + * 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。 + */ +router.post('/class-login', validate({ body: classLoginSchema }), async (req, res) => { + const result: LoginResponse = await authService.classLogin(req.body as ClassLoginInput); + sendOk(res, result, '班级登录成功'); }); -/** PATCH /api/auth/password - 修改密码 */ +/** GET /api/auth/me - 当前用户信息(班级账号返回班级信息) */ +router.get('/me', authenticate(), async (req, res) => { + const user = getAuthUser(req); + sendOk(res, await authService.getProfile(user), '获取当前用户成功'); +}); + +/** PATCH /api/auth/password - 修改密码(班级账号修改的是班级密码) */ router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => { const user = getAuthUser(req); - await authService.changePassword(user.sub, req.body as ChangePasswordInput); + await authService.changePassword(user, req.body as ChangePasswordInput); sendOk(res, { changed: true }, '密码修改成功,请重新登录'); }); diff --git a/packages/server/src/modules/auth/auth.schemas.ts b/packages/server/src/modules/auth/auth.schemas.ts index e90ddfe..f6c7746 100644 --- a/packages/server/src/modules/auth/auth.schemas.ts +++ b/packages/server/src/modules/auth/auth.schemas.ts @@ -5,10 +5,17 @@ export const loginSchema = z.object({ password: z.string().min(1, '请输入密码').max(128), }); +/** 班级账号登录(学生端):班级码 + 班级密码 */ +export const classLoginSchema = z.object({ + code: z.string().trim().min(4, '请输入班级码').max(16), + password: z.string().min(1, '请输入班级密码').max(128), +}); + export const changePasswordSchema = z.object({ currentPassword: z.string().min(1, '请输入当前密码'), newPassword: z.string().min(6, '新密码至少 6 位').max(128), }); export type LoginInput = z.infer; +export type ClassLoginInput = z.infer; export type ChangePasswordInput = z.infer; diff --git a/packages/server/src/modules/auth/auth.service.ts b/packages/server/src/modules/auth/auth.service.ts index f599662..a19c1d5 100644 --- a/packages/server/src/modules/auth/auth.service.ts +++ b/packages/server/src/modules/auth/auth.service.ts @@ -1,12 +1,14 @@ import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared'; +import { changeClassPassword, findClassForLogin, verifyClassPassword } from '../../lib/class-account.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; -import { signToken } from '../../lib/jwt.js'; +import { signToken, type TokenPayload } from '../../lib/jwt.js'; import { toStudentDto, toUserDto } from '../../lib/mappers.js'; import { hashPassword, verifyPassword } from '../../lib/password.js'; -import type { ChangePasswordInput, LoginInput } from './auth.schemas.js'; +import { isClassSession } from '../../lib/session.js'; +import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js'; -/** 登录:校验用户名密码并签发 JWT */ +/** 登录:校验用户名密码并签发 JWT(教师/管理员/个人学生账号) */ export async function login(input: LoginInput): Promise { const user = await prisma.user.findUnique({ where: { username: input.username } }); if (!user) throw ApiError.unauthorized('用户名或密码错误'); @@ -14,37 +16,107 @@ export async function login(input: LoginInput): Promise { const passwordMatched = await verifyPassword(input.password, user.passwordHash); if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误'); - const token = signToken({ + const payload: TokenPayload = { sub: user.id, username: user.username, name: user.name, role: user.role as UserRole, classId: user.classId, - }); + }; + const token = signToken(payload); return { token, user: toUserDto(user) }; } -/** 当前用户信息(学生额外带班级名与年级) */ -export async function getProfile(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { class: { select: { name: true, grade: true } } }, - }); - if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); - return toStudentDto(user); +/** + * 班级账号登录(学生端):班级码 + 班级密码。 + * + * 签发的 JWT 以**班级**为主体:`sub` 与 `classId` 都是班级 id,并带 `classSession: true`, + * 后续个人数据(作业完成、通知已读)由服务端按"全班"范围读写,个人学生不再是登录主体。 + */ +export async function classLogin(input: ClassLoginInput): Promise { + const record = await findClassForLogin(input.code); + + if (!(await verifyClassPassword(input.password, record.passwordHash ?? ''))) { + throw ApiError.unauthorized('班级码或班级密码错误'); + } + + const payload: TokenPayload = { + sub: record.id, + username: record.code, + name: record.name, + role: 'STUDENT', + classId: record.id, + classSession: true, + classCode: record.code, + }; + const token = signToken(payload); + + return { + token, + user: { + id: record.id, + username: record.code, + name: record.name, + role: 'STUDENT', + classId: record.id, + createdAt: new Date().toISOString(), + className: record.name, + grade: record.grade, + classSession: true, + classCode: record.code, + }, + }; } -/** 修改自己的密码 */ -export async function changePassword(userId: string, input: ChangePasswordInput): Promise { - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); +/** + * 当前用户信息(学生额外带班级名与年级)。 + * 班级账号返回班级信息(id 即班级 id)。 + */ +export async function getProfile(user: TokenPayload): Promise { + if (isClassSession(user)) { + const record = await prisma.class.findUnique({ + where: { id: user.classId ?? user.sub }, + select: { id: true, name: true, grade: true, code: true, createdAt: true }, + }); + if (!record) throw ApiError.unauthorized('班级不存在或已被删除'); + return { + id: record.id, + username: record.code, + name: record.name, + role: 'STUDENT', + classId: record.id, + createdAt: record.createdAt.toISOString(), + className: record.name, + grade: record.grade, + classSession: true, + classCode: record.code, + } as StudentDto & { classSession: boolean; classCode: string }; + } - const matched = await verifyPassword(input.currentPassword, user.passwordHash); + const user_ = await prisma.user.findUnique({ + where: { id: user.sub }, + include: { class: { select: { name: true, grade: true } } }, + }); + if (!user_) throw ApiError.unauthorized('账号不存在或已被删除'); + return toStudentDto(user_); +} + +/** 修改自己的密码;班级账号修改的是「班级密码」 */ +export async function changePassword(user: TokenPayload, input: ChangePasswordInput): Promise { + if (isClassSession(user)) { + await changeClassPassword(user.classId ?? user.sub, input.currentPassword, input.newPassword); + return; + } + + const record = await prisma.user.findUnique({ where: { id: user.sub } }); + if (!record) throw ApiError.unauthorized('账号不存在或已被删除'); + + const matched = await verifyPassword(input.currentPassword, record.passwordHash); if (!matched) throw ApiError.badRequest('当前密码不正确'); await prisma.user.update({ - where: { id: userId }, + where: { id: user.sub }, data: { passwordHash: await hashPassword(input.newPassword) }, }); } diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index fd7b80e..ef98cbb 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -1,4 +1,4 @@ -import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared'; +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'; @@ -49,11 +49,14 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr include: { creator: creatorSelect, reads: true }, }); - const dto = toNotificationDto(created, { userId: user.sub, withStatus: true }); + const dto = toNotificationDto(created, { userIds: [student.id], withStatus: true }); // 班级房间广播:通知中心/其他端都能看到 emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto); - // 定向广播给被叫的学生:客户端灵动岛立即展开 + // 定向广播给被叫的学生:个人学生端灵动岛立即展开 emitToUser(student.id, SOCKET_EVENTS.callNew, dto); + // 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录, + // 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁) + emitToUser(created.classId, SOCKET_EVENTS.callNew, dto); logger.info(`叫人:${teacherName} → ${studentName}(${message})`); return dto; diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 205c916..90dc8ed 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'; @@ -11,9 +11,11 @@ import { classTeacherParamSchema, createClassSchema, listClassesQuerySchema, + updateClassAccountSchema, updateClassSchema, type AddStudentInput, type CreateClassInput, + type UpdateClassAccountInput, type UpdateClassInput, } from './classes.schemas.js'; import * as classService from './classes.service.js'; @@ -68,6 +70,25 @@ router.delete( }, ); +/** + * PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码) + * 仅管理员:学生端「班级登录」用的就是这个班级码 + */ +router.patch( + '/:id/class-account', + requireRole('ADMIN'), + validate({ params: idParamSchema, body: updateClassAccountSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk( + res, + await classService.updateClassAccount(user, id, req.body as UpdateClassAccountInput), + '班级账号已更新', + ); + }, +); + /** GET /api/classes/:id/students - 班级学生名单 */ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index 0d0b29d..4c5f367 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -6,6 +6,12 @@ export const createClassSchema = z.object({ grade: z.string().trim().min(1, '请输入年级').max(32), /** 仅管理员可指定班主任,教师创建时固定为自己 */ teacherId: z.string().min(1).optional(), + /** 可选:自定义班级码(学生端班级账号登录用),留空自动生成 */ + code: z + .string() + .trim() + .regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字') + .optional(), }); export const updateClassSchema = z.object({ @@ -13,6 +19,20 @@ export const updateClassSchema = z.object({ grade: z.string().trim().min(1).max(32).optional(), }); +/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ +export const updateClassAccountSchema = z + .object({ + code: z + .string() + .trim() + .regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字') + .optional(), + password: z.string().min(6, '班级密码至少 6 位').max(64).optional(), + }) + .refine((value) => value.code !== undefined || value.password !== undefined, { + message: '请至少填写班级码或班级密码中的一项', + }); + export const listClassesQuerySchema = z.object({ keyword: keywordSchema, }); @@ -39,4 +59,5 @@ export const classTeacherParamSchema = z.object({ export type CreateClassInput = z.infer; export type UpdateClassInput = z.infer; +export type UpdateClassAccountInput = z.infer; export type AddStudentInput = z.infer; diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index f79bf06..835b43d 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -1,4 +1,4 @@ -import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared'; +import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared'; import { env } from '../../config/env.js'; import { assertClassAccess, @@ -8,6 +8,10 @@ import { classScopeIdFilter, resolveClassScope, } from '../../lib/access.js'; +import { + buildClassAccountCreateData, + updateClassAccount as updateClassAccountRecord, +} from '../../lib/class-account.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -24,6 +28,36 @@ const countSelect = { notifications: true, } as const; +/** + * 说明:班级码 code 与班级密码哈希 passwordHash 是 Class 的标量字段, + * 使用 include 查询时会默认返回,无需(也不能)写进 include 里。 + * 是否把它们放进 DTO 由 canSeeClassAccount() 决定,哈希永不外泄。 + */ + +/** + * 是否可以看到该班级的班级账号信息: + * 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。 + * 科任老师与学生都看不到班级码。 + */ +function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean { + if (user.role === 'ADMIN') return true; + return user.role === 'TEACHER' && item.teacherId === user.sub; +} + +/** 把班级记录转换为 DTO,并按权限附带班级码 */ +function toClassDtoWithAccount( + user: TokenPayload, + item: Parameters[0] & { code?: string; passwordHash?: string | null }, +): ClassDto { + const dto = toClassDto(item); + if (!canSeeClassAccount(user, item)) return dto; + return { + ...dto, + code: item.code, + hasPassword: Boolean(item.passwordHash), + }; +} + /** 班级列表(按权限过滤) */ export async function listClasses(user: TokenPayload, keyword?: string): Promise { const scope = await resolveClassScope(user); @@ -42,7 +76,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise orderBy: [{ grade: 'asc' }, { name: 'asc' }], }); - return classes.map(toClassDto); + return classes.map((item) => toClassDtoWithAccount(user, item)); } /** 班级详情:学生名单 + 课程 + 协作教师 */ @@ -68,7 +102,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi if (!item) throw ApiError.notFound('班级不存在'); return { - ...toClassDto(item), + ...toClassDtoWithAccount(user, item), students: item.students.map((student) => toStudentDto(student)), courses: item.courses.map(toCourseDto), teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)), @@ -87,15 +121,47 @@ export async function createClass(user: TokenPayload, input: CreateClassInput): teacherId = teacher.id; } + // 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码 + const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code); + const created = await prisma.class.create({ - data: { name: input.name, grade: input.grade, teacherId }, + data: { + name: input.name, + grade: input.grade, + teacherId, + code: account.code, + passwordHash: account.passwordHash, + }, include: { teacher: { select: { id: true, name: true, username: true } }, _count: { select: countSelect }, }, }); - return toClassDto(created); + return toClassDtoWithAccount(user, created); +} + +/** + * 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。 + * 班级码即学生端"班级登录"的账号;密码留空表示不修改。 + */ +export async function updateClassAccount( + user: TokenPayload, + classId: string, + input: { code?: string; password?: string }, +): Promise { + assertCanManageClasses(user); + await updateClassAccountRecord(classId, input); + + const updated = await prisma.class.findUnique({ + where: { id: classId }, + include: { + teacher: { select: { id: true, name: true, username: true } }, + _count: { select: countSelect }, + }, + }); + if (!updated) throw ApiError.notFound('班级不存在'); + return toClassDtoWithAccount(user, updated); } /** 编辑班级 */ diff --git a/packages/server/src/modules/dashboard/dashboard.service.ts b/packages/server/src/modules/dashboard/dashboard.service.ts index 0c29710..07a10a8 100644 --- a/packages/server/src/modules/dashboard/dashboard.service.ts +++ b/packages/server/src/modules/dashboard/dashboard.service.ts @@ -9,6 +9,7 @@ import { import { prisma } from '../../lib/db.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js'; +import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; const courseSelect = { select: { id: true, name: true } } as const; const creatorSelect = { select: { id: true, name: true, username: true } } as const; @@ -25,6 +26,9 @@ export async function getDashboardSummary(user: TokenPayload): Promise - toNotificationDto(item, { userId: user.sub }), + toNotificationDto(item, { userIds: personalIds }), ), recentHomeworks: recentHomeworks.map((item): HomeworkDto => - toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), + toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }), ), upcomingDeadlines: upcoming.map((item): HomeworkDto => - toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), + toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }), ), }; } diff --git a/packages/server/src/modules/grades/grades.service.ts b/packages/server/src/modules/grades/grades.service.ts index 1afee42..73b4e94 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, @@ -12,6 +12,7 @@ import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toGradeDto } from '../../lib/mappers.js'; import { parseOptionalDate } from '../../lib/schemas.js'; +import { resolvePersonalIds } from '../../lib/session.js'; import { emitToClass, emitToUser } from '../../realtime/bus.js'; import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js'; @@ -27,13 +28,20 @@ export interface ListGradeOptions { examName?: string; } -/** 学生查看自己的成绩 */ +/** + * 学生查看成绩。 + * - 普通学生账号:只看自己的成绩; + * - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体, + * 班级设备上一屏展示全班成绩,便于张贴/核对)。 + */ export async function listMyGrades(user: TokenPayload): Promise { + const personalIds = await resolvePersonalIds(user); + const grades = await prisma.grade.findMany({ - where: { userId: user.sub }, + where: { userId: { in: personalIds } }, include, - orderBy: [{ publishedAt: 'desc' }], - take: 300, + orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], + take: 500, }); return grades.map(toGradeDto); } diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index b6197c6..7a57225 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -1,4 +1,4 @@ -import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared'; +import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared'; import { assertCanPublishContent, assertClassAccess, @@ -11,6 +11,7 @@ import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js'; import { parseOptionalDate } from '../../lib/schemas.js'; +import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; import { emitToClass } from '../../realtime/bus.js'; import type { CreateHomeworkInput, @@ -39,6 +40,9 @@ export async function listHomeworks( ): Promise { const scope = await resolveClassScope(user, options.classId); const student = isStudent(user); + // 班级账号(班级设备)以全班学生为范围;普通学生即自己 + const personalIds = student ? await resolvePersonalIds(user) : []; + const personalWhere = personalIdWhere(personalIds); const homeworks = await prisma.homework.findMany({ where: { @@ -48,36 +52,41 @@ export async function listHomeworks( ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } : {}), ...(student && options.pendingOnly - ? { statuses: { none: { userId: user.sub, completed: true } } } + ? { statuses: { none: { ...personalWhere, completed: true } } } : {}), }, include: { course: courseSelect, creator: creatorSelect, - statuses: student ? { where: { userId: user.sub } } : true, + statuses: student ? { where: personalWhere } : true, }, orderBy: { createdAt: 'desc' }, take: 200, }); return homeworks.map((item) => - toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), + toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }), ); } export async function getHomework(user: TokenPayload, homeworkId: string): Promise { + const student = isStudent(user); + const personalIds = student ? await resolvePersonalIds(user) : []; const homework = await prisma.homework.findUnique({ where: { id: homeworkId }, include: { course: courseSelect, creator: creatorSelect, - statuses: isStudent(user) ? { where: { userId: user.sub } } : true, + statuses: student ? { where: personalIdWhere(personalIds) } : true, }, }); if (!homework) throw ApiError.notFound('作业不存在'); await assertClassAccess(user, homework.classId); - return toHomeworkDto(homework, { userId: isStudent(user) ? user.sub : null, withStatus: !isStudent(user) }); + return toHomeworkDto(homework, { + userIds: student ? personalIds : [], + withStatus: !student, + }); } /** 发布作业并实时推送到班级房间 */ @@ -146,7 +155,12 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr }); } -/** 标记完成 / 取消完成(学生标记自己;教师可代标记) */ +/** + * 标记完成 / 取消完成。 + * - 普通学生:标记自己; + * - 班级账号(班级设备):为全班学生写入完成状态(代全班操作); + * - 教师 / 管理员:可代指定学生标记。 + */ export async function updateHomeworkStatus( user: TokenPayload, homeworkId: string, @@ -155,10 +169,36 @@ export async function updateHomeworkStatus( const homework = await prisma.homework.findUnique({ where: { id: homeworkId } }); if (!homework) throw ApiError.notFound('作业不存在'); - const targetUserId = isStudent(user) ? user.sub : (input.userId ?? user.sub); await assertClassAccess(user, homework.classId); - if (!isStudent(user) && input.userId) { + if (isStudent(user)) { + const personalIds = await resolvePersonalIds(user); + let result: { + id: string; + homeworkId: string; + userId: string; + completed: boolean; + updatedAt: Date; + } | null = null; + + for (const userId of personalIds) { + const status = await prisma.homeworkStatus.upsert({ + where: { homeworkId_userId: { homeworkId, userId } }, + create: { homeworkId, userId, completed: input.completed }, + update: { completed: input.completed }, + }); + result ??= status; + } + + if (!result) throw ApiError.forbidden('当前班级还没有学生账号,无法标记作业完成状态'); + + const dto = toHomeworkStatusDto(result); + emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId }); + return dto; + } + + const targetUserId = input.userId ?? user.sub; + if (input.userId) { const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } }); if (!target || target.classId !== homework.classId) { throw ApiError.badRequest('目标学生不属于该作业所在班级'); diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 39b6424..fe2fb16 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; +import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { assertClassAccess, assertCanPublishContent, @@ -10,6 +10,7 @@ import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; import { toNotificationDto } from '../../lib/mappers.js'; +import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; import { emitToClass } from '../../realtime/bus.js'; import { computeClassStatus } from '../schedules/schedules.service.js'; import type { CreateNotificationInput } from './notifications.schemas.js'; @@ -29,6 +30,9 @@ export async function listNotifications( ): Promise { const scope = await resolveClassScope(user, options.classId); const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER'; + // 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己 + const personalIds = await resolvePersonalIds(user); + const personalWhere = personalIdWhere(personalIds); const notifications = await prisma.notification.findMany({ where: { @@ -37,17 +41,17 @@ export async function listNotifications( ...(options.keyword ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } : {}), - ...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}), + ...(options.unreadOnly ? { reads: { none: personalWhere } } : {}), }, include: { creator: creatorSelect, - reads: isStaff ? true : { where: { userId: user.sub } }, + reads: isStaff ? true : { where: personalWhere }, }, orderBy: [{ createdAt: 'desc' }], take: 200, }); - return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff })); + return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff })); } /** @@ -103,48 +107,72 @@ export async function deleteNotification(user: TokenPayload, notificationId: str await prisma.notification.delete({ where: { id: notificationId } }); } -/** 标记单条通知已读 */ +/** + * 标记单条通知已读。 + * 班级账号:为全班学生写入已读记录(班级设备代全班操作),教师端的已读人数随之更新。 + */ export async function markAsRead( user: TokenPayload, notificationId: string, -): Promise<{ notificationId: string; readAt: string }> { +): Promise<{ notificationId: string; readAt: string; marked: number }> { const notification = await prisma.notification.findUnique({ where: { id: notificationId } }); if (!notification) throw ApiError.notFound('通知不存在'); await assertClassAccess(user, notification.classId); - const read = await prisma.notificationRead.upsert({ - where: { notificationId_userId: { notificationId, userId: user.sub } }, - create: { notificationId, userId: user.sub }, - update: {}, + const personalIds = await resolvePersonalIds(user); + // 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates, + // 直接 createMany 遇到重复会整体失败。 + const existing = await prisma.notificationRead.findMany({ + where: { notificationId, userId: { in: personalIds } }, + select: { userId: true }, }); + const have = new Set(existing.map((item) => item.userId)); + const missing = personalIds.filter((userId) => !have.has(userId)); - return { notificationId, readAt: read.readAt.toISOString() }; + if (missing.length > 0) { + await prisma.notificationRead.createMany({ + data: missing.map((userId) => ({ notificationId, userId })), + }); + } + + return { notificationId, readAt: new Date().toISOString(), marked: missing.length }; } /** 全部标为已读(可按班级限定) */ export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> { const scope = await resolveClassScope(user, classId); + const personalIds = await resolvePersonalIds(user); + const personalWhere = personalIdWhere(personalIds); const pending = await prisma.notification.findMany({ - where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } }, + where: { ...classScopeWhere(scope), reads: { none: personalWhere } }, select: { id: true }, }); + let marked = 0; for (const item of pending) { - await prisma.notificationRead.upsert({ - where: { notificationId_userId: { notificationId: item.id, userId: user.sub } }, - create: { notificationId: item.id, userId: user.sub }, - update: {}, + const existing = await prisma.notificationRead.findMany({ + where: { notificationId: item.id, userId: { in: personalIds } }, + select: { userId: true }, }); + const have = new Set(existing.map((row) => row.userId)); + const missing = personalIds.filter((userId) => !have.has(userId)); + if (missing.length === 0) continue; + + await prisma.notificationRead.createMany({ + data: missing.map((userId) => ({ notificationId: item.id, userId })), + }); + marked += missing.length; } - return { marked: pending.length }; + return { marked }; } /** 未读数量(客户端红点) */ export async function unreadCount(user: TokenPayload, classId?: string): Promise { const scope = await resolveClassScope(user, classId); + const personalIds = await resolvePersonalIds(user); return prisma.notification.count({ - where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } }, + where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } }, }); } diff --git a/packages/server/src/realtime/socket.ts b/packages/server/src/realtime/socket.ts index fb8c832..6d5e347 100644 --- a/packages/server/src/realtime/socket.ts +++ b/packages/server/src/realtime/socket.ts @@ -52,9 +52,11 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer { return; } const payload = verifyToken(token); + // 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间, + // "叫人"等定向消息因此能直达班级设备(见 calls.service)。 socket.data.user = { - id: payload.sub, - username: payload.username, + id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub, + username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username, name: payload.name, role: payload.role, classId: payload.classId, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index dd88542..bfc0a9c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -64,9 +64,27 @@ export interface LoginRequest { password: string; } +/** + * 登录会话主体。 + * - 普通账号(教师/管理员/个人学生):就是用户本身; + * - 班级账号(班级设备):`classSession = true`,此时 `id` 与 `classId` 都是班级 id, + * `name` 是班级名,个人数据由服务端按"全班"范围读写。 + */ +export interface SessionUser extends StudentDto { + classSession?: boolean; + /** 班级账号的班级码(仅班级会话返回) */ + classCode?: string; +} + export interface LoginResponse { token: string; - user: UserDto; + user: SessionUser; +} + +/** 班级账号登录(学生端):班级码 + 班级密码 */ +export interface ClassLoginRequest { + code: string; + password: string; } /* ------------------------------------------------------------------ 班级 */ @@ -82,6 +100,10 @@ export interface ClassDto { courseCount?: number; homeworkCount?: number; notificationCount?: number; + /** 班级码(学生端班级账号登录用);仅对有管理权限的角色返回 */ + code?: string; + /** 是否已设置班级密码(哈希永不外泄) */ + hasPassword?: boolean; } export interface ClassTeacherBrief { @@ -99,6 +121,8 @@ export interface ClassDetailDto extends ClassDto { export interface CreateClassRequest { name: string; grade: string; + /** 可选:自定义班级码,留空自动生成 */ + code?: string; } export interface UpdateClassRequest { @@ -106,6 +130,14 @@ export interface UpdateClassRequest { grade?: string; } +/** 管理员设置/重置班级账号(班级码 + 班级密码) */ +export interface UpdateClassAccountRequest { + /** 新的班级码(4~16 位字母数字,留空表示不改) */ + code?: string; + /** 新的班级密码(留空表示不改) */ + password?: string; +} + /* ------------------------------------------------------------------ 课程 */ export interface CourseDto { diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 97c04a6..2bdfec6 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -26,6 +26,7 @@ import { type TimeLayoutDto, type TimeLayoutImportResult, type TimeLayoutParsePreview, + type UpdateClassAccountRequest, type UpdateClassRequest, type UserDto, } from '@classhelper/shared'; @@ -63,6 +64,9 @@ export const classApi = { api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }), removeTeacher: (id: string, teacherId: string): Promise => api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`), + /** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ + updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise => + api.patch(`${API_PATHS.classes}/${id}/class-account`, payload), }; /* ------------------------------------------------------------------ 课程 */ diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index 8148291..f21c196 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -8,6 +8,7 @@ import { type ClassDto, type CourseDto, type StudentDto, + type UpdateClassAccountRequest, type UserDto, } from '@classhelper/shared'; import { classApi, courseApi, teacherApi } from '@/api'; @@ -43,7 +44,7 @@ async function loadClasses(): Promise { const formVisible = ref(false); const formRef = ref(); const editingId = ref(null); -const form = reactive({ name: '', grade: '' }); +const form = reactive({ name: '', grade: '', code: '' }); const rules: FormRules = { name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }], grade: [{ required: true, message: '请输入年级', trigger: 'blur' }], @@ -53,6 +54,7 @@ function openCreate(): void { editingId.value = null; form.name = ''; form.grade = ''; + form.code = ''; formVisible.value = true; } @@ -60,6 +62,7 @@ function openEdit(row: ClassDto): void { editingId.value = row.id; form.name = row.name; form.grade = row.grade; + form.code = ''; formVisible.value = true; } @@ -71,13 +74,69 @@ async function submitForm(): Promise { await classApi.update(editingId.value, { name: form.name, grade: form.grade }); ElMessage.success('班级已更新'); } else { - await classApi.create({ name: form.name, grade: form.grade }); + await classApi.create({ + name: form.name, + grade: form.grade, + ...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}), + }); ElMessage.success('班级创建成功'); } formVisible.value = false; await loadClasses(); } +/* ------------------------------------------------------------ 班级账号(班级码 + 班级密码) */ + +const accountVisible = ref(false); +const accountSaving = ref(false); +const accountTarget = ref(null); +const accountForm = reactive({ code: '', password: '' }); + +/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */ +function openAccount(row: ClassDto): void { + accountTarget.value = row; + accountForm.code = row.code ?? ''; + accountForm.password = ''; + accountVisible.value = true; +} + +/** 一键生成一个易读的班级码(避开 0/O/1/I 等易混字符) */ +function suggestCode(): void { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let body = ''; + for (let index = 0; index < 6; index += 1) { + body += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + accountForm.code = body; +} + +async function submitAccount(): Promise { + const target = accountTarget.value; + if (!target) return; + if (!accountForm.code.trim() && !accountForm.password) { + ElMessage.warning('请填写新的班级码或班级密码'); + return; + } + + accountSaving.value = true; + try { + const payload: UpdateClassAccountRequest = {}; + if (accountForm.code.trim() && accountForm.code.trim() !== target.code) { + payload.code = accountForm.code.trim().toUpperCase(); + } + if (accountForm.password) payload.password = accountForm.password; + + const updated = await classApi.updateAccount(target.id, payload); + ElMessage.success( + `班级账号已更新:班级码 ${updated.code ?? '-'}${accountForm.password ? ',班级密码已重置' : ''}`, + ); + accountVisible.value = false; + await loadClasses(); + } finally { + accountSaving.value = false; + } +} + async function removeClass(row: ClassDto): Promise { await ElMessageBox.confirm( `删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`, @@ -267,12 +326,24 @@ onUnmounted(() => { + + + - + @@ -280,6 +351,38 @@ onUnmounted(() => { + + + + + + + + + + + + 随机生成 + + + + + + + + @@ -289,6 +392,9 @@ onUnmounted(() => { + + + + + diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index ad76f71..87c735d 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -375,7 +375,8 @@ function writeReadme() { 二、默认账号(首次安装后请立即修改密码) 管理员 admin / admin123 教师 teacher1 / teacher123、teacher2 / teacher123 - 学生 student01 ~ student15 / student123(桌面客户端使用) + 班级 班级码 G101/G102/G203 + 班级密码 123456(学生端桌面客户端使用) + 个人 student01 ~ student15 / student123(个人学生账号,接口向后兼容) 三、目录说明 server\\ 后端程序(server\\dist\\index.js 为入口)