线上问题的根因 - ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室 (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、 服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。 - 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除; 定时上报只报**运行状态**(当前上什么课),不带课表。 课表同步(#3) - 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule` (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记 - 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表 —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用 - 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明 「客户端」页(#4) - 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片, 两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作 - 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER), 页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用 - 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在), 从详情抽屉里给链接 —— 不删功能 成绩(#5/#6/#7) - 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、 `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里) - 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、 并列同名次(1、2、2、4) - 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜 - `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改 - 客户端的成绩查询开关改为只读(只显示服务端下发的状态) 其他(#2/#5/#8/#9) - 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次 (原先这一级只能来自 `.env`) - 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`, 与手动发布页共用 - 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端) - 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测, 而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度 - `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构, 往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因 验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿; 后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、 Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
406 lines
18 KiB
JavaScript
406 lines
18 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 writerSource = read('src/Services/ClassPlanWriter.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) && /"ClassHelper 联动"/.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.10 课表同步方向(2026-10-11 定稿):**两个方向都只由 Web 端的一次性动作触发**。
|
||
// 2026-10-06 曾经把它做成"服务端 → 教室自动下发 + 本机两个开关",但默认 15 秒一轮的
|
||
// 自动上传 + 自动镜像会让服务端与教室里的课表每 15 秒互相覆盖一次,老师看到的是
|
||
// "课表被反复删改"(2026-10-11 实测)。现在本机没有任何"一直同步"的开关:
|
||
// · 教室 → 服务端:`scheduleRequested` 一次性待办(Web 端「从教室机器获取课表」)
|
||
// · 服务端 → 教室:`mirrorRequested` 一次性待办(Web 端「下发课表到教室」,强制替换)
|
||
// 这几条盯着新契约,别删。
|
||
check(
|
||
'课表两个方向都改为按需:本机不再有「自动回传 / 自动镜像」开关',
|
||
!/_uploadSchedule/.test(settingsSource) && !/_mirrorSchedule/.test(settingsSource),
|
||
);
|
||
check(
|
||
'定时上报只报运行状态,课表仅在服务端显式请求时上传',
|
||
/var uploadSchedule = forceSchedule;/.test(bridgeSource),
|
||
);
|
||
check(
|
||
'响应 DTO 里有 scheduleRequested / mirrorRequested(两个方向的一次性待办)',
|
||
/scheduleRequested/.test(clientSource) && /mirrorRequested/.test(clientSource),
|
||
);
|
||
check(
|
||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
||
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
|
||
/forceSchedule:\s*true/.test(bridgeSource),
|
||
);
|
||
check(
|
||
'只有收到 mirrorRequested 才镜像(不再跟着每一次上报走)',
|
||
/MirrorRequested\s*==\s*true/.test(bridgeSource),
|
||
);
|
||
check(
|
||
'镜像写入是「强制替换」(停用冲突的旧课表,而不是只留一条警告)',
|
||
/Apply\(profile, plan, force: true\)/.test(bridgeSource) &&
|
||
/IsEnabled = false/.test(writerSource),
|
||
);
|
||
check(
|
||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
||
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
|
||
);
|
||
check(
|
||
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
|
||
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.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(
|
||
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
|
||
providerSource.includes('_deferred') &&
|
||
providerSource.includes('IsImmediate') &&
|
||
providerSource.includes('FlushDeferred') &&
|
||
providerSource.includes('OnBreakingTime'),
|
||
);
|
||
check(
|
||
'主动通知(紧急 / 叫人)上课时段也立即弹',
|
||
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
|
||
);
|
||
check(
|
||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
|
||
);
|
||
check(
|
||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||
settingsSource.includes('NotificationPollSeconds') &&
|
||
bridgeSource.includes('_notifyTimer') &&
|
||
bridgeSource.includes('NotificationPollSeconds'),
|
||
);
|
||
|
||
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
|
||
|
||
check(
|
||
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
|
||
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
|
||
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
|
||
);
|
||
check(
|
||
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
|
||
mapperSource.includes('Truncate(') &&
|
||
mapperSource.includes('SubjectMaxLength') &&
|
||
mapperSource.includes('PlanNameMaxLength'),
|
||
);
|
||
check(
|
||
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
|
||
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
|
||
);
|
||
check(
|
||
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
|
||
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
|
||
);
|
||
check(
|
||
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
|
||
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
|
||
);
|
||
check(
|
||
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
|
||
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
|
||
);
|
||
check(
|
||
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
|
||
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
|
||
);
|
||
check(
|
||
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
||
clientSource.includes('响应不是 ClassHelper的标准格式') &&
|
||
clientSource.includes('MaxResponseContentBufferSize'),
|
||
);
|
||
check(
|
||
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
|
||
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
|
||
);
|
||
check(
|
||
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
|
||
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
|
||
);
|
||
check(
|
||
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
|
||
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
|
||
);
|
||
|
||
/* ------------------------------------------------------------ 结果 */
|
||
|
||
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);
|
||
}
|