Files
ClassHelper/packages/server/scripts/verify-classisland.mjs
T
LoveChengke 9a38f86cd3 feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
  端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
  运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
  (保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
  `--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
  upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
  completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
  直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
  classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
  ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
  Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。

三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
  「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
  两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
  自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
  已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
  是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。

更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
  一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
  改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。

Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
  (原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
  viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
  并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。

版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
  docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
  有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
  shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
2026-10-05 16:39:38 +08:00

657 lines
27 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.
#!/usr/bin/env node
/**
* ClassIsland 联动验证(插件侧接口 + 下发链路)。
*
* 覆盖范围:
* 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
* 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
* 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
* 4) 停用设备后插件上报被 403 拦下
* 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:node packages/server/scripts/verify-classisland.mjs
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
*
* 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
* 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
*/
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
/** 造一份插件会上报的课表(周一/周二,含单双周) */
function sampleSchedule() {
return {
mode: 'merge',
entries: [
{
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
subject: '语文',
teacherName: '王老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周一课表',
},
{
dayOfWeek: 1,
startTime: '08:55',
endTime: '09:40',
subject: '数学',
teacherName: '李老师',
weekParity: 'ODD',
weekCountDiv: 1,
weekCountDivTotal: 2,
planName: '周一课表',
},
{
dayOfWeek: 2,
startTime: '08:00',
endTime: '08:45',
subject: '英语',
teacherName: '张老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周二课表',
},
],
};
}
function sampleTimeLayout() {
return {
name: 'ClassIsland 验证时间表',
mode: 'replace',
items: [
{ index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
{ index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
{ index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
],
};
}
/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
async function loginWithCandidates(username, passwords) {
for (const password of passwords) {
const result = await api('/auth/login', { method: 'POST', body: { username, password } });
const token = result.payload?.data?.token;
if (token) return token;
}
return null;
}
/**
* 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
*
* 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
* 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
* `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
* 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
* 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
* 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
*
* 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
* 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
* 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
*/
async function drainPending(deviceToken) {
let total = 0;
for (let round = 0; round < 20; round += 1) {
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
if (items.length === 0) break;
for (const item of items) {
await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
total += 1;
}
}
return total;
}
async function main() {
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
const health = await api('/health');
if (health.status !== 200) {
console.log('后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
const modules = health.payload?.data?.modules ?? [];
record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
if (!modules.includes('integrations')) process.exit(1);
// ---------------------------------------------------------------- 登录
// 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
// 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
record('教师登录 teacher1', Boolean(teacherToken));
if (!teacherToken) process.exit(1);
// 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
const adminToken = await loginWithCandidates('admin', ['admin123']);
const classes = await api('/classes', { token: teacherToken });
const target = (classes.payload?.data ?? [])[0];
record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
if (!target?.id) process.exit(1);
// 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
const firstStudent = (students.payload?.data ?? [])[0];
/** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
async function classLoginToken() {
if (adminToken) {
const detail = await api(`/classes/${target.id}`, { token: adminToken });
const code = detail.payload?.data?.code;
if (code) {
for (const password of ['123456', 'class123']) {
const result = await api('/auth/class-login', {
method: 'POST',
body: { code, password },
});
const token = result.payload?.data?.token;
if (token) return token;
}
}
}
return null;
}
// ---------------------------------------------------------------- 1. 新建设备
const created = await api('/integrations/devices', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
});
const deviceToken = created.payload?.data?.token;
const deviceId = created.payload?.data?.device?.id;
record(
'新建设备并返回设备令牌',
created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
`status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
);
if (!deviceToken || !deviceId) process.exit(1);
// ---------------------------------------------------------------- 2. 令牌鉴权
const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
const badToken = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken: 'chci_deadbeef',
body: {},
});
record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '1.1.0.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
subject: '语文',
nextSubject: '数学',
timeState: 'OnClass',
periodStart: '08:00',
periodEnd: '08:45',
week: 1,
classPlanLoaded: true,
clientTime: new Date().toISOString(),
},
schedule: sampleSchedule(),
timeLayout: sampleTimeLayout(),
};
const report1 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r1 = report1.payload?.data;
record(
'插件上报课表写入成功',
report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
`新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
);
record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
// 幂等:重复上报不应产生重复行
const report2 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r2 = report2.payload?.data;
record(
'重复上报幂等(不新增、只更新)',
r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
`新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
);
// 设备列表里的状态快照
const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
record(
'设备状态快照已更新(Web 端可展示)',
Boolean(listed?.lastSeenAt) &&
listed?.currentSubject === '语文' &&
listed?.currentTimeState === 'OnClass',
`科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
);
// ---------------------------------------------------------------- 4. 下发提醒
// 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
// 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
const drained = await drainPending(deviceToken);
if (drained > 0) {
console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
}
const notify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
title: '请到讲台领资料',
content: '下课后来讲台领取期中复习资料',
durationSeconds: 12,
speech: true,
},
});
const n1 = notify.payload?.data;
record(
'教师下发提醒(落库 + 标记设备数)',
notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
`设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
);
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
const picked = items.find((item) => item.title === '请到讲台领资料');
record(
'插件拉取到待提醒(离线补齐链路可用)',
pending.status === 200 && Boolean(picked),
`待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
);
if (picked) {
const ack = await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: picked.id },
});
record(
'插件确认提醒已弹出',
ack.status === 200 && ack.payload?.data?.acked === true,
`status=${ack.status}`,
);
const pending2 = await api('/integrations/classisland/pending', { deviceToken });
const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
}
// ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
// 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
// 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
record(
'班级有"通知显示位置"(默认两端都弹)',
classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
`channel=${classChannel.payload?.data?.notificationChannel}`,
);
const published = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
});
const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
(item) => item.title === '通知页联动验证',
);
record(
'通知发布页发的通知也会推送到 ClassIsland',
published.status === 201 && Boolean(publishPush),
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
);
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
record(
'通知类推送的 kind=notification(上课时段插件会暂存)',
publishPush?.kind === 'notification',
`kind=${publishPush?.kind}`,
);
if (publishPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: publishPush.id },
});
}
const call = await api('/calls', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
});
const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
(item) => item.content === '叫人联动验证',
);
record(
'叫人也会推送到 ClassIsland',
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
);
record(
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
callPush?.kind === 'call',
`kind=${callPush?.kind}`,
);
if (callPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: callPush.id },
});
}
// 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
const setClient = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'client' },
});
record(
'可把"通知显示位置"改成只在客户端显示',
setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
`channel=${setClient.payload?.data?.notificationChannel}`,
);
const mutedPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
});
const mutedNotify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
});
const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
record(
'显示位置=client 时通知不再推送 ClassIsland',
mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
`待提醒=${mutedItems.length}`,
);
record(
'显示位置=client 时联动页的下发也会被跳过(并说明原因)',
mutedNotify.status === 200 &&
mutedNotify.payload?.data?.skipped === 'channel-client' &&
String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
`skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
);
// 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
const classSessionToken = await classLoginToken();
const readByClass = await api(`/classes/${target.id}/notification-channel`, {
token: classSessionToken,
});
record(
'班级账号可读取通知显示位置',
readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
`channel=${readByClass.payload?.data?.notificationChannel}`,
);
const setByClass = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: classSessionToken,
body: { notificationChannel: 'both' },
});
record(
'班级账号可修改通知显示位置(由教室客户端自己选)',
setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
`channel=${setByClass.payload?.data?.notificationChannel}`,
);
const badChannel = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'nope' },
});
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'镜像开关关闭时不下发课表',
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
);
const enabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: true },
});
record(
'打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
const plan = mirror.payload?.data?.classPlan;
const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
record(
'拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
mirror.status === 200 &&
Array.isArray(plan?.entries) &&
plan.entries.length >= 3 &&
weekDays.every((day) => day >= 0 && day <= 6),
`条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
);
record(
'单双周换算为 WeekCountDiv/Total',
(plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
(plan?.entries ?? [])
.map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
.join(' '),
);
// ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
// 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
const timeOk = (plan?.entries ?? []).every(
(item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
);
record(
'镜像条目带起止时间(插件据此重建上课时间点)',
(plan?.entries ?? []).length > 0 && timeOk,
(plan?.entries ?? [])
.slice(0, 3)
.map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
.join(' | '),
);
const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
record(
'镜像时间表含上课时间点(与课表条目对得上)',
classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
);
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
const courseId = (courseList.payload?.data ?? [])[0]?.id;
const manualCreated = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
courseId,
dayOfWeek: 7,
startTime: '21:00',
endTime: '21:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'ALL',
},
});
const manualScheduleId = manualCreated.payload?.data?.id;
record(
'(准备)Web 端手排一节课(source=manual)',
manualCreated.status === 201 && Boolean(manualScheduleId),
`status=${manualCreated.status}`,
);
const entry = (startTime, endTime) => ({
dayOfWeek: 6,
startTime,
endTime,
subject: '语文',
weekParity: 'ALL',
});
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
const listSaturday = async () => {
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
return (list.payload?.data ?? []).filter(
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
);
};
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
const ghostReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
},
});
record(
'插件上报两节课(其中一节随后会被删除)',
ghostReport.payload?.data?.scheduleApplied === true,
`status=${ghostReport.status}`,
);
const beforePrune = await listSaturday();
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
},
});
const afterPrune = await listSaturday();
record(
'插件侧删除的课被同步清理(不再留幽灵课)',
pruneReport.payload?.data?.scheduleApplied === true &&
afterPrune.length === 1 &&
afterPrune[0]?.startTime === '19:50',
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
);
// 只清理"插件同步出来的行":老师手排的课必须活着
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
// 机器码回填:设备列表里不再是 pending-…
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const backfilledKey =
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
record(
'插件上报的机器码已回填到设备记录',
backfilledKey === 'smoke-machine-01',
`deviceKey=${backfilledKey}`,
);
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
if (manualScheduleId) {
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
}
for (const row of await listSaturday()) {
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
}
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
// ---------------------------------------------------------------- 6. 停用设备
await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { enabled: false },
});
const blocked = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: { state: { inClass: false, clientTime: new Date().toISOString() } },
});
record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
// ---------------------------------------------------------------- 7. 权限隔离
// 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
let classCode = target.code ?? null;
if (!classCode && adminToken) {
const all = await api('/classes', { token: adminToken });
classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
}
const studentLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode ?? '', password: '123456' },
});
const studentToken = studentLogin.payload?.data?.token;
if (studentToken) {
const forbidden = await api('/integrations/devices', { token: studentToken });
record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
} else {
record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
}
// ---------------------------------------------------------------- 清理
const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
if (failed.length > 0) {
console.log('失败用例:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('ClassIsland 联动链路全部通过。\n');
}
main().catch((error) => {
console.error('验证脚本异常:', error);
process.exit(1);
});