Files
ClassHelper/packages/classisland-plugin/scripts/verify.mjs
T
LoveChengke 9d5ac7adf9 feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
2026-10-06 19:41:18 +08:00

391 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) && /"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-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
check(
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
);
check(
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
/scheduleRequested/.test(clientSource),
);
check(
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
/forceSchedule:\s*true/.test(bridgeSource),
);
check(
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
);
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);
}