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 { 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<HomeworkDto[]> {
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<HomeworkDto> {
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('目标学生不属于该作业所在班级');