chore(release): 1.3.3 发布并退役旧联动
This commit is contained in:
1 parent
b7cdc7c9ea
commit
c0ebf88138
184 files changed
+5450
-11705
No files matched your search
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
|
||||
*
|
||||
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
|
||||
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
|
||||
* 部署,就需要显式跑一次本脚本。
|
||||
* 服务端启动时会自动应用完整迁移。本脚本仅用于排查旧库缺列,
|
||||
* 不能替代正式迁移(例如移除联动表、创建权限组索引)。
|
||||
*
|
||||
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
|
||||
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
|
||||
@@ -56,12 +55,6 @@ const COLUMNS = [
|
||||
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '成绩等级文本(percent 下由得分率换算,可手改)',
|
||||
},
|
||||
{
|
||||
table: 'IntegrationDevice',
|
||||
column: 'lastHeartbeatAt',
|
||||
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
|
||||
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
|
||||
},
|
||||
{
|
||||
table: 'User',
|
||||
column: 'phone',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 先清理编译目录,确保已移除的模块不会残留在下一次交付物中。 */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.build.json'],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
@@ -1,679 +0,0 @@
|
||||
#!/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.2.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. 课表镜像
|
||||
//
|
||||
// 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
|
||||
// 再验证"关掉之后确实不下发"。
|
||||
const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'课表默认由服务端自动下发(新方向:服务端 → 教室)',
|
||||
mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
|
||||
`status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
|
||||
);
|
||||
|
||||
const disabled = await api(`/integrations/devices/${deviceId}`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { mirrorScheduleToClassIsland: false },
|
||||
});
|
||||
record(
|
||||
'关闭「镜像课表到 ClassIsland」',
|
||||
disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
|
||||
`status=${disabled.status}`,
|
||||
);
|
||||
|
||||
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'关掉镜像后不再下发课表',
|
||||
mirrorOff.status === 200 &&
|
||||
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
|
||||
`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、从不删除,老师删掉的课会永远留在服务端(幽灵课),
|
||||
// ClassHelper 班级端与 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);
|
||||
});
|
||||
@@ -476,7 +476,11 @@ async function main() {
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await notificationWait;
|
||||
record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
record(
|
||||
'ClassHelper 班级端 5 秒内收到 notification:new',
|
||||
elapsed < TIME_LIMIT_MS,
|
||||
`${elapsed}ms · ${payload?.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
|
||||
}
|
||||
@@ -534,7 +538,11 @@ async function main() {
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await homeworkWait;
|
||||
record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
record(
|
||||
'ClassHelper 班级端 5 秒内收到 homework:new',
|
||||
elapsed < TIME_LIMIT_MS,
|
||||
`${elapsed}ms · ${payload?.title}`,
|
||||
);
|
||||
} catch (error) {
|
||||
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
|
||||
}
|
||||
@@ -705,8 +713,8 @@ async function main() {
|
||||
'管理员设置某班某科的任课老师(PUT /subject-teachers)',
|
||||
adminAssignSubject.status === 200 &&
|
||||
(adminAssignSubject.payload?.data ?? []).some(
|
||||
(item) => item.subjectName === probeSubject &&
|
||||
item.teacherId === teacher2Login.payload?.data?.user?.id,
|
||||
(item) =>
|
||||
item.subjectName === probeSubject && item.teacherId === teacher2Login.payload?.data?.user?.id,
|
||||
),
|
||||
`status=${adminAssignSubject.status}`,
|
||||
);
|
||||
@@ -1145,11 +1153,7 @@ async function main() {
|
||||
token: adminToken,
|
||||
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
|
||||
});
|
||||
record(
|
||||
'管理员分配任课老师成功(200)',
|
||||
adminAssign.status === 200,
|
||||
`status=${adminAssign.status}`,
|
||||
);
|
||||
record('管理员分配任课老师成功(200)', adminAssign.status === 200, `status=${adminAssign.status}`);
|
||||
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
|
||||
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
@@ -1183,8 +1187,9 @@ async function main() {
|
||||
// 新规则:**作业按科目授权**。
|
||||
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
|
||||
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
|
||||
const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
|
||||
.payload?.data?.find((item) => item.teacherId === teacher2Id);
|
||||
const subjectCourse = (
|
||||
await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
|
||||
).payload?.data?.find((item) => item.teacherId === teacher2Id);
|
||||
const subjectHomework = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: subjectClassId ? teacher2Token : teacherToken,
|
||||
@@ -1215,8 +1220,9 @@ async function main() {
|
||||
);
|
||||
// 科任老师改**别人任教科目**的作业同样被拒
|
||||
if (subjectCourse) {
|
||||
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
|
||||
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
|
||||
const otherCourse = (
|
||||
await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
|
||||
).payload?.data?.find((item) => item.teacherId !== teacher2Id);
|
||||
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
|
||||
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
|
||||
record(
|
||||
@@ -1355,7 +1361,9 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
|
||||
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, {
|
||||
token: teacherToken,
|
||||
});
|
||||
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
|
||||
|
||||
// 批量发布通知:一次提交多个班,返回逐班结果
|
||||
@@ -1545,25 +1553,32 @@ async function main() {
|
||||
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
|
||||
);
|
||||
|
||||
// 管理员固定拥有全部权限,不接受分配
|
||||
// 管理员可以分配权限组,但始终保留两个管理入口
|
||||
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
|
||||
});
|
||||
record(
|
||||
'管理员不接受权限组分配(PUT /users/:id/groups → 400)',
|
||||
Boolean(adminMember) &&
|
||||
assignAdmin.status === 400 &&
|
||||
String(assignAdmin.payload?.message ?? '').includes('权限组'),
|
||||
'管理员可以分配权限组(PUT /users/:id/groups → 200)',
|
||||
Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false,
|
||||
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
await api(`/permissions/users/${adminMember?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: [] },
|
||||
});
|
||||
|
||||
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
|
||||
const groupsList = await api('/permissions/groups', { token: adminToken });
|
||||
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
|
||||
if (builtinGroup) {
|
||||
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken });
|
||||
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, {
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
});
|
||||
record(
|
||||
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
|
||||
deleteBuiltin.status === 400,
|
||||
@@ -1607,7 +1622,11 @@ async function main() {
|
||||
|
||||
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
|
||||
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
|
||||
const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase });
|
||||
const heartbeat = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: heartbeatBase,
|
||||
});
|
||||
const heartbeatData = heartbeat.payload?.data;
|
||||
record(
|
||||
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
|
||||
@@ -1644,7 +1663,11 @@ async function main() {
|
||||
const command = await api('/clients/commands', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
|
||||
body: {
|
||||
deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'],
|
||||
kind: 'lock',
|
||||
note: 'E2E 远控用例',
|
||||
},
|
||||
});
|
||||
const heartbeatWithCommand = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
@@ -1677,7 +1700,8 @@ async function main() {
|
||||
);
|
||||
|
||||
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
|
||||
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
|
||||
const ackedDevice =
|
||||
(devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
|
||||
record(
|
||||
'回执后设备不再有待执行命令(pendingCommand 为空)',
|
||||
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
|
||||
@@ -1803,7 +1827,9 @@ async function main() {
|
||||
});
|
||||
record(
|
||||
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
|
||||
enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock',
|
||||
enableAi.status === 200 &&
|
||||
enableAi.payload?.data?.enabled === true &&
|
||||
enableAi.payload?.data?.provider === 'mock',
|
||||
`status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`,
|
||||
);
|
||||
|
||||
@@ -1916,8 +1942,7 @@ async function main() {
|
||||
const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {});
|
||||
record(
|
||||
'运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)',
|
||||
diagnostics.status === 200 &&
|
||||
!/apiKey|password|token|secret/i.test(diagnosticsText),
|
||||
diagnostics.status === 200 && !/apiKey|password|token|secret/i.test(diagnosticsText),
|
||||
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
|
||||
);
|
||||
}
|
||||
@@ -2930,8 +2955,8 @@ async function main() {
|
||||
body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' },
|
||||
});
|
||||
record(
|
||||
'仅管理员可录入教师(教师读取/新建均 403)',
|
||||
teacherSeesTeachers.status === 403 && teacherCreatesTeacher.status === 403,
|
||||
'教师名单只读与管理分离(默认教师可读取,但不能新建)',
|
||||
teacherSeesTeachers.status === 200 && teacherCreatesTeacher.status === 403,
|
||||
`列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
@@ -3187,8 +3212,6 @@ async function main() {
|
||||
'Notification',
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
'PermissionGroup',
|
||||
'PermissionGroupItem',
|
||||
'UserPermissionGroup',
|
||||
@@ -3511,13 +3534,12 @@ async function main() {
|
||||
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
|
||||
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
|
||||
record(
|
||||
'更新检查:教师可查(公开信息),但 force 仅管理员生效',
|
||||
'更新检查:默认教师可查,强制刷新需要 update:force',
|
||||
updateTeacherForced.status === 200 &&
|
||||
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
|
||||
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
|
||||
);
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
|
||||
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
|
||||
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
|
||||
@@ -3624,62 +3646,13 @@ async function main() {
|
||||
`configured=${termRestored.payload?.data?.configured}`,
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- 14. 课表同步方向反转 */
|
||||
|
||||
// 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
|
||||
const tempDevice = await api('/integrations/devices', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, name: 'E2E 课表方向校验设备' },
|
||||
});
|
||||
const tempDeviceId = tempDevice.payload?.data?.device?.id;
|
||||
const retiredIntegration = await api('/integrations/devices', { token: adminToken });
|
||||
record(
|
||||
'课表方向:新建设备默认「服务端自动下发、不自动回传」',
|
||||
tempDevice.status === 201 &&
|
||||
tempDevice.payload?.data?.device?.syncScheduleToServer === false &&
|
||||
tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
|
||||
`status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
|
||||
'退役联动接口不再提供服务',
|
||||
retiredIntegration.status === 404,
|
||||
`status=${retiredIntegration.status}`,
|
||||
);
|
||||
|
||||
if (tempDeviceId) {
|
||||
const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
});
|
||||
record(
|
||||
'从教室机器获取课表:管理员可发起(POST /request-schedule)',
|
||||
pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
|
||||
`status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
|
||||
const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
|
||||
const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
|
||||
record(
|
||||
'从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
|
||||
requestedDevice?.requestScheduleReport === true,
|
||||
`requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
|
||||
);
|
||||
|
||||
// 教师对**别人班**的设备不能发起
|
||||
const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
});
|
||||
record(
|
||||
'从教室机器获取课表:非本班教师被拒绝(403)',
|
||||
deniedPull.status === 403,
|
||||
`status=${deniedPull.status}`,
|
||||
);
|
||||
|
||||
// 收尾:删掉临时设备
|
||||
const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
});
|
||||
record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
|
||||
}
|
||||
|
||||
socket.close();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
Reference in new issue
Block a user