feat(class-account): 学生端主体改为班级(班级码 + 班级密码)+ 数据按班级隔离

服务端:
- 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 同步
This commit is contained in:
LoveChengke committed 2026-09-12 15:51:08 +08:00
1 parent 8b85079d81
commit 1073436ad6
38 files changed
+1289 -157

No files matched your search

@@ -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");
+4
View File
@@ -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
+14 -5
View File
@@ -84,10 +84,12 @@ async function main(): Promise<void> {
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<void> {
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' });
}