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

+4
View File
@@ -28,6 +28,8 @@ const envSchema = z.object({
CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
@@ -139,6 +141,8 @@ export const env = {
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL,
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
+134
View File
@@ -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) },
});
}
+2
View File
@@ -28,6 +28,8 @@ export type ApiErrorCode =
| 'IMPORT_FORMAT_INVALID'
| 'IMPORT_INVALID'
| 'IMPORT_EMPTY'
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
| 'CLASS_PASSWORD_NOT_SET'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
+6
View File
@@ -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('登录状态已失效,请重新登录');
+22 -4
View File
@@ -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,
+53
View File
@@ -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 } };
}
+25
View File
@@ -15,6 +15,31 @@ export function authenticate(): RequestHandler {
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
const payload = verifyToken(token);
// 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表
if (payload.classSession) {
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
req.auth = {
token,
user: {
sub: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
classSession: true,
classCode: record.code,
},
};
next();
return;
}
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true, classId: true },
@@ -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) },
});
}
@@ -1,4 +1,4 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishContent } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -49,11 +49,14 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
const dto = toNotificationDto(created, { userIds: [student.id], withStatus: true });
// 班级房间广播:通知中心/其他端都能看到
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 定向广播给被叫的学生:客户端灵动岛立即展开
// 定向广播给被叫的学生:个人学生端灵动岛立即展开
emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
// 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录,
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
return dto;
@@ -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);
}
/** 编辑班级 */
@@ -9,6 +9,7 @@ import {
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
@@ -25,6 +26,9 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const now = new Date();
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const [
classCount,
@@ -47,15 +51,15 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where }),
prisma.notification.count({ where: { ...where, reads: { none: { userId: user.sub } } } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
student
? prisma.homework.count({
where: { ...where, statuses: { none: { userId: user.sub, completed: true } } },
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
})
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
prisma.notification.findMany({
where,
include: { creator: creatorSelect, reads: { where: { userId: user.sub } } },
include: { creator: creatorSelect, reads: { where: personalWhere } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
@@ -64,7 +68,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
statuses: student ? { where: personalWhere } : true,
},
orderBy: { createdAt: 'desc' },
take: 5,
@@ -74,7 +78,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
statuses: student ? { where: personalWhere } : true,
},
orderBy: { dueAt: 'asc' },
take: 5,
@@ -92,13 +96,13 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications: recentNotifications.map((item): NotificationDto =>
toNotificationDto(item, { userId: user.sub }),
toNotificationDto(item, { userIds: personalIds }),
),
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
),
upcomingDeadlines: upcoming.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
),
};
}
@@ -1,4 +1,4 @@
import {
import {
SOCKET_EVENTS,
gradeLevel,
gradePercent,
@@ -12,6 +12,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toGradeDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { resolvePersonalIds } from '../../lib/session.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js';
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
@@ -27,13 +28,20 @@ export interface ListGradeOptions {
examName?: string;
}
/** 学生查看自己的成绩 */
/**
* 学生查看成绩。
* - 普通学生账号:只看自己的成绩;
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
*/
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
const personalIds = await resolvePersonalIds(user);
const grades = await prisma.grade.findMany({
where: { userId: user.sub },
where: { userId: { in: personalIds } },
include,
orderBy: [{ publishedAt: 'desc' }],
take: 300,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
});
return grades.map(toGradeDto);
}
@@ -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('目标学生不属于该作业所在班级');
@@ -1,4 +1,4 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertCanPublishContent,
@@ -10,6 +10,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toNotificationDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
import { computeClassStatus } from '../schedules/schedules.service.js';
import type { CreateNotificationInput } from './notifications.schemas.js';
@@ -29,6 +30,9 @@ export async function listNotifications(
): Promise<NotificationDto[]> {
const scope = await resolveClassScope(user, options.classId);
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const notifications = await prisma.notification.findMany({
where: {
@@ -37,17 +41,17 @@ export async function listNotifications(
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}),
...(options.unreadOnly ? { reads: { none: personalWhere } } : {}),
},
include: {
creator: creatorSelect,
reads: isStaff ? true : { where: { userId: user.sub } },
reads: isStaff ? true : { where: personalWhere },
},
orderBy: [{ createdAt: 'desc' }],
take: 200,
});
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff }));
}
/**
@@ -103,48 +107,72 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
await prisma.notification.delete({ where: { id: notificationId } });
}
/** 标记单条通知已读 */
/**
* 标记单条通知已读。
* 班级账号:为全班学生写入已读记录(班级设备代全班操作),教师端的已读人数随之更新。
*/
export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string }> {
): Promise<{ notificationId: string; readAt: string; marked: number }> {
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
const read = await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId, userId: user.sub } },
create: { notificationId, userId: user.sub },
update: {},
const personalIds = await resolvePersonalIds(user);
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
// 直接 createMany 遇到重复会整体失败。
const existing = await prisma.notificationRead.findMany({
where: { notificationId, userId: { in: personalIds } },
select: { userId: true },
});
const have = new Set(existing.map((item) => item.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
return { notificationId, readAt: read.readAt.toISOString() };
if (missing.length > 0) {
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId, userId })),
});
}
return { notificationId, readAt: new Date().toISOString(), marked: missing.length };
}
/** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const pending = await prisma.notification.findMany({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
where: { ...classScopeWhere(scope), reads: { none: personalWhere } },
select: { id: true },
});
let marked = 0;
for (const item of pending) {
await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } },
create: { notificationId: item.id, userId: user.sub },
update: {},
const existing = await prisma.notificationRead.findMany({
where: { notificationId: item.id, userId: { in: personalIds } },
select: { userId: true },
});
const have = new Set(existing.map((row) => row.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
if (missing.length === 0) continue;
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId: item.id, userId })),
});
marked += missing.length;
}
return { marked: pending.length };
return { marked };
}
/** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
});
}
+4 -2
View File
@@ -52,9 +52,11 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
return;
}
const payload = verifyToken(token);
// 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间,
// "叫人"等定向消息因此能直达班级设备(见 calls.service)。
socket.data.user = {
id: payload.sub,
username: payload.username,
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,