教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。 - 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动" - 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404 - 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件 - Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发) - 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx - 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
/**
|
||
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
|
||
*
|
||
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
|
||
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
|
||
*
|
||
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
|
||
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
|
||
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
|
||
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
|
||
* 最难排查,所以专门盯住。
|
||
*/
|
||
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 results = [];
|
||
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
|
||
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
|
||
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||
|
||
const manifest = read('manifest.yml');
|
||
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
|
||
const pluginSource = read('src/Plugin.cs');
|
||
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
|
||
const bridgeSource = read('src/Services/BridgeService.cs');
|
||
const clientSource = read('src/Interop/ClassHelperClient.cs');
|
||
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
|
||
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
|
||
const mapperSource = read('src/Services/ScheduleMapper.cs');
|
||
const settingsSource = read('src/Models/PluginSettings.cs');
|
||
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
|
||
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
|
||
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
|
||
const sharedTypes = readRepo('packages/shared/src/types.ts');
|
||
const sharedConstants = readRepo('packages/shared/src/constants.ts');
|
||
|
||
/* ------------------------------------------------------------ 1. 清单一致性 */
|
||
|
||
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
|
||
function field(name) {
|
||
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
|
||
return raw?.replace(/^['"]|['"]$/g, '');
|
||
}
|
||
|
||
check('manifest.yml 有插件 id', Boolean(field('id')));
|
||
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
|
||
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
|
||
|
||
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
|
||
check(
|
||
'entranceAssembly 与 csproj 的 AssemblyName 一致',
|
||
field('entranceAssembly') === assemblyName + '.dll',
|
||
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
|
||
);
|
||
|
||
const manifestVersion = field('version');
|
||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||
check(
|
||
'插件版本号在清单与代码里一致',
|
||
Boolean(manifestVersion) && manifestVersion === codeVersion,
|
||
'manifest=' + manifestVersion + ' code=' + codeVersion,
|
||
);
|
||
|
||
check(
|
||
'图标与自述文件按清单约定命名(icon.png / README.md)',
|
||
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
|
||
);
|
||
|
||
/* ------------------------------------------------------------ 2. 注册完整性 */
|
||
|
||
check(
|
||
'插件入口带 [PluginEntrance] 且继承 PluginBase',
|
||
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
|
||
);
|
||
check(
|
||
'初始化时注册了提醒提供方',
|
||
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
|
||
);
|
||
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
|
||
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
|
||
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
|
||
check(
|
||
'设置以 JSON 持久化到插件配置目录',
|
||
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
|
||
);
|
||
|
||
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
|
||
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
|
||
check(
|
||
'提醒提供方属性引用同一个 GUID 常量',
|
||
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
|
||
);
|
||
check(
|
||
'提醒提供方会在播完后回执给服务端',
|
||
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
|
||
);
|
||
|
||
check(
|
||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||
);
|
||
check(
|
||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
|
||
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
|
||
);
|
||
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
|
||
|
||
/* ------------------------------------------------------------ 3. 接口契约 */
|
||
|
||
// 3.1 路由:插件调用的路径必须都在服务端注册过
|
||
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
|
||
const registeredPaths = new Set(
|
||
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
|
||
(item) => item[1],
|
||
),
|
||
);
|
||
for (const route of [
|
||
'/classisland/report',
|
||
'/classisland/pending',
|
||
'/classisland/ack',
|
||
'/classisland/class-plan',
|
||
]) {
|
||
check('服务端注册了 ' + route, registeredPaths.has(route));
|
||
}
|
||
check(
|
||
'插件调用的路由都存在于服务端',
|
||
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
|
||
'插件调用=[' + clientPaths.join(',') + ']',
|
||
);
|
||
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
|
||
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
|
||
|
||
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
|
||
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||
const requestFields = [
|
||
'pluginVersion',
|
||
'classIslandVersion',
|
||
'state',
|
||
'schedule',
|
||
'timeLayout',
|
||
'inClass',
|
||
'subject',
|
||
'nextSubject',
|
||
'timeState',
|
||
'periodStart',
|
||
'periodEnd',
|
||
'classPlanLoaded',
|
||
'clientTime',
|
||
'mode',
|
||
'entries',
|
||
'items',
|
||
];
|
||
const missingRequestFields = requestFields.filter(
|
||
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
|
||
);
|
||
check(
|
||
'上报请求字段都出现在插件 DTO 里',
|
||
missingRequestFields.length === 0,
|
||
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
|
||
);
|
||
|
||
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
|
||
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||
const entryFields = [
|
||
'dayOfWeek',
|
||
'startTime',
|
||
'endTime',
|
||
'subject',
|
||
'teacherName',
|
||
'weekParity',
|
||
'weekCountDiv',
|
||
'weekCountDivTotal',
|
||
'planName',
|
||
];
|
||
const csharpJsonNames = new Set(
|
||
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
|
||
);
|
||
const missingEntryFields = entryFields.filter(
|
||
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
|
||
);
|
||
check(
|
||
'课表条目字段服务端 zod 与插件 DTO 对齐',
|
||
missingEntryFields.length === 0,
|
||
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
|
||
);
|
||
|
||
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
|
||
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
|
||
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
|
||
if (knownLocalOnly.has(name)) return false;
|
||
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
|
||
});
|
||
check(
|
||
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
|
||
unknownJsonNames.length === 0,
|
||
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
|
||
);
|
||
|
||
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
|
||
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
|
||
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
|
||
}
|
||
|
||
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
|
||
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||
for (const name of [
|
||
'classId',
|
||
'title',
|
||
'content',
|
||
'durationSeconds',
|
||
'speech',
|
||
'speechContent',
|
||
'saveToNotifications',
|
||
'priority',
|
||
]) {
|
||
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
|
||
}
|
||
check(
|
||
'提醒时长有上界(避免老师发出永不消失的提醒)',
|
||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
|
||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
|
||
);
|
||
|
||
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
|
||
check(
|
||
'共享契约声明了 classisland 实时事件',
|
||
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
|
||
);
|
||
check(
|
||
'服务端广播事件名取自共享常量(不是手写字符串)',
|
||
/SOCKET_EVENTS\.classislandState/.test(serverService),
|
||
);
|
||
|
||
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
|
||
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
|
||
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
|
||
check(
|
||
'HttpClient 显式配置代理(不裸用默认行为)',
|
||
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
|
||
);
|
||
check(
|
||
'本地/内网地址绕开系统代理',
|
||
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
|
||
);
|
||
check(
|
||
'代理判定覆盖回环与内网网段',
|
||
/IsLoopback/.test(clientSource) &&
|
||
/10\.0\.0\.0\/8/.test(clientSource) &&
|
||
/192\.168\.0\.0\/16/.test(clientSource),
|
||
);
|
||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||
|
||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||
check(
|
||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||
providerSource.includes('Dispatcher.UIThread.Post'),
|
||
);
|
||
check(
|
||
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
|
||
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
|
||
);
|
||
check(
|
||
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
|
||
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
|
||
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
|
||
);
|
||
check(
|
||
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
||
mapperSource.includes('Subject.Fallback'),
|
||
);
|
||
check(
|
||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||
settingsSource.includes('NotificationPollSeconds') &&
|
||
bridgeSource.includes('_notifyTimer') &&
|
||
bridgeSource.includes('NotificationPollSeconds'),
|
||
);
|
||
|
||
/* ------------------------------------------------------------ 结果 */
|
||
|
||
const failed = results.filter((item) => !item.ok);
|
||
for (const item of results) {
|
||
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
|
||
}
|
||
console.log('');
|
||
console.log(
|
||
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
|
||
);
|
||
if (failed.length > 0) {
|
||
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
|
||
process.exit(1);
|
||
}
|