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:
1 parent
8b85079d81
commit
1073436ad6
38 files changed
+1289
-157
No files matched your search
@@ -0,0 +1,134 @@
|
||||
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) {
|
||||
body += CODE_ALPHABET[Math.floor(Math.random() * 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) },
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export type ApiErrorCode =
|
||||
| 'IMPORT_FORMAT_INVALID'
|
||||
| 'IMPORT_INVALID'
|
||||
| 'IMPORT_EMPTY'
|
||||
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||
| 'CLASS_PASSWORD_NOT_SET'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface TokenPayload {
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
|
||||
classSession?: boolean;
|
||||
/** 班级账号的班级码(便于日志/审计) */
|
||||
classCode?: string;
|
||||
}
|
||||
|
||||
export function signToken(payload: TokenPayload): string {
|
||||
@@ -37,6 +41,8 @@ export function verifyToken(token: string): TokenPayload {
|
||||
name: String(record.name ?? ''),
|
||||
role,
|
||||
classId: record.classId ? String(record.classId) : null,
|
||||
classSession: record.classSession === true,
|
||||
classCode: record.classCode ? String(record.classCode) : undefined,
|
||||
};
|
||||
} catch {
|
||||
throw ApiError.unauthorized('登录状态已失效,请重新登录');
|
||||
|
||||
@@ -25,6 +25,16 @@ import type {
|
||||
const toIso = (value: Date): string => value.toISOString();
|
||||
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
||||
|
||||
/**
|
||||
* "个人记录"归属范围解析:
|
||||
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
|
||||
* - 否则退化为单个 userId(普通学生账号 = 自己)。
|
||||
*/
|
||||
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
|
||||
if (options.userIds && options.userIds.length > 0) return options.userIds;
|
||||
return options.userId ? [options.userId] : [];
|
||||
}
|
||||
|
||||
export interface UserLike {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -200,14 +210,16 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
|
||||
* @param options.withStatus 是否统计完成人数(教师视角)
|
||||
*/
|
||||
export function toHomeworkDto(
|
||||
item: HomeworkLike,
|
||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
): HomeworkDto {
|
||||
const statuses = item.statuses ?? [];
|
||||
const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined;
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -248,12 +260,18 @@ export interface NotificationLike {
|
||||
reads?: NotificationReadLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户(普通学生账号)
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
|
||||
* @param options.withStatus 是否统计已读人数(教师视角)
|
||||
*/
|
||||
export function toNotificationDto(
|
||||
item: NotificationLike,
|
||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
): NotificationDto {
|
||||
const reads = item.reads ?? [];
|
||||
const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined;
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 会话主体解析:普通账号 vs 班级账号(班级设备)。
|
||||
*
|
||||
* 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。
|
||||
* 班级账号的 JWT 形如:
|
||||
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'STUDENT',
|
||||
* classId: <classId>, classSession: true }
|
||||
*
|
||||
* 语义(由用户确认):
|
||||
* - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
|
||||
* 因此教师端的"完成人数/已读人数"统计依然准确;
|
||||
* - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
|
||||
* - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。
|
||||
*
|
||||
* 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口,
|
||||
* 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。
|
||||
*/
|
||||
export function isClassSession(user: TokenPayload): boolean {
|
||||
return user.role === 'STUDENT' && user.classSession === true;
|
||||
}
|
||||
|
||||
/** 班级账号对应的班级 id */
|
||||
export function classSessionClassId(user: TokenPayload): string {
|
||||
return user.classId ?? user.sub;
|
||||
}
|
||||
|
||||
/** 该班级的全部学生 id(班级账号的"全班"范围) */
|
||||
export async function listClassStudentIds(classId: string): Promise<string[]> {
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
select: { id: true },
|
||||
});
|
||||
return students.map((item) => item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会话的"个人记录主体"列表:
|
||||
* - 普通学生:自己
|
||||
* - 班级账号:全班学生
|
||||
* - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表)
|
||||
*/
|
||||
export async function resolvePersonalIds(user: TokenPayload): Promise<string[]> {
|
||||
if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user));
|
||||
return [user.sub];
|
||||
}
|
||||
|
||||
/** Prisma 过滤片段:`{ userId: { in: ids } }` */
|
||||
export function personalIdWhere(ids: string[]): { userId: { in: string[] } } {
|
||||
return { userId: { in: ids } };
|
||||
}
|
||||
Reference in new issue
Block a user