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 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

@@ -38,8 +38,53 @@ const COLUMNS = [
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
note: '班级教学周数(班主任可调,默认 20)',
},
{
table: 'Class',
column: 'studentGradeQueryEnabled',
sql: `ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true`,
note: 'ClassHelper 班级端按学号查成绩的开关(默认开启)',
},
{
table: 'Grade',
column: 'levelType',
sql: `ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent'`,
note: '成绩等级口径:percent / letter / custom',
},
{
table: 'Grade',
column: 'level',
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',
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
];
/**
* ⚠ 本脚本只能补**新增的列**,补不了结构变化。
*
* 2026-10-06 那版「学生从账号变成名单记录」(`prisma/migrations/20261006120000_add_student_table`)
* 是一次真正的表重构:新建 `Student` / `StudentClassTransfer`、把 `Grade`/`HomeworkStatus`/
* `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
* 这些**必须走正式的迁移链路**(`prisma migrate deploy`,安装程序升级时自动执行):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" # 只补列
* pnpm --filter @classhelper/server db:deploy # 结构变化走这条
*
* 只跑本脚本、不跑迁移的库会停在"列有了但表没换"的中间态 —— 下面的列已经能把
* 界面撑起来不报错,但学生数据仍留在 `User` 里,属于不可用状态。
*/
(async () => {
let prisma;
try {
+28 -5
View File
@@ -444,11 +444,34 @@ async function main() {
});
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
// ---------------------------------------------------------------- 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.status === 200 &&
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
);
@@ -458,7 +481,7 @@ async function main() {
body: { mirrorScheduleToClassIsland: true },
});
record(
'打开「镜像课表到 ClassIsland」',
'重新打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
@@ -562,7 +585,7 @@ async function main() {
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
// ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
+361 -119
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> ClassHelper 班级端实时收到,并可标记完成
* 3. 教师录入成绩 -> ClassHelper 班级端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
@@ -52,7 +52,7 @@ function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
}
async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
console.log(`\n=== ClassHelper 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
@@ -97,12 +97,12 @@ async function main() {
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
if (!adminToken) process.exit(1);
// 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
// ClassHelper 班级端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
const classesForLogin = await api('/classes', { token: teacherToken });
const loginClass = classesForLogin.payload?.data?.[0];
if (!loginClass?.code) {
record(
'学生端班级登录(班级码 + 班级密码 → classSession)',
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
false,
'没有可用班级码,请先执行 pnpm db:seed',
);
@@ -127,15 +127,19 @@ async function main() {
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record(
'学生端班级登录(班级码 + 班级密码 → classSession)',
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
Boolean(studentToken) && studentUser?.classSession === true,
`code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`,
);
// 学生不是账号:`User` 表里根本没有学生行,所以拿学号当账号登录只可能是"账号不存在"
const studentAsAccount = await api('/auth/login', {
method: 'POST',
body: { username: '202601', password: '123456' },
});
record(
'个人学生账号登录已停用(403)',
(await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } }))
.status === 403,
'student01 → 403',
'学生没有账号:拿学号当账号登录被拒(401)',
studentAsAccount.status === 401,
`status=${studentAsAccount.status}`,
);
const wrongPassword = await api('/auth/login', {
@@ -149,7 +153,7 @@ async function main() {
const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
const ownClass = classes.find((item) => item.name?.includes('2026级1')) ?? classes[0];
const classId = ownClass?.id;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
@@ -169,14 +173,17 @@ async function main() {
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
/** 权限矩阵用例里新建的作业(收尾统一删除) */
const tempHomeworkIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `权限校验班 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 93,
code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}),
},
@@ -194,6 +201,8 @@ async function main() {
const classmates = studentList.payload?.data ?? [];
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
const targetStudent = classmates[0];
/** 同班第二位学生的学号(导入用例里要造「两条不同的学号」) */
const classmateTwoNo = classmates[1]?.studentNo ?? '202602';
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
@@ -420,7 +429,7 @@ async function main() {
});
});
record(
'学生端 Socket.IO 连接与鉴权',
'ClassHelper 班级端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
@@ -441,9 +450,9 @@ async function main() {
try {
const { payload, elapsed } = await notificationWait;
record('学生端 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('学生端 5 秒内收到 notification:new', false, error.message);
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
@@ -499,9 +508,9 @@ async function main() {
try {
const { payload, elapsed } = await homeworkWait;
record('学生端 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('学生端 5 秒内收到 homework:new', false, error.message);
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
@@ -517,7 +526,7 @@ async function main() {
/* ---------------------------------------------------------------- 5.1 未交名单 */
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId);
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.studentId);
record(
'教师可读取作业提交名单(全班学生 + 完成状态)',
submissions.status === 200 &&
@@ -531,13 +540,13 @@ async function main() {
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: teacherToken,
body: { notSubmittedUserIds: [firstStudentId] },
body: { notSubmittedStudentIds: [firstStudentId] },
});
record(
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
savedSubmissions.status === 200 &&
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.studentId === firstStudentId &&
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
@@ -584,7 +593,7 @@ async function main() {
body: {
classId,
courseId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
@@ -596,16 +605,16 @@ async function main() {
try {
const { payload, elapsed } = await gradeWait;
record(
'学生端 5 秒内收到 grade:updated',
'ClassHelper 班级端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message);
record('ClassHelper 班级端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
record('ClassHelper 班级端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
const myGrades = await api('/grades/my', { token: studentToken });
@@ -658,23 +667,42 @@ async function main() {
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
}
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
// 任课关系 = 班级 + 科目 + 教师,唯一写入口是 PUT /classes/:id/subject-teachers。
// 用一个演示数据里没用来录成绩的科目,验证完就还回班主任,避免影响后面的成绩权限用例。
const probeSubject = '通用技术';
const adminAssignSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: probeSubject, teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`);
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
method: 'DELETE',
record(
'管理员设置某班某科的任课老师(PUT /subject-teachers)',
adminAssignSubject.status === 200 &&
(adminAssignSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject &&
item.teacherId === teacher2Login.payload?.data?.user?.id,
),
`status=${adminAssignSubject.status}`,
);
const restoreSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: probeSubject, teacherId: ownClass?.teacherId }] },
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
record(
'任课老师可改回班主任(同一个人既是班主任又是科任,不重复建号)',
restoreSubject.status === 200 &&
(restoreSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject && item.teacherId === ownClass?.teacherId,
),
`status=${restoreSubject.status}`,
);
const tempUsername = `e2e_student_${Date.now()}`;
const tempUsername = `E2E${Date.now().toString().slice(-8)}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: adminToken,
body: { username: tempUsername, name: '联调学生' },
body: { studentNo: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
@@ -682,8 +710,10 @@ async function main() {
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record(
'学生名单包含新加入学生',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
'学生名单包含新加入学生(按学号)',
(studentListAfter.payload?.data ?? []).some(
(item) => item.id === addedStudentId && item.studentNo === tempUsername.toUpperCase(),
),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
@@ -728,7 +758,7 @@ async function main() {
courseId,
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
totalScore: 100,
items: classmates.slice(0, 3).map((item, index) => ({ userId: item.id, score: 70 + index * 5 })),
items: classmates.slice(0, 3).map((item, index) => ({ studentId: item.id, score: 70 + index * 5 })),
},
});
record(
@@ -739,9 +769,13 @@ async function main() {
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
// 等级分布不再固定 A~E 五档:等级制(A-D)与自定义等级(优/良/合格)都在用,
// 因此断言"有分布且每个档位都有人",而不是数档位个数。
'成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
gradeStats.status === 200 &&
(gradeStats.payload?.data?.distribution ?? []).length >= 1 &&
(gradeStats.payload?.data?.distribution ?? []).every((item) => typeof item.level === 'string'),
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}% 档位=${(gradeStats.payload?.data?.distribution ?? []).map((item) => item.level).join('/')}`,
);
const unread = await api('/notifications/unread-count', { token: studentToken });
@@ -931,7 +965,7 @@ async function main() {
`status=${callByTeacher.status} title="${callTitle}"`,
);
record(
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
'普通叫人(默认)不是紧急级别:priority=HIGH,ClassHelper 班级端按普通通知排队',
callByTeacher.payload?.data?.priority === 'HIGH',
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
);
@@ -947,7 +981,7 @@ async function main() {
},
});
record(
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
'紧急叫人(urgent=true → priority=URGENT,ClassHelper 班级端无视上课时段立即展开)',
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
);
@@ -1013,27 +1047,28 @@ async function main() {
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 是某班的协作(科任)老师。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
// teacher1 当班主任 + teacher2 协作,收尾删除。
// 科任老师:teacher2 在某班**有任课关系**(Course.teacherId === 他)。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过 / 与外班同一个)时现建一个,
// 用 teacher1 当班主任 + 给 teacher2 配一门课,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `科任校验班 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 90,
code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id,
},
});
const createdId = created.payload?.data?.id;
if (createdId) {
await api(`/classes/${createdId}/teachers`, {
method: 'POST',
await api(`/classes/${createdId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Id },
body: { assignments: [{ subjectName: '数学', teacherId: teacher2Id }] },
});
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId);
@@ -1050,53 +1085,52 @@ async function main() {
const headCreate = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '班主任越权班', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 91 },
});
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
const subjectCreate = await api('/classes', {
method: 'POST',
token: teacher2Token,
body: { name: '科任越权班', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 92 },
});
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
const headAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const headAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: teacherToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
record('班主任分配任课老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
const subjectAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const subjectAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: teacher2Token,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`);
// 管理员可以分配(用"先加后删"验证)
const adminAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const adminAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record(
'管理员分配科任老师成功(201/200)',
[200, 201].includes(adminAssign.status),
'管理员分配任课老师成功(200)',
adminAssign.status === 200,
`status=${adminAssign.status}`,
);
const adminUnassign = await api(
`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`,
{
method: 'DELETE',
token: adminToken,
},
);
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: '音乐', teacherId: ownClass?.teacherId }] },
});
record('管理员解除任课关系成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 课表:班主任可管理本班,科任老师不可
if (subjectClassId) {
@@ -1120,16 +1154,59 @@ async function main() {
);
}
// 新规则:**作业按科目授权**。
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
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,
body: {
classId: subjectClassId,
courseId: subjectCourse?.id,
title: '科任老师布置的作业',
content: '权限矩阵校验用',
},
});
record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`);
record(
'科任老师可布置自己任教科目的作业(201)',
subjectHomework.status === 201,
`status=${subjectHomework.status} 科目=${subjectCourse?.name ?? '-'}`,
);
if (subjectHomework.payload?.data?.id) {
tempHomeworkIds.push(subjectHomework.payload.data.id);
}
const subjectHomeworkNoCourse = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: { classId: subjectClassId, title: '科任老师的不指定科目作业', content: '应被拒绝' },
});
record(
'科任老师布置不指定科目的作业被拒绝(403)',
subjectHomeworkNoCourse.status === 403,
`status=${subjectHomeworkNoCourse.status}`,
);
// 科任老师改**别人任教科目**的作业同样被拒
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
body: {
classId: subjectClassId,
courseId: otherCourse?.id,
title: '科任老师替别人发作业',
content: '应被拒绝',
},
});
record(
'科任老师给非任教科目布置作业被拒绝(403)',
foreignHomework.status === 403,
`status=${foreignHomework.status} 科目=${otherCourse?.name ?? '-'}`,
);
}
if (subjectHomework.payload?.data?.id) {
await api(`/homeworks/${subjectHomework.payload.data.id}`, {
method: 'DELETE',
@@ -1142,7 +1219,7 @@ async function main() {
token: teacher2Token,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '越权成绩',
score: 90,
totalScore: 100,
@@ -1156,7 +1233,7 @@ async function main() {
token: teacherToken,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '班主任本班成绩',
score: 90,
totalScore: 100,
@@ -1177,7 +1254,7 @@ async function main() {
token: teacherToken,
body: {
classId: foreignClass.id,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '班主任跨班成绩',
score: 90,
totalScore: 100,
@@ -1195,7 +1272,7 @@ async function main() {
token: adminToken,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '管理员录入成绩',
score: 88,
totalScore: 100,
@@ -1438,10 +1515,10 @@ async function main() {
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [
'用户名,姓名,考试名称,分数,总分,课程',
'student01,,期中导入考试,92,100,',
'学号,姓名,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},,期中导入考试,92,100,`,
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
'unknown-user,,期中导入考试,88,100,',
'UNKNOWN-USER,,期中导入考试,88,100,',
].join(NL);
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
@@ -1490,8 +1567,8 @@ async function main() {
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
const xlsxSheet = XLSX.utils.aoa_to_sheet([
['学号', '姓名', '考试', '得分', '满分', '科目'],
['student02', '', '期中导入考试', 85, 100, ''],
['student01', '', '期中导入考试', 78, 100, ''],
[classmateTwoNo, '', '期中导入考试', 85, 100, ''],
[targetStudent?.studentNo ?? '202601', '', '期中导入考试', 78, 100, ''],
]);
const xlsxBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
@@ -1506,13 +1583,13 @@ async function main() {
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
xlsxPreview.status === 200 &&
xlsxPreview.payload?.data?.totalRows === 2 &&
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.studentNo === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
);
const commitMapping = {
username: '学号',
studentNo: '学号',
name: '姓名',
examName: '考试',
score: '得分',
@@ -1573,12 +1650,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'append',
},
@@ -1608,9 +1685,9 @@ async function main() {
);
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
// 学生导入模板已无"初始密码"列(学生没有账号属性)
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
const studentsCsv = ['学号,姓名', `${importedUsername},导入测试生`, 'bad@user,非法学号'].join(NL);
const studentsCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
@@ -1619,12 +1696,12 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' },
mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
record(
'学生名单导入(新增 1 / 非法用户名进错误行 1)',
'学生名单导入(新增 1 / 非法学号进错误行 1)',
studentsCommit.status === 200 &&
studentsCommit.payload?.data?.inserted === 1 &&
studentsCommit.payload?.data?.failed === 1,
@@ -1653,12 +1730,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1672,8 +1749,8 @@ async function main() {
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
);
// teacher2 是高二(3)班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
const subjectOnlyClass = classes.find((item) => item.name?.includes('高二(3)')) ?? null;
// teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
const subjectGradesImport = await api('/imports/table/commit', {
method: 'POST',
token: teacher2Token,
@@ -1683,12 +1760,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1696,7 +1773,7 @@ async function main() {
record(
'科任老师导入本班成绩被拒绝(403,需求 7)',
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
`班级=${subjectOnlyClass?.name ?? '未找到高二(3)班'} status=${subjectGradesImport.status}`,
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
);
if (foreignClass) {
@@ -1709,12 +1786,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1749,7 +1826,7 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' },
mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
@@ -1774,7 +1851,7 @@ async function main() {
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
const roster = await api(`/students?classId=${classId}`, { token: adminToken });
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername);
const importedStudent = (roster.payload?.data ?? []).find((item) => item.studentNo === importedUsername);
if (importedStudent) {
const removedStudent = await api(`/students/${importedStudent.id}`, {
method: 'DELETE',
@@ -1795,7 +1872,7 @@ async function main() {
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
}
// ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级)
// ---------------------------------------------------------------- 9. 班级账号(ClassHelper 班级端主体 = 班级)
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
// 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。
@@ -1887,7 +1964,7 @@ async function main() {
const classGradesWrite = await api('/grades', {
method: 'POST',
token: classToken,
body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
body: { classId, studentId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
});
record(
'班级账号录入成绩被拒绝(403)',
@@ -2020,10 +2097,10 @@ async function main() {
const classNameGrade = await api('/grades', {
method: 'POST',
token: adminToken,
body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
body: { classId, studentId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
});
const classMyGrades = await api('/grades/my', { token: classToken });
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.studentId));
record(
'班级账号查看成绩 = 全班总览',
classMyGrades.status === 200 &&
@@ -2180,7 +2257,7 @@ async function main() {
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv =
'\ufeff用户名,姓名,初始密码,角色\n' +
'\ufeff工号,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
@@ -2202,7 +2279,7 @@ async function main() {
kind: 'teachers',
fileName: 'teachers.csv',
contentBase64: teacherCsvBase64,
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
mapping: { username: '工号', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert',
},
});
@@ -2253,11 +2330,11 @@ async function main() {
});
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知
await api(`/classes/${classId}/teachers`, {
method: 'POST',
// 1) 先让他成为该班某科的任课老师(这样才有发布通知的权限),发一条通知
await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: cascadeTeacherId },
body: { assignments: [{ subjectName: '美术', teacherId: cascadeTeacherId }] },
});
const cascadeNotification = await api('/notifications', {
method: 'POST',
@@ -2265,10 +2342,11 @@ async function main() {
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
});
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
method: 'DELETE',
// 2) 解除任课关系(把该科还回班主任):此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: '美术', teacherId: ownClass?.teacherId }] },
});
// 3) 删除账号应当被拒(409),且那条通知必须还在
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
@@ -2303,7 +2381,7 @@ async function main() {
const teacherCreateClass = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '越权班级', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 94 },
});
record(
'仅管理员可创建班级(教师 403)',
@@ -2316,8 +2394,9 @@ async function main() {
method: 'POST',
token: adminToken,
body: {
name: `冒烟班级 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 95,
code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}),
},
@@ -2357,11 +2436,11 @@ async function main() {
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
'数据库状态(管理员):已连接且返回 15 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
@@ -2478,10 +2557,10 @@ async function main() {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
@@ -2665,6 +2744,169 @@ async function main() {
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
);
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
const termGlobal = await api('/term', { token: adminToken });
record(
'学期周次:读取全校默认口径',
termGlobal.status === 200 &&
typeof termGlobal.payload?.data?.currentWeek === 'number' &&
Array.isArray(termGlobal.payload?.data?.weeks),
`status=${termGlobal.status} 当前第 ${termGlobal.payload?.data?.currentWeek ?? '-'} 周 配置=${termGlobal.payload?.data?.configured}`,
);
const termForTeacher = await api('/term', { token: teacherToken });
record(
'学期周次:教师可读(课表页要显示"第几周")',
termForTeacher.status === 200,
`status=${termForTeacher.status}`,
);
// 用一段**覆盖今天**的区间,验证"当前周次按区间判定"而不是按开学日期线性推算
const today = new Date();
const pad = (value) => String(value).padStart(2, '0');
const dayKey = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const rangeStart = new Date(today.getTime() - 2 * 86400000);
const rangeEnd = new Date(today.getTime() + 2 * 86400000);
const beforeTerm = (await api('/term', { token: adminToken })).payload?.data;
const termSaved = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 7, startDate: dayKey(rangeStart), endDate: dayKey(rangeEnd), note: 'e2e 区间回归' },
],
},
});
record(
'学期周次:管理员保存逐周区间(PUT /term)',
termSaved.status === 200 &&
termSaved.payload?.data?.configured === true &&
termSaved.payload?.data?.currentWeek === 7,
`status=${termSaved.status} 当前第 ${termSaved.payload?.data?.currentWeek ?? '-'} 周(区间覆盖今天应为第 7 周)`,
);
const termOverlap = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-10' },
{ weekNumber: 2, startDate: '2026-09-05', endDate: '2026-09-14' },
],
},
});
record(
'学期周次:区间重叠被拒绝(400,避免"当前第几周"出现歧义)',
termOverlap.status === 400,
`status=${termOverlap.status} message=${termOverlap.payload?.message ?? ''}`,
);
const termDenied = await api('/term', {
method: 'PUT',
token: teacherToken,
body: { weeks: [{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' }] },
});
record(
'学期周次:教师写入被拒绝(403,周次会让全校课表错位)',
termDenied.status === 403,
`status=${termDenied.status}`,
);
const termAuto = await api('/term/auto', {
method: 'POST',
token: adminToken,
body: { termStartDate: '2026-09-07', maxWeek: 3 },
});
record(
'学期周次:按开学日期一键生成(POST /term/auto)',
termAuto.status === 200 &&
(termAuto.payload?.data?.weeks ?? []).length === 3 &&
termAuto.payload?.data?.weeks?.[0]?.startDate === '2026-09-07' &&
termAuto.payload?.data?.weeks?.[2]?.endDate === '2026-09-27',
`status=${termAuto.status} 首周=${termAuto.payload?.data?.weeks?.[0]?.startDate} 末周=${termAuto.payload?.data?.weeks?.[2]?.endDate}`,
);
const holidays = await api('/term/holidays', { token: adminToken });
record(
'学期周次:联网拉取放假安排(机房无外网时 ok=false 属正常结果,不能抛 500)',
holidays.status === 200 && typeof holidays.payload?.data?.ok === 'boolean',
`status=${holidays.status} ok=${holidays.payload?.data?.ok} 条数=${holidays.payload?.data?.holidays?.length ?? 0}`,
);
// 收尾:还原成纯线性推算,不污染演示数据
await api('/term', {
method: 'PUT',
token: adminToken,
body: { termStartDate: beforeTerm?.termStartDate ?? undefined, weeks: [] },
});
const termRestored = await api('/term', { token: adminToken });
record(
'学期周次用例收尾:清空逐周配置(回到按开学日期线性推算)',
termRestored.payload?.data?.configured === false,
`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;
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}`,
);
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();
// ---------------------------------------------------------------- 汇总