功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import {
|
||
SOCKET_EVENTS,
|
||
resolveClassStatus,
|
||
type ClassPeriod,
|
||
type HomeworkDto,
|
||
type NotificationDto,
|
||
type ScheduleDto,
|
||
} from '@classhelper/shared';
|
||
import { notificationApi, scheduleApi } from '../api/index.js';
|
||
import { fetchWithCache } from '../cache/index.js';
|
||
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
|
||
import { useAuthStore } from '../stores/auth.js';
|
||
import { useNotificationStore } from '../stores/notifications.js';
|
||
import { useRealtimeStore } from '../stores/realtime.js';
|
||
|
||
/**
|
||
* 灵动岛桥接(渲染进程侧)。
|
||
*
|
||
* 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 ——
|
||
* 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。
|
||
*
|
||
* 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次,
|
||
* 因此下课后最多 10 秒内会自动弹出暂存的通知。
|
||
*/
|
||
const TICK_INTERVAL_MS = 10_000;
|
||
const REFRESH_INTERVAL_MS = 5 * 60_000;
|
||
|
||
let schedules: ScheduleDto[] = [];
|
||
let currentPeriod: ClassPeriod | null = null;
|
||
let inClass = false;
|
||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
|
||
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
|
||
return { inClass, currentPeriodEnd: currentPeriod?.endTime ?? null };
|
||
}
|
||
|
||
export function getIslandSchedules(): ScheduleDto[] {
|
||
return schedules;
|
||
}
|
||
|
||
/** 主动刷新课表(课表变更事件触发) */
|
||
export async function refreshIslandSchedules(): Promise<void> {
|
||
const appStore = useAppStore();
|
||
const auth = useAuthStore();
|
||
if (!auth.token) return;
|
||
|
||
const week = appStore.currentWeek;
|
||
const result = await fetchWithCache<ScheduleDto[]>(
|
||
'schedules',
|
||
`week:${week}`,
|
||
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }),
|
||
[],
|
||
);
|
||
schedules = result.data;
|
||
recomputeClassState(true);
|
||
}
|
||
|
||
function recomputeClassState(force = false): void {
|
||
const appStore = useAppStore();
|
||
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek);
|
||
const nextInClass = status.inClass;
|
||
currentPeriod = status.current;
|
||
|
||
if (!force && nextInClass === inClass) return;
|
||
inClass = nextInClass;
|
||
|
||
window.desktop?.islandSetClassState({
|
||
inClass,
|
||
currentPeriodEnd: currentPeriod?.endTime ?? null,
|
||
week: appStore.currentWeek,
|
||
});
|
||
}
|
||
|
||
/** 把校园通知投递到灵动岛(由实时通道收到 notification:new 时调用) */
|
||
export function pushNotificationToIsland(notification: NotificationDto): void {
|
||
window.desktop?.islandPush({
|
||
notification: {
|
||
id: notification.id,
|
||
title: notification.title,
|
||
content: notification.content,
|
||
priority: notification.priority,
|
||
createdAt: notification.createdAt,
|
||
courseName: currentPeriod?.courseName ?? null,
|
||
teacherName: notification.creator?.name ?? null,
|
||
kind: 'notification',
|
||
},
|
||
context: getIslandClassContext(),
|
||
});
|
||
}
|
||
|
||
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
||
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
|
||
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
|
||
if (isLocalHomework(homework)) {
|
||
return;
|
||
}
|
||
window.desktop?.islandPush({
|
||
notification: {
|
||
id: `homework-${homework.id}`,
|
||
title: homework.title,
|
||
content: homework.content?.trim() || '(老师没有填写作业说明)',
|
||
priority: 'NORMAL',
|
||
createdAt: homework.createdAt,
|
||
courseName: homework.course?.name ?? null,
|
||
teacherName: homework.creator?.name ?? null,
|
||
kind: 'homework',
|
||
},
|
||
context: getIslandClassContext(),
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 本机录入的作业 */
|
||
|
||
/**
|
||
* 本机刚录入的作业指纹。
|
||
*
|
||
* 为什么按指纹而不只按 id:`homework:new` 是服务端**先广播、后回响应**的,
|
||
* 录入接口返回的 id 很可能比实时事件到得还晚 —— 只记 id 会漏掉"事件先到"的那一半,
|
||
* 于是本机录的作业照样弹上岛。因此在发请求**之前**就按内容记一份指纹,
|
||
* 回来后补记 id(覆盖事件晚到的情况)。指纹带 TTL,避免长期占用内存。
|
||
*/
|
||
const localHomeworkKeys = new Set<string>();
|
||
let localHomeworkKeysAt = 0;
|
||
const LOCAL_HOMEWORK_TTL_MS = 60_000;
|
||
|
||
/** 作业内容指纹:同一班、同一天、同标题同正文 = 就是本机刚录的那条 */
|
||
function homeworkFingerprint(homework: {
|
||
classId?: string | null;
|
||
title?: string | null;
|
||
content?: string | null;
|
||
assignDate?: string | null;
|
||
}): string {
|
||
return [
|
||
homework.classId ?? '',
|
||
homework.assignDate ?? '',
|
||
(homework.title ?? '').trim(),
|
||
(homework.content ?? '').trim(),
|
||
].join('|');
|
||
}
|
||
|
||
/** 录入前/后登记"这条作业是本机录的"(id 可后补) */
|
||
export function markHomeworkCreatedLocally(homework: {
|
||
id?: string | null;
|
||
classId?: string | null;
|
||
title?: string | null;
|
||
content?: string | null;
|
||
assignDate?: string | null;
|
||
}): void {
|
||
const now = Date.now();
|
||
if (now - localHomeworkKeysAt > LOCAL_HOMEWORK_TTL_MS) localHomeworkKeys.clear();
|
||
localHomeworkKeysAt = now;
|
||
localHomeworkKeys.add(homeworkFingerprint(homework));
|
||
if (homework.id) localHomeworkKeys.add(homework.id);
|
||
}
|
||
|
||
/** 这条作业是不是本机刚录的(供投递前判定与冒烟验证) */
|
||
export function isLocalHomework(homework: {
|
||
id?: string | null;
|
||
classId?: string | null;
|
||
title?: string | null;
|
||
content?: string | null;
|
||
assignDate?: string | null;
|
||
}): boolean {
|
||
if (homework.id && localHomeworkKeys.has(homework.id)) return true;
|
||
return localHomeworkKeys.has(homeworkFingerprint(homework));
|
||
}
|
||
|
||
/**
|
||
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
||
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
||
* 两种都带"叫人"类型(徽标/「收到」按钮/更大的展开卡)。
|
||
*/
|
||
export function pushCallToIsland(call: NotificationDto): void {
|
||
window.desktop?.islandPush({
|
||
notification: {
|
||
id: call.id,
|
||
title: call.title,
|
||
content: call.content?.trim() || '老师正在等你,请尽快前往。',
|
||
priority: call.priority,
|
||
createdAt: call.createdAt,
|
||
courseName: null,
|
||
teacherName: call.creator?.name ?? null,
|
||
kind: 'call',
|
||
subtitle: '请尽快前往,收到后点「收到」',
|
||
},
|
||
context: getIslandClassContext(),
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
|
||
|
||
/**
|
||
* 补弹条数上限。
|
||
*
|
||
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
|
||
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
|
||
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
|
||
* 补太多只会让"展开更多"变成一个没有意义的按钮。
|
||
*/
|
||
const REPLAY_MAX_COUNT = 5;
|
||
|
||
/**
|
||
* 上线后补弹「离线期间收到的消息」(需求 11)。
|
||
*
|
||
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
|
||
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
|
||
*
|
||
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
|
||
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
|
||
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
|
||
*
|
||
* 实现上有四条约定:
|
||
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
|
||
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
|
||
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
|
||
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
|
||
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
|
||
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
|
||
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
|
||
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
|
||
*
|
||
* @returns 实际补弹的条数(0 = 没补)
|
||
*/
|
||
export async function replayMissedNotifications(): Promise<number> {
|
||
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
|
||
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
|
||
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
|
||
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
|
||
// 补弹本身由人工/单元路径覆盖,不进冒烟。
|
||
if (window.desktop?.smokeTest === true) return 0;
|
||
|
||
const offlineSince = readOfflineSince();
|
||
if (!offlineSince) return 0;
|
||
const sinceMs = Date.parse(offlineSince);
|
||
if (Number.isNaN(sinceMs)) {
|
||
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
|
||
clearOfflineSince();
|
||
return 0;
|
||
}
|
||
|
||
let unread: NotificationDto[];
|
||
try {
|
||
unread = await notificationApi.list({ unreadOnly: true });
|
||
} catch {
|
||
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
|
||
return 0;
|
||
}
|
||
|
||
const missed = unread
|
||
.filter((item) => {
|
||
const at = Date.parse(item.createdAt);
|
||
return !Number.isNaN(at) && at > sinceMs;
|
||
})
|
||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||
|
||
const picked = missed.slice(-REPLAY_MAX_COUNT);
|
||
for (const item of picked) pushNotificationToIsland(item);
|
||
|
||
// 无论补到几条都清掉:这一轮离线已经被处理过了
|
||
clearOfflineSince();
|
||
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
|
||
return picked.length;
|
||
}
|
||
|
||
/**
|
||
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
||
* 由 App.vue 在挂载时订阅一次。
|
||
*/
|
||
export function subscribeIslandMarkRead(): void {
|
||
window.desktop?.onIslandMarkRead?.((id) => {
|
||
void (async () => {
|
||
const store = useNotificationStore();
|
||
try {
|
||
await store.markRead(id);
|
||
} catch {
|
||
// 离线或通知已被删除:本地先记为已读,等下次同步纠正
|
||
const target = store.items.find((item) => item.id === id);
|
||
if (target) target.read = true;
|
||
}
|
||
})();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 灵动岛点了"标为已读"(多条通知的整批)→ 把这一批都在通知中心里标记已读。
|
||
* 与单条版本同一套兜底:离线时先在本地记为已读。
|
||
*/
|
||
export function subscribeIslandMarkAllRead(): void {
|
||
window.desktop?.onIslandMarkAllRead?.((ids) => {
|
||
void (async () => {
|
||
const store = useNotificationStore();
|
||
await Promise.all(
|
||
(ids ?? []).map(async (id) => {
|
||
try {
|
||
await store.markRead(id);
|
||
} catch {
|
||
const target = store.items.find((item) => item.id === id);
|
||
if (target) target.read = true;
|
||
}
|
||
}),
|
||
);
|
||
})();
|
||
});
|
||
}
|
||
|
||
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
|
||
function onScheduleUpdated(): void {
|
||
void refreshIslandSchedules().catch(() => undefined);
|
||
}
|
||
|
||
export function startIslandBridge(): void {
|
||
if (tickTimer) return;
|
||
|
||
void refreshIslandSchedules().catch(() => undefined);
|
||
|
||
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
|
||
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||
|
||
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
|
||
refreshTimer = setInterval(() => {
|
||
void refreshIslandSchedules().catch(() => undefined);
|
||
}, REFRESH_INTERVAL_MS);
|
||
}
|
||
|
||
/**
|
||
* 停止桥接:退出登录 / 切换账号时必须调用。
|
||
*
|
||
* 只清定时器是不够的:
|
||
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
|
||
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
|
||
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
|
||
*/
|
||
export function stopIslandBridge(): void {
|
||
if (tickTimer) clearInterval(tickTimer);
|
||
if (refreshTimer) clearInterval(refreshTimer);
|
||
tickTimer = null;
|
||
refreshTimer = null;
|
||
|
||
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||
schedules = [];
|
||
currentPeriod = null;
|
||
inClass = false;
|
||
|
||
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
|
||
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
|
||
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
|
||
window.desktop?.islandSetClassState({
|
||
inClass: false,
|
||
currentPeriodEnd: null,
|
||
week: useAppStore().currentWeek,
|
||
});
|
||
}
|