Files
ClassHelper/packages/desktop-client/src/main/crash-guard.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

188 lines
7.3 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 { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
import { logger } from './logger.js';
/**
* 崩溃自愈(主窗口)。
*
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
* 只能人工去重启,因此这里补三条自愈路径:
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
*/
/** 限流:窗口内最多几次 */
const RECOVER_MAX_TIMES = 3;
/** 限流窗口:10 分钟 */
const RECOVER_WINDOW_MS = 10 * 60_000;
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
quitApp: () => void;
}
const windowRecoverAt: number[] = [];
const restartAt: number[] = [];
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
let recovering = false;
/** 当前主窗口(重建后会换成新实例) */
let mainWindow: BrowserWindow | null = null;
/** install 时拿到的回调集合(事件回调里复用) */
let options: CrashGuardOptions | null = null;
/**
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
*/
export function installCrashGuard(guard: CrashGuardOptions): void {
options = guard;
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
if (!isMainWindowContents(contents.id)) return;
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
logger.error(
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
);
});
process.on('uncaughtException', (error) => {
logger.error('主进程未捕获异常:', error);
restartIfIdle('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
/**
* 记录当前主窗口并挂上无响应监听。
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
*/
export function watchMainWindow(win: BrowserWindow): void {
mainWindow = win;
win.on('unresponsive', () => {
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
armUnresponsiveTimer();
});
win.on('responsive', () => {
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
logger.info('主窗口界面已恢复响应,取消重建');
}
});
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}
function armUnresponsiveTimer(): void {
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
return;
}
const now = Date.now();
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
windowRecoverAt.shift();
}
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
':请检查显卡驱动或教学软件冲突后手动重启应用',
);
return;
}
windowRecoverAt.push(now);
recovering = true;
try {
const win = mainWindow;
if (win && !win.isDestroyed()) {
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
recovering = false;
}
}
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
function restartIfIdle(reason: string): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
return;
}
let idle = false;
try {
idle = options?.isIdle() === true;
} catch (error) {
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
}
if (!idle) {
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
return;
}
const now = Date.now();
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
restartAt.shift();
}
if (restartAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
);
return;
}
restartAt.push(now);
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
app.relaunch();
options?.quitApp();
}