功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
169 lines
7.7 KiB
JavaScript
169 lines
7.7 KiB
JavaScript
/**
|
||
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
|
||
*
|
||
* 用法:
|
||
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
|
||
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
|
||
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
|
||
*
|
||
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
||
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
|
||
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
|
||
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
|
||
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||
const cacheDir = path.join(repoRoot, '.cache');
|
||
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
|
||
|
||
const argv = process.argv.slice(2);
|
||
const withCipx = argv.includes('--cipx');
|
||
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
|
||
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
|
||
|
||
const log = (message) => console.log(`[classisland-plugin] ${message}`);
|
||
|
||
/* ------------------------------------------------------------ 1. 环境准备 */
|
||
|
||
const env = { ...process.env };
|
||
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
|
||
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
|
||
}
|
||
const setDefault = (key, value) => {
|
||
if (!env[key]) env[key] = value;
|
||
};
|
||
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
|
||
setDefault('TEMP', path.join(cacheDir, 'tmp'));
|
||
setDefault('TMP', env.TEMP);
|
||
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
|
||
env.DOTNET_NOLOGO = '1';
|
||
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
|
||
|
||
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
|
||
function resolveExecutable(envKey, names, fallbacks) {
|
||
const candidates = [];
|
||
if (env[envKey]) candidates.push(env[envKey]);
|
||
const finder = process.platform === 'win32' ? 'where' : 'which';
|
||
for (const name of names) {
|
||
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
|
||
if (found.status === 0) {
|
||
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
|
||
}
|
||
}
|
||
candidates.push(...fallbacks);
|
||
for (const candidate of candidates) {
|
||
if (!candidate) continue;
|
||
try {
|
||
if (fs.statSync(candidate).isFile()) return candidate;
|
||
} catch {
|
||
/* 继续试下一个 */
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const dotnet = resolveExecutable(
|
||
'DOTNET_EXE',
|
||
['dotnet'],
|
||
process.platform === 'win32'
|
||
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
|
||
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
|
||
);
|
||
if (!dotnet) {
|
||
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
|
||
}
|
||
|
||
const powerShell = resolveExecutable(
|
||
'POWERSHELL_EXE',
|
||
['pwsh', 'powershell'],
|
||
process.platform === 'win32'
|
||
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
|
||
: [],
|
||
);
|
||
|
||
/* ------------------------------------------------------------ 2. 构建 */
|
||
|
||
const buildArgs = ['build', '-c', 'Release'];
|
||
if (withCipx) {
|
||
buildArgs.push('-p:CreateCipx=true');
|
||
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
|
||
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
|
||
}
|
||
buildArgs.push(...passthrough);
|
||
|
||
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
|
||
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
|
||
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
|
||
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
|
||
|
||
/* ------------------------------------------------------------ 3. 产物自检 */
|
||
|
||
const outputDir = path.join(pluginDir, 'bin', 'Release');
|
||
const manifestPath = path.join(outputDir, 'manifest.yml');
|
||
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
|
||
|
||
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
|
||
if (!fs.existsSync(required)) {
|
||
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
|
||
}
|
||
}
|
||
|
||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
|
||
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
|
||
if (entrance !== path.basename(assemblyPath)) {
|
||
throw new Error(
|
||
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
|
||
);
|
||
}
|
||
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
|
||
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
|
||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||
if (!manifestVersion || manifestVersion !== codeVersion) {
|
||
throw new Error(
|
||
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
|
||
);
|
||
}
|
||
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
|
||
|
||
/* ------------------------------------------------------------ 4. 打包归集 */
|
||
|
||
if (!withCipx) {
|
||
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
|
||
process.exit(0);
|
||
}
|
||
|
||
const cipxDir = path.join(pluginDir, 'cipx');
|
||
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
|
||
if (!fs.existsSync(cipxFile)) {
|
||
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
||
}
|
||
|
||
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
|
||
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
|
||
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
|
||
// 产物只有 `安装包/*.cipx` + `checksums.md`:**.cipx 本身就是个 zip**,解压即用的那份
|
||
// 「免安装目录」与它内容完全一样,纯属重复留档,已不再产出。
|
||
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
|
||
const versionDir = path.join(releasesRoot, pluginVersion);
|
||
const installerDir = path.join(versionDir, '安装包');
|
||
fs.rmSync(installerDir, { recursive: true, force: true });
|
||
fs.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
|
||
fs.mkdirSync(installerDir, { recursive: true });
|
||
|
||
for (const file of fs.readdirSync(cipxDir)) {
|
||
// .cipx 是插件包本体,校验清单跟着版本目录走
|
||
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
|
||
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
|
||
}
|
||
|
||
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
|
||
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
|