Files
ClassHelper/packages/server/scripts/verify-e2e.mjs
T
LoveChengke 3a9f8812fc feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)

- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
  (尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
  下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
  新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
  src/renderer/island/bridge.ts、island.html 与多页 Vite 构建

## 上课时段发布紧急通知的二次确认

- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
  409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
  确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗

## 打包与验证脚本修复(Electron 宿主环境)

- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
  Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
  Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
  纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
  (process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
  安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
  按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知

## 验证结果

- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
2026-09-12 08:41:08 +08:00

645 lines
25 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
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:pnpm verify:e2e
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000
*/
import { io } from 'socket.io-client';
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const TIME_LIMIT_MS = 5000;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
const mark = ok ? '\u2713' : '\u2717';
console.log(`${mark} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, body } = {}) {
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待事件 ${event} 超时(${timeoutMs}ms)`)), timeoutMs);
socket.once(event, (payload) => {
clearTimeout(timer);
resolve({ payload, elapsed: Date.now() - startedAt });
});
});
}
async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
record(
'后端健康检查 /api/health',
health.status === 200 && health.payload?.data?.status === 'ok',
`status=${health.status} db=${health.payload?.data?.database?.connected}`,
);
if (health.status !== 200) {
console.log('\n后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
// ---------------------------------------------------------------- 1. 登录
const teacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'teacher123' },
});
const teacherToken = teacherLogin.payload?.data?.token;
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
const studentLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'student01', password: 'student123' },
});
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record('学生登录 student01', Boolean(studentToken), `classId=${studentUser?.classId}`);
const wrongPassword = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher1', password: 'bad-password' },
});
record('错误密码被拒绝(401)', wrongPassword.status === 401, `status=${wrongPassword.status}`);
// ---------------------------------------------------------------- 2. 数据准备
const myClasses = await api('/classes', { token: teacherToken });
const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
const classId = ownClass?.id;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
process.exit(1);
}
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离
const teacher2Login = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
const foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
record(
'教师权限隔离测试数据就绪',
Boolean(foreignClass),
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: teacherToken });
const classmates = studentList.payload?.data ?? [];
const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0];
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
const courseId = courses.payload?.data?.[0]?.id ?? null;
record('教师获取课程列表', Boolean(courseId), `course=${courses.payload?.data?.[0]?.name ?? '-'}`);
const weekGrid = await api(`/schedules/grid?classId=${classId}&week=1`, { token: studentToken });
const totalSlots = (weekGrid.payload?.data?.columns ?? []).reduce((sum, col) => sum + col.items.length, 0);
record(
'学生按周查看课表(week=1)',
weekGrid.status === 200 && totalSlots > 0,
`第 1 周 ${totalSlots} 节课`,
);
// ---------------------------------------------------------------- 3. WebSocket 连接
const socket = io(BASE_URL, {
auth: { token: studentToken, clientType: 'desktop' },
transports: ['websocket'],
reconnection: false,
});
const connected = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), TIME_LIMIT_MS);
socket.on('connected', (payload) => {
clearTimeout(timer);
resolve(payload);
});
socket.on('connect_error', (error) => {
clearTimeout(timer);
resolve({ error: error.message });
});
});
record(
'学生端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
// ---------------------------------------------------------------- 4. 通知实时推送
const notificationWait = waitForEvent(socket, 'notification:new');
const createdNotification = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `联调验证通知 ${new Date().toISOString()}`,
content: '这是端到端验证脚本自动创建的通知。',
priority: 'HIGH',
},
});
record('教师发布通知', createdNotification.status === 201, `status=${createdNotification.status}`);
try {
const { payload, elapsed } = await notificationWait;
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
const homeworkWait = waitForEvent(socket, 'homework:new');
const createdHomework = await api('/homeworks', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
title: `联调验证作业 ${new Date().toISOString()}`,
content: '请完成验证脚本创建的作业。',
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
},
});
const homeworkId = createdHomework.payload?.data?.id;
record('教师发布作业', createdHomework.status === 201, `status=${createdHomework.status}`);
try {
const { payload, elapsed } = await homeworkWait;
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
method: 'PATCH',
token: studentToken,
body: { completed: true },
});
record(
'学生标记作业完成',
statusUpdate.status === 200 && statusUpdate.payload?.data?.completed === true,
`status=${statusUpdate.status}`,
);
const myHomeworks = await api(`/homeworks?classId=${classId}`, { token: studentToken });
const markedHomework = (myHomeworks.payload?.data ?? []).find((item) => item.id === homeworkId);
record(
'学生作业列表回显完成状态',
markedHomework?.completed === true,
`completed=${markedHomework?.completed}`,
);
// ---------------------------------------------------------------- 6. 成绩实时推送
const gradeWait = waitForEvent(socket, 'grade:updated');
const createdGrade = await api('/grades', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
userId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
},
});
record('教师录入成绩', createdGrade.status === 201, `status=${createdGrade.status}`);
if (targetStudent?.id === studentUser?.id) {
try {
const { payload, elapsed } = await gradeWait;
record(
'学生端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
const myGrades = await api('/grades/my', { token: studentToken });
record(
'学生查看个人成绩 /grades/my',
(myGrades.payload?.data ?? []).length > 0,
`共 ${myGrades.payload?.data?.length ?? 0} 条`,
);
// ---------------------------------------------------------------- 6.1 Web 管理端依赖的接口全覆盖
const classDetail = await api(`/classes/${classId}`, { token: teacherToken });
record(
'班级详情(学生/课程/协作教师)',
classDetail.status === 200 &&
Array.isArray(classDetail.payload?.data?.students) &&
Array.isArray(classDetail.payload?.data?.courses),
`学生 ${classDetail.payload?.data?.students?.length ?? 0} 人 · 课程 ${classDetail.payload?.data?.courses?.length ?? 0} 门`,
);
const teacherList = await api('/teachers', { token: teacherToken });
record(
'教师列表 /teachers',
(teacherList.payload?.data ?? []).length >= 2,
`共 ${teacherList.payload?.data?.length ?? 0} 人`,
);
const summary = await api('/dashboard/summary', { token: teacherToken });
record(
'仪表盘汇总 /dashboard/summary',
summary.status === 200 && (summary.payload?.data?.classCount ?? 0) > 0,
`班级 ${summary.payload?.data?.classCount ?? 0} · 学生 ${summary.payload?.data?.studentCount ?? 0}`,
);
const term = await api('/dashboard/term', { token: teacherToken });
record(
'学期信息 /dashboard/term',
term.status === 200 && (term.payload?.data?.currentWeek ?? 0) > 0,
`当前第 ${term.payload?.data?.currentWeek} 周`,
);
const createdCourse = await api('/courses', {
method: 'POST',
token: teacherToken,
body: { name: `联调验证课程 ${Date.now()}`, classId },
});
const createdCourseId = createdCourse.payload?.data?.id;
record('新增课程 POST /courses', createdCourse.status === 201, `status=${createdCourse.status}`);
if (createdCourseId) {
const removedCourse = await api(`/courses/${createdCourseId}`, { method: 'DELETE', token: teacherToken });
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
}
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacherToken,
body: { 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',
token: teacherToken,
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
const tempUsername = `e2e_student_${Date.now()}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: teacherToken,
body: { username: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record(
'学生名单包含新加入学生',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
method: 'POST',
token: teacherToken,
body: {},
});
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
const renamed = await api(`/students/${addedStudentId}`, {
method: 'PATCH',
token: teacherToken,
body: { name: '联调学生(已改名)' },
});
record(
'修改学生信息',
renamed.status === 200 && renamed.payload?.data?.name?.includes('已改名'),
`name=${renamed.payload?.data?.name}`,
);
const removedStudent = await api(`/classes/${classId}/students/${addedStudentId}`, {
method: 'DELETE',
token: teacherToken,
});
record('班级移出学生', removedStudent.status === 200, `status=${removedStudent.status}`);
// 移出后学生已无班级,按权限规则只有管理员可以删除该账号
const teacherDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: teacherToken });
record('教师删除未分班学生被拒绝(403)', teacherDelete.status === 403, `status=${teacherDelete.status}`);
const adminLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'admin', password: 'admin123' },
});
const adminToken = adminLogin.payload?.data?.token;
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
const adminDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: adminToken });
record('管理员删除学生账号', adminDelete.status === 200, `status=${adminDelete.status}`);
}
const bulkResult = await api('/grades/bulk', {
method: 'POST',
token: teacherToken,
body: {
classId,
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 })),
},
});
record(
'批量录入成绩 POST /grades/bulk',
bulkResult.status === 201,
`录入 ${bulkResult.payload?.data?.count ?? 0} 条`,
);
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
'成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
);
const unread = await api('/notifications/unread-count', { token: studentToken });
record(
'未读数 /notifications/unread-count',
unread.status === 200,
`未读 ${unread.payload?.data?.count ?? 0} 条`,
);
const markedRead = await api(`/notifications/${createdNotification.payload?.data?.id}/read`, {
method: 'POST',
token: studentToken,
});
record('标记通知已读', markedRead.status === 200, `status=${markedRead.status}`);
const readAll = await api('/notifications/read-all', { method: 'POST', token: studentToken, body: {} });
record('全部标为已读', readAll.status === 200, `本次标记 ${readAll.payload?.data?.marked ?? 0} 条`);
const pendingOnly = await api('/homeworks?pendingOnly=true', { token: studentToken });
const allPending = (pendingOnly.payload?.data ?? []).every((item) => item.completed !== true);
record(
'学生查询未完成作业(pendingOnly)',
pendingOnly.status === 200 && allPending,
`共 ${pendingOnly.payload?.data?.length ?? 0} 条`,
);
const homeworkDetail = await api(`/homeworks/${homeworkId}`, { token: studentToken });
record('作业详情 GET /homeworks/:id', homeworkDetail.status === 200, `status=${homeworkDetail.status}`);
const updatedGrade = await api(`/grades/${createdGrade.payload?.data?.id}`, {
method: 'PATCH',
token: teacherToken,
body: { score: 95 },
});
record(
'修改成绩 PATCH /grades/:id',
updatedGrade.status === 200 && updatedGrade.payload?.data?.score === 95,
`score=${updatedGrade.payload?.data?.score}`,
);
const dashboardForStudent = await api('/dashboard/summary', { token: studentToken });
record(
'学生仪表盘汇总',
dashboardForStudent.status === 200 && (dashboardForStudent.payload?.data?.classCount ?? 0) === 1,
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
);
// ---------------------------------------------------------------- 6.2 上课时段与紧急通知二次确认
// 诊断参数 at:2026-09-07 是周一,种子数据里周一 08:00-08:45 有课
const mondayInClass = await api(`/schedules/current?classId=${classId}&at=2026-09-07T08:10:00`, {
token: teacherToken,
});
record(
'上课状态接口(at=周一 08:10 判定为上课)',
mondayInClass.status === 200 && mondayInClass.payload?.data?.inClass === true,
`course=${mondayInClass.payload?.data?.current?.courseName ?? '-'} week=${mondayInClass.payload?.data?.week ?? '-'}`,
);
const sundayFree = await api(`/schedules/current?classId=${classId}&at=2026-09-06T03:00:00`, {
token: teacherToken,
});
record(
'上课状态接口(at=周日 03:00 判定为不在上课)',
sundayFree.status === 200 && sundayFree.payload?.data?.inClass === false,
`inClass=${sundayFree.payload?.data?.inClass}`,
);
// 造一节覆盖"此刻"的课,让紧急通知拦截用例与运行时刻无关
const nowDate = new Date();
const toHHmm = (minutes) => {
const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes));
return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`;
};
const nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes();
const probeStart = toHHmm(nowMinutes - 30);
const probeEnd = toHHmm(nowMinutes + 30);
const todayWeekday = nowDate.getDay() === 0 ? 7 : nowDate.getDay();
const probeSchedule = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
dayOfWeek: todayWeekday,
startTime: probeStart,
endTime: probeEnd,
location: '联调验证教室',
weekStart: 1,
weekEnd: 30,
},
});
const probeScheduleId = probeSchedule.payload?.data?.id;
record('创建覆盖当前时刻的课表(构造上课场景)', probeSchedule.status === 201, `${probeStart}-${probeEnd}`);
const realTimeStatus = await api(`/schedules/current?classId=${classId}`, { token: teacherToken });
record(
'当前时刻被判定为上课时间段',
realTimeStatus.status === 200 && realTimeStatus.payload?.data?.inClass === true,
`course=${realTimeStatus.payload?.data?.current?.courseName ?? '-'}`,
);
const blockedUrgent = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: '上课时段紧急通知(未确认)',
content: '这条应当被服务端拦截',
priority: 'URGENT',
},
});
record(
'上课时段发布紧急通知被拦截(409 URGENT_DURING_CLASS)',
blockedUrgent.status === 409 && blockedUrgent.payload?.code === 'URGENT_DURING_CLASS',
`status=${blockedUrgent.status} code=${blockedUrgent.payload?.code ?? '-'} 当前课程=${blockedUrgent.payload?.details?.current?.courseName ?? '-'}`,
);
const confirmedUrgent = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `上课时段紧急通知(已二次确认)${Date.now()}`,
content: '教师已在上课警告中确认,允许发布',
priority: 'URGENT',
confirmDuringClass: true,
},
});
record('二次确认后允许发布紧急通知', confirmedUrgent.status === 201, `status=${confirmedUrgent.status}`);
const normalDuringClass = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `上课时段普通通知${Date.now()}`,
content: '普通通知不受上课时段限制',
priority: 'NORMAL',
},
});
record(
'上课时段发布普通通知不受限制',
normalDuringClass.status === 201,
`status=${normalDuringClass.status}`,
);
if (probeScheduleId) {
const removedProbe = await api(`/schedules/${probeScheduleId}`, {
method: 'DELETE',
token: teacherToken,
});
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
record(
'学生访问其他班级被拒绝(403)',
studentCrossClass.status === 403,
`status=${studentCrossClass.status}`,
);
const studentCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询作业被拒绝(403)',
studentCrossHomework.status === 403,
`status=${studentCrossHomework.status}`,
);
const studentCrossSchedule = await api(`/schedules?classId=${foreignClass.id}`, { token: studentToken });
record(
'学生跨班查询课表被拒绝(403)',
studentCrossSchedule.status === 403,
`status=${studentCrossSchedule.status}`,
);
const teacherCrossClass = await api(`/classes/${foreignClass.id}`, { token: teacherToken });
record(
'教师访问他人班级被拒绝(403)',
teacherCrossClass.status === 403,
`status=${teacherCrossClass.status}`,
);
const teacherCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: teacherToken });
record(
'教师跨班发布/查询被拒绝(403)',
teacherCrossHomework.status === 403,
`status=${teacherCrossHomework.status}`,
);
const teacherCrossPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: foreignClass.id, title: '跨班发布', content: '不应成功' },
});
record(
'教师向他人班级发布通知被拒绝(403)',
teacherCrossPublish.status === 403,
`status=${teacherCrossPublish.status}`,
);
}
const studentPublish = await api('/notifications', {
method: 'POST',
token: studentToken,
body: { classId, title: '学生越权发布', content: '不应成功' },
});
record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`);
const anonymous = await api('/classes');
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
// 学生只能看到自己班级:作用域内所有返回项都应属于自己班级
const studentClasses = await api('/classes', { token: studentToken });
const studentVisible = studentClasses.payload?.data ?? [];
record(
'学生班级列表仅含自己所在班级',
studentVisible.length === 1 && studentVisible[0]?.id === studentUser?.classId,
`共 ${studentVisible.length} 个`,
);
const studentHomeworkAll = await api('/homeworks', { token: studentToken });
const homeworkClassIds = new Set((studentHomeworkAll.payload?.data ?? []).map((item) => item.classId));
record(
'学生作业列表已按班级收敛',
homeworkClassIds.size <= 1 && (homeworkClassIds.size === 0 || homeworkClassIds.has(studentUser?.classId)),
`涉及 ${homeworkClassIds.size} 个班级`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
const passed = results.filter((item) => item.ok).length;
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${passed}/${results.length} 项通过 ===`);
if (failed.length > 0) {
console.log('失败项:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('全部验收项通过 \u2713\n');
}
main().catch((error) => {
console.error('\n验证脚本执行失败:', error);
process.exit(1);
});