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
@@ -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),
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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) } },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user