功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
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();
|
||
}
|