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,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -0,0 +1,304 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { BrowserWindow, app } from 'electron';
|
||||
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
|
||||
*
|
||||
* 设计要点(都是"教室机器"这个使用场景逼出来的):
|
||||
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
|
||||
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
|
||||
* 本进程还没装过;
|
||||
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
||||
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
||||
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
|
||||
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
|
||||
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
||||
*/
|
||||
|
||||
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
|
||||
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
|
||||
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
|
||||
export function pendingUpdateDir(): string {
|
||||
return path.join(app.getPath('userData'), 'updates');
|
||||
}
|
||||
|
||||
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
|
||||
let mainWindowHiddenAt: number | null = null;
|
||||
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
||||
let installing = false;
|
||||
/**
|
||||
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
||||
*
|
||||
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
|
||||
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
|
||||
*/
|
||||
let quitHandler: (() => void) | null = null;
|
||||
|
||||
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
|
||||
export function setQuitHandler(handler: () => void): void {
|
||||
quitHandler = handler;
|
||||
}
|
||||
/** 正在下载的版本(同一版本不并发下载) */
|
||||
const downloading = new Set<string>();
|
||||
|
||||
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
|
||||
let hookedWindow: BrowserWindow | null = null;
|
||||
|
||||
/**
|
||||
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
|
||||
*
|
||||
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
|
||||
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
|
||||
*/
|
||||
export function watchMainWindow(win: BrowserWindow): void {
|
||||
if (hookedWindow === win) return;
|
||||
hookedWindow = win;
|
||||
win.on('hide', () => {
|
||||
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
|
||||
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
|
||||
void maybeInstallPendingUpdate();
|
||||
});
|
||||
win.on('show', () => {
|
||||
mainWindowHiddenAt = null;
|
||||
});
|
||||
win.on('closed', () => {
|
||||
mainWindowHiddenAt = null;
|
||||
if (hookedWindow === win) hookedWindow = null;
|
||||
});
|
||||
if (win.isVisible()) mainWindowHiddenAt = null;
|
||||
}
|
||||
|
||||
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
|
||||
function mainWindowIdleLongEnough(): boolean {
|
||||
if (mainWindowHiddenAt === null) return false;
|
||||
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
|
||||
}
|
||||
|
||||
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
|
||||
function mainWindowVisible(): boolean {
|
||||
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
|
||||
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
|
||||
}
|
||||
|
||||
/** 服务端是否要求升级 / 本机是否确实更旧 */
|
||||
export function shouldConsider(version: string): boolean {
|
||||
return getConfig().autoUpdate !== false && !!version.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
|
||||
*
|
||||
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
|
||||
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
|
||||
*/
|
||||
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
|
||||
const version = targetVersion.trim() || info.version.trim();
|
||||
if (!shouldConsider(version)) {
|
||||
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
|
||||
return;
|
||||
}
|
||||
if (downloading.has(version)) {
|
||||
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (config.pendingUpdateVersion === version) {
|
||||
const existing = config.pendingUpdateFile;
|
||||
if (existing && fs.existsSync(existing)) {
|
||||
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
|
||||
await maybeInstallPendingUpdate();
|
||||
return;
|
||||
}
|
||||
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
|
||||
}
|
||||
|
||||
if (!SAFE_FILE_NAME.test(info.fileName)) {
|
||||
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = pendingUpdateDir();
|
||||
const target = path.join(dir, info.fileName);
|
||||
const temp = `${target}.part`;
|
||||
const token = config.token?.trim() ?? '';
|
||||
if (!token) {
|
||||
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
|
||||
return;
|
||||
}
|
||||
|
||||
downloading.add(version);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// 先删掉上次中断留下的临时文件
|
||||
await fsp.rm(temp, { force: true });
|
||||
|
||||
const url = `${config.serverUrl}${info.url}`;
|
||||
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败:HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('下载失败:响应没有内容');
|
||||
}
|
||||
|
||||
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
|
||||
const handle = await fsp.open(temp, 'w');
|
||||
try {
|
||||
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
await handle.write(chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
const digest = await sha256File(temp);
|
||||
const expected = info.sha256.trim().toLowerCase();
|
||||
if (!expected) {
|
||||
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
|
||||
await fsp.rm(temp, { force: true });
|
||||
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
|
||||
return;
|
||||
}
|
||||
if (digest !== expected) {
|
||||
await fsp.rm(temp, { force: true });
|
||||
logger.error(
|
||||
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.rm(target, { force: true });
|
||||
await fsp.rename(temp, target);
|
||||
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
|
||||
logger.info(
|
||||
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
|
||||
(Date.now() - startedAt) / 1000,
|
||||
)} 秒),将在空闲时自动安装`,
|
||||
);
|
||||
await maybeInstallPendingUpdate();
|
||||
} catch (error) {
|
||||
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
||||
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
downloading.delete(version);
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件的 sha256(十六进制小写) */
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const handle = await fsp.open(file, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(1024 * 256);
|
||||
for (;;) {
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
||||
if (bytesRead <= 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/** 人类可读的字节数(日志与界面提示用) */
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果条件全部满足就装更新。
|
||||
*
|
||||
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
|
||||
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
|
||||
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
|
||||
*/
|
||||
export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
if (installing) return;
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
|
||||
|
||||
const config = getConfig();
|
||||
const version = config.pendingUpdateVersion?.trim() ?? '';
|
||||
const installer = config.pendingUpdateFile?.trim() ?? '';
|
||||
if (!version || !installer) return;
|
||||
if (!fs.existsSync(installer)) {
|
||||
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
|
||||
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||
return;
|
||||
}
|
||||
if (island.getState().inClass) {
|
||||
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
|
||||
return;
|
||||
}
|
||||
if (mainWindowVisible()) {
|
||||
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
|
||||
return;
|
||||
}
|
||||
if (!mainWindowIdleLongEnough()) {
|
||||
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
|
||||
logger.debug(
|
||||
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
|
||||
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
|
||||
logger.warn(
|
||||
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
installing = true;
|
||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||
try {
|
||||
const exe = app.getPath('exe');
|
||||
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
|
||||
const child = spawn('cmd.exe', ['/c', command], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
|
||||
if (quitHandler) {
|
||||
quitHandler();
|
||||
} else {
|
||||
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
|
||||
app.quit();
|
||||
}
|
||||
} catch (error) {
|
||||
installing = false;
|
||||
logger.error(
|
||||
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
|
||||
export function isMainWindowIdleLongEnough(): boolean {
|
||||
return mainWindowIdleLongEnough();
|
||||
}
|
||||
Reference in new issue
Block a user