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