Files
ClassHelper/packages/server/src/lib/class-account.ts
T
LoveChengke d27f9a4b9e feat(students,teachers,classes,auth,web): 学生个人账号彻底清理,学生只是名单记录
学生不再有登录能力:User.passwordHash 以空串占位,role=STUDENT 在密码校验之前
一律 403 并提示改用班级登录(否则「密码错 401 / 密码对但已停用 403」的差异会把
学生账号密码变成可离线验证的预言机)。成绩、未交名单、叫人、已读全部按名单记。

配套改动:
- 删除 POST /students/:id/reset-password(调用返回 404,e2e 有负断言);
- 新建学生不设密码,StudentsView 移除密码输入与「重置密码」入口,
  列表标识仍自动生成为学号式(student01…);
- class-account.ts / auth.service.ts 收紧班级会话语义;
- 经管「修改密码」入口收敛到 Web 端:教师改本人密码走顶栏下拉,
  班级密码由管理员在班级管理里维护,教师可用 POST /teachers/:id/reset-password 设初始密码。
2026-10-02 00:25:04 +08:00

138 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomInt } from 'node:crypto';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { hashPassword, verifyPassword } from './password.js';
/**
* 班级账号(学生端「班级」主体):
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
*
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
*/
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_PATTERN = /^[A-Za-z0-9]{4,16}$/;
/** 归一化班级码:去空格 + 转大写(登录时用户输入大小写都可) */
export function normalizeClassCode(input: string): string {
return input.trim().toUpperCase();
}
export function isValidClassCode(input: string): boolean {
return CODE_PATTERN.test(input.trim());
}
/** 随机生成一个未被占用的班级码 */
export async function generateClassCode(prefix = ''): Promise<string> {
for (let attempt = 0; attempt < 40; attempt += 1) {
const length = Math.max(4, 6 - prefix.length);
let body = '';
for (let index = 0; index < length; index += 1) {
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
}
const code = `${prefix}${body}`.toUpperCase();
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (!exists) return code;
}
throw new ApiError(500, 'INTERNAL_ERROR', '班级码生成失败,请重试或手动指定班级码');
}
/** 创建班级时调用:生成班级码并写入默认班级密码 */
export async function buildClassAccountCreateData(
defaultPassword: string,
requestedCode?: string,
): Promise<{ code: string; passwordHash: string }> {
const code = requestedCode ? normalizeClassCode(requestedCode) : await generateClassCode();
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被占用,请更换`);
return { code, passwordHash: await hashPassword(defaultPassword) };
}
/** 管理员:修改班级码 / 重置班级密码 */
export async function updateClassAccount(
classId: string,
input: { code?: string; password?: string },
): Promise<{ code: string; hasPassword: boolean }> {
const current = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, code: true } });
if (!current) throw ApiError.notFound('班级不存在');
const data: { code?: string; passwordHash?: string } = {};
if (input.code !== undefined) {
const code = normalizeClassCode(input.code);
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
if (code !== current.code) {
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被其他班级使用`);
data.code = code;
}
}
if (input.password !== undefined && input.password !== '') {
if (input.password.length < 6) throw ApiError.badRequest('班级密码至少 6 位');
data.passwordHash = await hashPassword(input.password);
}
if (Object.keys(data).length === 0) {
throw ApiError.badRequest('没有需要更新的内容(班级码或班级密码)');
}
const updated = await prisma.class.update({
where: { id: classId },
data,
select: { code: true, passwordHash: true },
});
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
}
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
export async function findClassForLogin(code: string): Promise<{
id: string;
name: string;
grade: string;
code: string;
passwordHash: string | null;
}> {
const normalized = normalizeClassCode(code);
const record = await prisma.class.findUnique({
where: { code: normalized },
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
});
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
if (!record.passwordHash) {
throw new ApiError(
403,
'CLASS_PASSWORD_NOT_SET',
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
);
}
return record;
}
export async function verifyClassPassword(plain: string, passwordHash: string): Promise<boolean> {
return verifyPassword(plain, passwordHash);
}
/** 班级账号自助修改班级密码 */
export async function changeClassPassword(
classId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const record = await prisma.class.findUnique({ where: { id: classId }, select: { passwordHash: true } });
if (!record) throw ApiError.notFound('班级不存在');
if (record.passwordHash && !(await verifyPassword(currentPassword, record.passwordHash))) {
throw ApiError.badRequest('当前班级密码不正确');
}
await prisma.class.update({
where: { id: classId },
data: { passwordHash: await hashPassword(newPassword) },
});
}