Files
ClassHelper/packages/server/src/modules/notifications/notifications.service.ts
T
LoveChengke d0830f6830 feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。

- 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动"
- 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404
- 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件
- Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发)
- 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx
- 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
2026-09-27 14:59:12 +08:00

228 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertCanPublishContent,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
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 { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
import { computeClassStatus } from '../schedules/schedules.service.js';
import type { CreateNotificationInput } from './notifications.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
export interface ListNotificationOptions {
classId?: string;
priority?: string;
unreadOnly?: boolean;
keyword?: string;
}
export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): 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: {
...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(options.unreadOnly ? { reads: { none: personalWhere } } : {}),
},
include: {
creator: creatorSelect,
reads: isStaff ? true : { where: personalWhere },
},
orderBy: [{ createdAt: 'desc' }],
take: 200,
});
return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff }));
}
/**
* 发布通知并实时广播到 class:{classId} 房间。
*
* 上课时段保护:若目标班级当前正在上课且优先级为"紧急",必须由前端二次确认
* (`confirmDuringClass: true`)才允许发布,否则返回 409 URGENT_DURING_CLASS,
* 由 Web 端弹出全屏警告并倒计时 3 秒。
*/
export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertCanPublishContent(user, input.classId);
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
const status = await computeClassStatus(input.classId);
if (status.inClass) {
logger.warn(
`上课时段发布紧急通知被拦截:班级=${input.classId} 课程=${status.current?.courseName ?? '-'}(等待教师二次确认)`,
);
throw new ApiError(409, 'URGENT_DURING_CLASS', '现在为上课时间段,发布紧急通知会干扰上课,请再次确认', {
classId: input.classId,
current: status.current,
next: status.next,
week: status.week,
serverTime: status.serverTime,
});
}
}
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: input.title,
content: input.content,
priority,
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
// 是否真的推送由**教室客户端**在设置页里选的显示位置决定(见 Class.notificationChannel):
// 选了"只弹 ClassHelper 客户端"就不打扰 ClassIsland。
await pushToClassIslandIfEnabled({
classId: created.classId,
title: created.title,
content: created.content,
priority,
createdBy: user.sub,
notificationId: created.id,
teacherName: created.creator?.name ?? created.creator?.username ?? null,
});
return dto;
}
/**
* 通知类发布入口的公共尾巴:按班级的显示位置决定要不要推 ClassIsland。
*
* 推失败**不能**影响通知本身已经发布成功这件事 —— 教室机器离线、插件没装都不该让老师收到 500,
* 因此这里把异常收敛成日志告警。
*/
async function pushToClassIslandIfEnabled(input: {
classId: string;
title: string;
content: string;
priority: string;
createdBy: string;
notificationId: string;
teacherName: string | null;
}): Promise<void> {
try {
if (!(await shouldPushToClassIsland(input.classId))) {
logger.info(`通知未推送 ClassIsland:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
return;
}
await pushToClassIsland({
classId: input.classId,
title: input.title,
content: input.content,
urgent: input.priority === 'URGENT',
createdBy: input.createdBy,
notificationId: input.notificationId,
teacherName: input.teacherName,
});
} catch (error) {
logger.warn(`通知推送 ClassIsland 失败(通知本身已发布):${String(error)}`);
}
}
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishContent(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
}
/**
* 标记单条通知已读。
* 班级账号:为全班学生写入已读记录(班级设备代全班操作),教师端的已读人数随之更新。
*/
export async function markAsRead(
user: TokenPayload,
notificationId: 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 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));
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: personalWhere } },
select: { id: true },
});
let marked = 0;
for (const item of pending) {
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 };
}
/** 未读数量(客户端红点) */
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: personalIdWhere(personalIds) } },
});
}