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 { 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);
@@ -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<typeof createClassSchema>;
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>;
@@ -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);
}
/** 编辑班级 */