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

@@ -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 }, '密码修改成功,请重新登录');
});
@@ -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<typeof loginSchema>;
export type ClassLoginInput = z.infer<typeof classLoginSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
@@ -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<LoginResponse> {
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<LoginResponse> {
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<StudentDto> {
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<LoginResponse> {
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<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
/**
* 当前用户信息(学生额外带班级名与年级)。
* 班级账号返回班级信息(id 即班级 id)。
*/
export async function getProfile(user: TokenPayload): Promise<StudentDto> {
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<void> {
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) },
});
}