功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
217 lines
8.5 KiB
TypeScript
217 lines
8.5 KiB
TypeScript
import { app, type BrowserWindow } from 'electron';
|
||
import {
|
||
GITHUB_RELEASES_LATEST_API,
|
||
GITHUB_RELEASES_PAGE,
|
||
UPDATE_CHECK_CACHE_TTL_MS,
|
||
UPDATE_CHECK_TIMEOUT_MS,
|
||
UPDATE_CHECK_USER_AGENT,
|
||
isNewerVersion,
|
||
normalizeVersion,
|
||
type UpdateAssetDto,
|
||
type UpdateInfo,
|
||
} from '@classhelper/shared';
|
||
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
|
||
import { getConfig, saveConfig } from './config.js';
|
||
import { logger } from './logger.js';
|
||
|
||
/**
|
||
* 更新检查(桌面客户端)。
|
||
*
|
||
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
|
||
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
|
||
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
|
||
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
|
||
*
|
||
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
|
||
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
|
||
*/
|
||
|
||
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
|
||
export { UPDATE_AVAILABLE_CHANNEL };
|
||
|
||
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
|
||
const STARTUP_DELAY_MS = 5_000;
|
||
|
||
interface GithubReleasePayload {
|
||
tag_name?: unknown;
|
||
name?: unknown;
|
||
published_at?: unknown;
|
||
body?: unknown;
|
||
html_url?: unknown;
|
||
assets?: unknown;
|
||
}
|
||
|
||
let cache: { at: number; info: UpdateInfo } | null = null;
|
||
let startupTimer: ReturnType<typeof setTimeout> | null = null;
|
||
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
|
||
let startupCheckSkipped = false;
|
||
|
||
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
|
||
function failure(error: string): UpdateInfo {
|
||
return {
|
||
ok: false,
|
||
error,
|
||
currentVersion: app.getVersion(),
|
||
latestVersion: null,
|
||
hasUpdate: false,
|
||
releaseUrl: GITHUB_RELEASES_PAGE,
|
||
releaseName: null,
|
||
publishedAt: null,
|
||
notes: null,
|
||
assets: [],
|
||
checkedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function toAssets(raw: unknown): UpdateAssetDto[] {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw
|
||
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
|
||
.filter(
|
||
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
|
||
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
|
||
)
|
||
.map((item) => ({
|
||
name: item.name,
|
||
downloadUrl: item.browser_download_url,
|
||
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
|
||
}));
|
||
}
|
||
|
||
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
|
||
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
|
||
if (!latestVersion) {
|
||
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
|
||
return failure('GitHub 返回的版本号无法识别');
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
error: null,
|
||
currentVersion,
|
||
latestVersion,
|
||
hasUpdate: isNewerVersion(currentVersion, latestVersion),
|
||
releaseUrl:
|
||
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
|
||
releaseName: typeof payload.name === 'string' ? payload.name : null,
|
||
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
|
||
notes: typeof payload.body === 'string' ? payload.body : null,
|
||
assets: toAssets(payload.assets),
|
||
checkedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
|
||
function describeFailure(error: unknown): string {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
|
||
}
|
||
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
|
||
return '无法连接 GitHub,本机可能没有外网';
|
||
}
|
||
return `检查更新失败:${message}`;
|
||
}
|
||
|
||
/**
|
||
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
|
||
*
|
||
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
|
||
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
|
||
*/
|
||
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
|
||
const currentVersion = app.getVersion();
|
||
const now = Date.now();
|
||
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
|
||
return cache.info;
|
||
}
|
||
|
||
let info: UpdateInfo;
|
||
try {
|
||
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
|
||
headers: {
|
||
// GitHub API 强制要求 User-Agent,不带会直接 403
|
||
'User-Agent': UPDATE_CHECK_USER_AGENT,
|
||
Accept: 'application/vnd.github+json',
|
||
},
|
||
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
info =
|
||
response.status === 403 || response.status === 429
|
||
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
|
||
: failure(`GitHub 返回 HTTP ${response.status}`);
|
||
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
|
||
} else {
|
||
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
|
||
logger.info(
|
||
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
|
||
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
info = failure(describeFailure(error));
|
||
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
|
||
}
|
||
|
||
cache = { at: now, info };
|
||
return info;
|
||
}
|
||
|
||
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
|
||
export function ignoreUpdateVersion(version: string): void {
|
||
const normalized = normalizeVersion(version);
|
||
if (!normalized) return;
|
||
saveConfig({ ignoredUpdateVersion: normalized });
|
||
logger.info(`已忽略版本 ${normalized} 的更新提示`);
|
||
}
|
||
|
||
/** 该版本是否已被用户忽略 */
|
||
export function isUpdateIgnored(version: string): boolean {
|
||
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
|
||
}
|
||
|
||
/**
|
||
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
|
||
*
|
||
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
|
||
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
|
||
*/
|
||
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
|
||
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||
startupCheckSkipped = true;
|
||
logger.info('更新检查:冒烟模式下跳过启动自动检查');
|
||
return;
|
||
}
|
||
if (startupTimer) clearTimeout(startupTimer);
|
||
|
||
startupTimer = setTimeout(() => {
|
||
startupTimer = null;
|
||
void (async () => {
|
||
try {
|
||
const info = await checkForUpdates();
|
||
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
|
||
if (isUpdateIgnored(info.latestVersion)) {
|
||
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
|
||
return;
|
||
}
|
||
const win = getWindow();
|
||
if (!win || win.isDestroyed()) return;
|
||
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
|
||
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
|
||
} catch (error) {
|
||
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
|
||
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
|
||
}
|
||
})();
|
||
}, STARTUP_DELAY_MS);
|
||
// 不阻止进程退出:这个定时器只是"顺便查一下"
|
||
startupTimer.unref?.();
|
||
}
|
||
|
||
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
|
||
export function wasStartupCheckSkipped(): boolean {
|
||
return startupCheckSkipped;
|
||
}
|