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

@@ -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<typeof toClassDto>[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<ClassDto[]> {
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<ClassDto> {
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);
}
/** 编辑班级 */