Files
ClassHelper/packages/desktop-client/src/renderer/island/bridge.ts
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

356 lines
14 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,
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,
});
}