feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)

功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-11 11:49:32 +08:00
1 parent e51879667a
commit 817af02357
131 files changed
+12995 -538

No files matched your search

@@ -68,6 +68,18 @@ const COLUMNS = [
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
{
table: 'User',
column: 'careMode',
sql: `ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false`,
note: '关怀模式(大字版界面)',
},
{
table: 'Notification',
column: 'assignDate',
sql: `ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''`,
note: '通知所属日期(本地日期 YYYY-MM-DD,按天归档用)',
},
];
/**
+782 -9
View File
@@ -167,9 +167,35 @@ async function main() {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id;
let teacher2Token = teacher2Login.payload?.data?.token;
let teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
// 演示库里的密码可能被人工改过(本机 teacher1 就有被改成 123456 的情况):登录失败时由管理员
// 重置为验证环境已知值再重登。**teacher2 是"科任老师/班级隔离"整组用例的主体**,
// 上一版漏了这条兜底,密码一改后面整片用例都只能跑空分支。
if (!teacher2Token) {
const staffList = await api('/teachers', { token: adminToken });
const fallbackTeacher2Id =
teacher2Id ?? (staffList.payload?.data ?? []).find((item) => item.username === 'teacher2')?.id;
if (fallbackTeacher2Id) {
await api(`/teachers/${fallbackTeacher2Id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: 'e2e-teacher2-123456' },
});
const retryTeacher2 = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'e2e-teacher2-123456' },
});
teacher2Token = retryTeacher2.payload?.data?.token;
teacher2Id = retryTeacher2.payload?.data?.user?.id;
record(
'教师 teacher2 密码被改过,已由管理员重置为验证环境已知值(登录环境自愈)',
Boolean(teacher2Token),
`status=${retryTeacher2.status}`,
);
}
}
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
@@ -1191,6 +1217,13 @@ async function main() {
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record(
'存在"别人任教科目"用于越权校验',
Boolean(otherCourse?.id),
`科目=${otherCourse?.name ?? '-'}`,
);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
@@ -1285,6 +1318,641 @@ async function main() {
}
}
// ---------------------------------------------------------------- 6.5 通知按天归档 + 批量发布
{
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
// 按天归档:通知也有"所属日期"(assignDate,本地日期),晚间发布不会被 UTC 切片算到第二天
const daysRes = await api(`/notifications/days?classId=${classId}`, { token: teacherToken });
const days = daysRes.payload?.data?.days ?? [];
record(
'通知按天归档 GET /notifications/days(按所属日期汇总条数)',
daysRes.status === 200 &&
days.length > 0 &&
days.every((item) => DAY_KEY.test(String(item.date)) && item.count > 0),
`status=${daysRes.status} 天数=${days.length} 样例=${JSON.stringify(days[0] ?? null)}`,
);
const creatorsRes = await api(`/notifications/creators?classId=${classId}`, { token: teacherToken });
const creators = creatorsRes.payload?.data?.creators ?? [];
record(
'通知按发布人归档 GET /notifications/creators(带名字与条数)',
creatorsRes.status === 200 &&
creators.length > 0 &&
creators.every((item) => item.id && item.name && item.count > 0),
`status=${creatorsRes.status} 发布人=${creators.length} 样例=${JSON.stringify(creators[0] ?? null)}`,
);
// 按天筛选:返回的每条通知 assignDate 必须等于筛选的日期
const targetDay = days[0]?.date;
if (targetDay) {
const byDay = await api(`/notifications?classId=${classId}&date=${targetDay}`, { token: teacherToken });
const rows = byDay.payload?.data ?? [];
record(
'通知按天筛选(?date=)只返回该天的通知',
byDay.status === 200 && rows.length > 0 && rows.every((item) => item.assignDate === targetDay),
`date=${targetDay} 条数=${rows.length}`,
);
}
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
// 批量发布通知:一次提交多个班,返回逐班结果
const bulkClassIds = foreignClass ? [classId, foreignClass.id] : [classId];
const bulkNotice = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: bulkClassIds,
title: `批量发布回归 ${Date.now()}`,
content: '批量发布通知用例(管理员对多个班级一次发布)',
priority: 'NORMAL',
},
});
const bulkNoticeData = bulkNotice.payload?.data;
record(
'批量发布通知 POST /notifications/bulk(逐班结果)',
bulkNotice.status === 201 &&
bulkNoticeData?.total === bulkClassIds.length &&
bulkNoticeData?.succeeded === bulkClassIds.length &&
(bulkNoticeData?.results ?? []).every((item) => item.ok && item.id),
`status=${bulkNotice.status} total=${bulkNoticeData?.total} 成功=${bulkNoticeData?.succeeded}`,
);
for (const item of bulkNoticeData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
if (foreignClass) {
// 教师批量发到"别人的班":那个班逐班失败,自己的班照常发布,整批不能变成 500
const mixed = await api('/notifications/bulk', {
method: 'POST',
token: teacherToken,
body: {
classIds: [classId, foreignClass.id],
title: `批量越权回归 ${Date.now()}`,
content: '其中一个班没有发布权限',
priority: 'NORMAL',
},
});
const mixedData = mixed.payload?.data;
const denied = (mixedData?.results ?? []).find((item) => item.classId === foreignClass.id);
record(
'批量发布通知:无权限的班逐班失败、有权限的班照常发布',
mixed.status === 201 && mixedData?.succeeded === 1 && denied?.ok === false,
`status=${mixed.status} 成功=${mixedData?.succeeded} 失败code=${denied?.code ?? '-'} 失败原因=${denied?.message ?? '-'}`,
);
for (const item of mixedData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: teacherToken });
}
// 批量布置作业:跨班按**科目名称**匹配课程(课程是班级内的,不可能共用一个 courseId)
const bulkHomework = await api('/homeworks/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [classId, foreignClass.id],
subjectName: '语文',
title: `批量布置回归 ${Date.now()}`,
content: '批量布置作业用例(按科目名称跨班匹配)',
},
});
const bulkHomeworkData = bulkHomework.payload?.data;
record(
'批量布置作业 POST /homeworks/bulk(逐班结果 + 按科目名称匹配课程)',
bulkHomework.status === 201 &&
bulkHomeworkData?.total === 2 &&
(bulkHomeworkData?.results ?? []).every((item) => typeof item.ok === 'boolean'),
`status=${bulkHomework.status} 成功=${bulkHomeworkData?.succeeded} 失败=${bulkHomeworkData?.failed} ` +
`逐班=${JSON.stringify((bulkHomeworkData?.results ?? []).map((item) => item.code ?? 'ok'))}`,
);
for (const item of bulkHomeworkData?.results ?? []) {
if (item.id) await api(`/homeworks/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
}
// ---------------------------------------------------------------- 6.6 权限组
{
const meTeacher = await api('/permissions/me', { token: teacherToken });
const meTeacherData = meTeacher.payload?.data;
const meAdmin = await api('/permissions/me', { token: adminToken });
const meAdminData = meAdmin.payload?.data;
record(
'当前账号生效权限 GET /permissions/me(教师有 homework:write、无 permission:manage)',
meTeacher.status === 200 &&
Array.isArray(meTeacherData?.permissions) &&
meTeacherData.permissions.includes('homework:write') &&
!meTeacherData.permissions.includes('permission:manage'),
`status=${meTeacher.status} 权限数=${(meTeacherData?.permissions ?? []).length} ` +
`fromGroups=${meTeacherData?.fromGroups}`,
);
record(
'管理员生效权限是全集(含 permission:manage 与 remote:control)',
meAdmin.status === 200 &&
(meAdminData?.permissions ?? []).includes('permission:manage') &&
(meAdminData?.permissions ?? []).includes('remote:control'),
`status=${meAdmin.status} 权限数=${(meAdminData?.permissions ?? []).length}`,
);
// 权限键目录是 permission:manage 专属:教师 403、管理员拿到整棵树
const catalogDenied = await api('/permissions/catalog', { token: teacherToken });
const catalog = await api('/permissions/catalog', { token: adminToken });
const catalogData = catalog.payload?.data;
record(
'权限目录 GET /permissions/catalog(教师 403 / 管理员拿到 keys+categories+defaultRoles)',
catalogDenied.status === 403 &&
catalog.status === 200 &&
Array.isArray(catalogData?.keys) &&
catalogData.keys.includes('homework:write') &&
Array.isArray(catalogData?.categories) &&
typeof catalogData?.defaultRoles === 'object' &&
catalogData.defaultRoles !== null,
`教师=${catalogDenied.status} 管理员=${catalog.status} 键数=${(catalogData?.keys ?? []).length} ` +
`分类数=${(catalogData?.categories ?? []).length}`,
);
// 权限组可以比角色默认**更少**:只给 dashboard:read 的组分配给 teacher2 后,他只剩这一个键
const groupName = `e2e-只读-${Date.now().toString().slice(-6)}`;
const createdGroup = await api('/permissions/groups', {
method: 'POST',
token: adminToken,
body: { name: groupName, description: '验证用临时权限组(收尾删除)', keys: ['dashboard:read'] },
});
const permissionGroupId = createdGroup.payload?.data?.id;
// 成员列表是"账号 id"的权威来源:登录响应里的 user.id 只是会话用户,
// 权限页的成员 id 要按 username 从 GET /permissions/users 取(管理员本人也要在这里找)
const membersRes = await api('/permissions/users', { token: adminToken });
const members = membersRes.payload?.data ?? [];
const teacher2Member = members.find((item) => item.username === 'teacher2') ?? null;
const adminMember = members.find((item) => item.username === 'admin') ?? null;
// teacher2 的密码按脚本既有做法自愈(本机可能被人工改过),失败就重置成已知值再登
const teacher2Password = 'teacher123';
let teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
if (teacher2Auth.status !== 200 && teacher2Member) {
await api(`/teachers/${teacher2Member.id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: teacher2Password },
});
teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
}
const permissionTestToken = teacher2Auth.payload?.data?.token;
const assignLess = permissionGroupId
? await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [permissionGroupId] },
})
: { status: 0, payload: null };
const meLess = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2LessPermissions = meLess.payload?.data?.permissions ?? [];
record(
'权限组可以比角色默认更少(只给 dashboard:read 的组 → 生效权限恰好只有这一个键)',
Boolean(permissionGroupId) &&
assignLess.status === 200 &&
teacher2LessPermissions.length === 1 &&
teacher2LessPermissions[0] === 'dashboard:read',
`建组=${createdGroup.status} 分配=${assignLess.status} 生效权限=${JSON.stringify(teacher2LessPermissions)}`,
);
const assignDefault = await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
const meReset = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2ResetPermissions = meReset.payload?.data?.permissions ?? [];
record(
'清空权限组后回到角色默认模板(PUT /users/:id/groups 传空数组 → 含 homework:write)',
assignDefault.status === 200 &&
teacher2ResetPermissions.includes('homework:write') &&
!teacher2ResetPermissions.includes('permission:manage'),
`status=${assignDefault.status} 生效权限数=${teacher2ResetPermissions.length} ` +
`含 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('权限组'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
);
// 内置权限组不可删除:先找一个 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 });
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
deleteBuiltin.status === 400,
`内置组=${builtinGroup.name} status=${deleteBuiltin.status} 响应=${deleteBuiltin.payload?.message ?? ''}`,
);
} else {
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
true,
`跳过:当前库里没有任何 builtin=true 的权限组(GET /permissions/groups 返回 ${(groupsList.payload?.data ?? []).length} 个,种子里的「教务协作(示例)」不在库里)`,
);
}
// 收尾:teacher2 必须回到角色默认(否则后面所有以他为科任老师的用例全被权限组污染),临时组删掉
await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
if (permissionGroupId) {
await api(`/permissions/groups/${permissionGroupId}`, { method: 'DELETE', token: adminToken });
}
const groupsAfter = await api('/permissions/groups', { token: adminToken });
record(
'权限组用例收尾:teacher2 分组已清空、临时权限组已删除',
!(groupsAfter.payload?.data ?? []).some((item) => item.id === permissionGroupId),
`剩余权限组=${(groupsAfter.payload?.data ?? []).length}`,
);
}
// ---------------------------------------------------------------- 6.7 客户端设备与远控
{
const deviceKey = `e2e-device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const heartbeatBase = {
deviceKey,
name: 'E2E 测试机',
clientVersion: '1.0.0',
platform: 'win32',
stateNote: '待机',
};
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 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 heartbeatData = heartbeat.payload?.data;
record(
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
heartbeatNoAuth.status === 401 &&
heartbeat.status === 200 &&
Boolean(heartbeatData?.deviceId) &&
'command' in (heartbeatData ?? {}),
`未登录=${heartbeatNoAuth.status} 登录=${heartbeat.status} deviceId=${heartbeatData?.deviceId ?? '-'} ` +
`command=${JSON.stringify(heartbeatData?.command ?? null)}`,
);
// 越权:remote:control 默认只有管理员有
const teacherCommandDenied = await api('/clients/commands', {
method: 'POST',
token: teacherToken,
body: { deviceIds: [heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: '越权用例' },
});
record(
'远控命令越权被拒(教师无 remote:control → 403)',
teacherCommandDenied.status === 403,
`status=${teacherCommandDenied.status} 响应=${teacherCommandDenied.payload?.message ?? ''}`,
);
// 设备列表:刚心跳的这台要在线
const devices = await api('/clients/devices', { token: adminToken });
const device = (devices.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'设备列表 GET /clients/devices(刚心跳的设备在线)',
devices.status === 200 && Boolean(device) && device.online === true,
`status=${devices.status} 设备=${device?.name ?? '未找到'} online=${device?.online}`,
);
// 下发锁屏命令(带备注)→ 下一次心跳必须带回这条命令
const command = await api('/clients/commands', {
method: 'POST',
token: adminToken,
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
});
const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, stateNote: '待机' },
});
const delivered = heartbeatWithCommand.payload?.data?.command;
record(
'远控命令下发 POST /clients/commands(lock)并在下次心跳送达(kind=lock)',
command.status === 201 && Boolean(delivered?.id) && delivered?.kind === 'lock',
`下发=${command.status} 送达=${JSON.stringify(delivered ?? null)}`,
);
const ackBody = { deviceKey, ok: true, result: 'e2e' };
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: ackBody,
});
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
});
record(
'命令回执 POST /clients/commands/:id/ack(首次 200,重复回执幂等仍 200)',
ackFirst.status === 200 && ackSecond.status === 200,
`首次=${ackFirst.status}(${ackFirst.payload?.data?.result ?? '-'}) 重复=${ackSecond.status}(${ackSecond.payload?.data?.result ?? '-'})`,
);
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'回执后设备不再有待执行命令(pendingCommand 为空)',
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
`pendingCommand=${JSON.stringify(ackedDevice?.pendingCommand ?? null)}`,
);
// 归属校验:别人拿自己的 deviceKey 回执同一 id 必须被拒
const otherDeviceKey = `${deviceKey}-other`;
const otherHeartbeat = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
});
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
});
record(
'回执归属校验(别的 deviceKey 回执同一命令 → 403)',
ackWrongDevice.status === 403,
`status=${ackWrongDevice.status} code=${ackWrongDevice.payload?.code ?? '-'} ` +
`响应=${ackWrongDevice.payload?.message ?? ''}`,
);
// 收尾:两台测试机都清掉(否则会长期挂在设备列表里)
const deleteTestDevice = device
? await api(`/clients/devices/${device.id}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deleteOtherDevice = otherDeviceId
? await api(`/clients/devices/${otherDeviceId}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deviceAfterDelete = await api('/clients/devices', { token: adminToken });
const leftoverKeys = (deviceAfterDelete.payload?.data ?? [])
.filter((item) => item.deviceKey === deviceKey || item.deviceKey === otherDeviceKey)
.map((item) => item.deviceKey);
record(
'客户端设备用例收尾:两台测试机都已移除',
deleteTestDevice.status === 200 && deleteOtherDevice.status === 200 && leftoverKeys.length === 0,
`删除测试机=${deleteTestDevice.status} 删除另一台=${deleteOtherDevice.status} ` +
`残留=${leftoverKeys.length ? leftoverKeys.join(',') : '无'}`,
);
}
// ---------------------------------------------------------------- 6.8 客户端安装包
{
const releases = await api('/update/client/releases', { token: adminToken });
record(
'客户端安装包列表 GET /update/client/releases',
releases.status === 200 && Array.isArray(releases.payload?.data),
`status=${releases.status} 版本数=${(releases.payload?.data ?? []).length}`,
);
// 登记只写元数据,**但要求磁盘上真的有这个版本的安装包**:不存在的版本必须被拒(400)
const fakeVersion = '9.9.9-e2e';
const registerMissing = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: fakeVersion },
});
const releasesAfterRegister = await api('/update/client/releases', { token: adminToken });
record(
'登记不存在的客户端安装包版本被拒(400,且不会落库)',
registerMissing.status === 400 &&
!(releasesAfterRegister.payload?.data ?? []).some((item) => item.version === fakeVersion),
`status=${registerMissing.status} code=${registerMissing.payload?.code ?? '-'} ` +
`响应=${registerMissing.payload?.message ?? ''}`,
);
// 版本白名单:含路径分隔符的版本号连 schema 都过不去
const badVersion = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: '../evil' },
});
record(
'版本号白名单(POST /update/client/releases 传 ../evil → 400/422)',
badVersion.status === 400 || badVersion.status === 422,
`status=${badVersion.status} code=${badVersion.payload?.code ?? '-'} ` +
`响应=${badVersion.payload?.message ?? ''}`,
);
// 下载接口本身不挂权限键,但**要登录**:不带 token 会被 401 挡在鉴权层
const downloadNoAuth = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`);
const downloadMissing = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`, {
token: adminToken,
});
record(
'下载不存在的客户端安装包(未登录 401 / 已登录 404)',
downloadNoAuth.status === 401 && downloadMissing.status === 404,
`未登录=${downloadNoAuth.status} 已登录=${downloadMissing.status} ` +
`响应=${downloadMissing.payload?.message ?? ''}`,
);
}
// ---------------------------------------------------------------- 6.9 大模型助手(一律 mock,绝不联网)
{
const aiSettingsDenied = await api('/ai/settings', { token: teacherToken });
const aiSettings = await api('/ai/settings', { token: adminToken });
const aiSettingsData = aiSettings.payload?.data ?? {};
const aiSettingsText = JSON.stringify(aiSettingsData);
record(
'大模型设置 GET /ai/settings(教师 403 / 管理员 200,且响应里没有任何密钥字段)',
aiSettingsDenied.status === 403 &&
aiSettings.status === 200 &&
typeof aiSettingsData.enabled === 'boolean' &&
'provider' in aiSettingsData &&
'baseUrl' in aiSettingsData &&
'model' in aiSettingsData &&
'hasApiKey' in aiSettingsData &&
'keyHint' in aiSettingsData &&
'systemPromptExtra' in aiSettingsData &&
!aiSettingsText.includes('apiKey'),
`教师=${aiSettingsDenied.status} 管理员=${aiSettings.status} 字段=${Object.keys(aiSettingsData).join(',')}`,
);
// 助手没开启时 /interpret 会 400:先开启,且**强制走离线 mock 解析**(绝不联网)
const enableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: true, provider: 'mock' },
});
record(
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
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}`,
);
const promptText = '给2026级1班布置作业,把卷子写完';
const interpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: promptText },
});
const interpretData = interpret.payload?.data;
record(
'助手解析布置作业(POST /ai/interpret → create_homework,需确认、班级非空、有预览)',
interpret.status === 200 &&
interpretData?.action === 'create_homework' &&
interpretData?.needConfirm === true &&
Boolean(interpretData?.params?.classId) &&
Boolean(interpretData?.preview?.summary),
`status=${interpret.status} action=${interpretData?.action} needConfirm=${interpretData?.needConfirm} ` +
`classId=${interpretData?.params?.classId ?? '-'} summary=${interpretData?.preview?.summary ?? '-'}`,
);
const unknownInterpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: '今天天气不错' },
});
record(
'助手听不懂时返回 unknown(不猜动作)',
unknownInterpret.status === 200 && unknownInterpret.payload?.data?.action === 'unknown',
`status=${unknownInterpret.status} action=${unknownInterpret.payload?.data?.action}`,
);
const audit = await api('/ai/audit', { token: adminToken });
const auditRows = audit.payload?.data ?? [];
record(
'助手使用审计 GET /ai/audit(能看到刚写入的那句话)',
audit.status === 200 && auditRows.some((item) => item.prompt === promptText),
`status=${audit.status} 记录数=${auditRows.length}`,
);
// 收尾:助手必须还原成关闭(否则别的东西会以为已经配好了模型)
const disableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: false },
});
record(
'大模型助手用例收尾:enabled 已还原为 false',
disableAi.status === 200 && disableAi.payload?.data?.enabled === false,
`status=${disableAi.status} enabled=${disableAi.payload?.data?.enabled}`,
);
}
// ---------------------------------------------------------------- 6.10 系统设置
{
const systemDenied = await api('/system/settings', { token: teacherToken });
const systemSettings = await api('/system/settings', { token: adminToken });
record(
'系统设置 GET /system/settings(教师 403 / 管理员 200)',
systemDenied.status === 403 &&
systemSettings.status === 200 &&
typeof systemSettings.payload?.data?.developerMode === 'boolean',
`教师=${systemDenied.status} 管理员=${systemSettings.status} ` +
`developerMode=${systemSettings.payload?.data?.developerMode}`,
);
// 开发者模式:开启后错误响应会带上堆栈,所以**必须**还原
const enableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: true },
});
const readBackDeveloper = await api('/system/settings', { token: adminToken });
const disableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: false },
});
const readBackDisabled = await api('/system/settings', { token: adminToken });
record(
'开发者模式可开关并回读(PUT /system/settings → GET 一致,随后还原为 false)',
enableDeveloper.status === 200 &&
readBackDeveloper.payload?.data?.developerMode === true &&
disableDeveloper.status === 200 &&
readBackDisabled.payload?.data?.developerMode === false,
`开启=${enableDeveloper.status} 回读=${readBackDeveloper.payload?.data?.developerMode} ` +
`还原=${readBackDisabled.payload?.data?.developerMode}`,
);
const autostart = await api('/system/autostart', { token: adminToken });
const autostartData = autostart.payload?.data;
const enableAutostart = await api('/system/autostart', {
method: 'PUT',
token: adminToken,
body: { enabled: true },
});
record(
'开机自启:开发机不支持(GET supported=false,PUT 拒绝 400)',
autostart.status === 200 &&
autostartData?.supported === false &&
typeof autostartData?.detail === 'string' &&
autostartData.detail.length > 0 &&
enableAutostart.status === 400,
`GET=${autostart.status} supported=${autostartData?.supported} mechanism=${autostartData?.mechanism} ` +
`detail=${autostartData?.detail ?? '-'} PUT=${enableAutostart.status} ` +
`响应=${enableAutostart.payload?.message ?? ''}`,
);
const diagnostics = await api('/system/diagnostics', { token: adminToken });
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),
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
);
}
// ---------------------------------------------------------------- 6.11 批量发布补漏
{
// 一个不存在的班级 + 一个真实班级:单班失败不能把整批带崩(逐班结果,不整批回滚)
const missingClassId = `not-exist-${Date.now().toString(36)}`;
const mixedBulk = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [missingClassId, classId],
title: `批量失败隔离回归 ${Date.now()}`,
content: '其中一个班不存在,另一个班照常发布',
priority: 'NORMAL',
},
});
const mixedBulkData = mixedBulk.payload?.data;
const missingResult = (mixedBulkData?.results ?? []).find((item) => item.classId === missingClassId);
record(
'批量发布:不存在的班级逐班失败、真实班级照常成功(failed>=1 且 succeeded>=1)',
mixedBulk.status === 201 &&
(mixedBulkData?.failed ?? 0) >= 1 &&
(mixedBulkData?.succeeded ?? 0) >= 1 &&
missingResult?.ok === false,
`status=${mixedBulk.status} total=${mixedBulkData?.total} 成功=${mixedBulkData?.succeeded} ` +
`失败=${mixedBulkData?.failed} 失败code=${missingResult?.code ?? '-'}`,
);
// 清理:只删成功落库的那几条(失败的班没有 id)
for (const item of mixedBulkData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
@@ -1684,6 +2352,76 @@ async function main() {
`status=${gradesByHeadTeacher.status} 跳过=${gradesByHeadTeacher.payload?.data?.skipped} 新增=${gradesByHeadTeacher.payload?.data?.inserted}`,
);
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
{
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
const firstCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
const firstCourseId = firstCourse.payload?.data?.id;
record(
'新建科目成功(用于越权校验)',
firstCourse.status === 201 && Boolean(firstCourseId),
`status=${firstCourse.status} id=${firstCourseId ?? '-'}`,
);
const duplicateCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
record(
'同名课程被拒绝并返回已有课程 id(COURSE_EXISTS)',
duplicateCourse.status === 409 &&
duplicateCourse.payload?.code === 'COURSE_EXISTS' &&
duplicateCourse.payload?.details?.courseId === firstCourseId,
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
);
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
// 班主任导入它的成绩必须被拒,且不能落库。
const guardedCsv = [
'学号,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
].join(NL);
const guardedImport = await api('/imports/table/commit', {
method: 'POST',
token: teacherToken,
body: {
kind: 'grades',
classId,
fileName: 'guarded.csv',
contentBase64: Buffer.from(`\ufeff${guardedCsv}${NL}`, 'utf8').toString('base64'),
mapping: {
studentNo: '学号',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '科目',
},
mode: 'append',
},
});
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
record(
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
guardedImport.status === 200 &&
guardedImport.payload?.data?.inserted === 0 &&
guardedImport.payload?.data?.failed === 1 &&
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
);
if (firstCourseId) {
await api(`/courses/${firstCourseId}`, { method: 'DELETE', token: adminToken });
}
}
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
@@ -2433,17 +3171,48 @@ async function main() {
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。
const REQUIRED_DB_TABLES = [
'User',
'ArchivedYear',
'Class',
'Student',
'Course',
'StudentClassTransfer',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'IntegrationDevice',
'ClassIslandPush',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
];
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
? dbStatusData.tables.map((item) => item.name)
: [];
record(
'数据库状态(管理员):已连接且返回 15 张表行数',
'数据库状态(管理员):已连接且返回全部数据表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
dbStatusData.tables.every((item) => item.count >= 0) &&
dbStatusTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => dbStatusTableNames.includes(name)),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
dbStatusTableNames.length
}`,
);
@@ -2556,13 +3325,17 @@ async function main() {
} catch {
snapshotParsed = null;
}
const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {});
record(
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
'导出快照(JSON 格式正确、全部数据表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
snapshotTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => snapshotTableNames.includes(name)) &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 表=${snapshotTableNames.length} 用户=${
snapshotParsed?.counts?.User ?? '-'
}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)