线上问题的根因 - ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室 (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、 服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。 - 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除; 定时上报只报**运行状态**(当前上什么课),不带课表。 课表同步(#3) - 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule` (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记 - 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表 —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用 - 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明 「客户端」页(#4) - 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片, 两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作 - 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER), 页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用 - 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在), 从详情抽屉里给链接 —— 不删功能 成绩(#5/#6/#7) - 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、 `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里) - 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、 并列同名次(1、2、2、4) - 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜 - `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改 - 客户端的成绩查询开关改为只读(只显示服务端下发的状态) 其他(#2/#5/#8/#9) - 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次 (原先这一级只能来自 `.env`) - 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`, 与手动发布页共用 - 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端) - 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测, 而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度 - `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构, 往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因 验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿; 后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、 Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import crypto from 'node:crypto';
|
||
import type { Request, RequestHandler } from 'express';
|
||
import { prisma } from '../../lib/db.js';
|
||
import { ApiError } from '../../lib/http.js';
|
||
|
||
/**
|
||
* 联动设备的令牌工具。
|
||
*
|
||
* 为什么不用 JWT:
|
||
* - 令牌是「设备级长期凭证」,插件装在教室机器上,长时间无人值守,
|
||
* 需要一个"管理员在 Web 端点了重置才失效"的凭证,而不是有固定过期时间的会话;
|
||
* - 明文只在创建/重置接口返回一次,服务端只存 sha256 —— 库被读走也无法反推出可用令牌。
|
||
*/
|
||
export function generateDeviceToken(): string {
|
||
// 32 字节随机 -> 64 位十六进制,前缀便于在日志/设置页里一眼认出
|
||
return `chci_${crypto.randomBytes(32).toString('hex')}`;
|
||
}
|
||
|
||
export function hashDeviceToken(token: string): string {
|
||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
||
}
|
||
|
||
/** 令牌前缀提示(chci_ + 前 8 位),用于设备列表里人眼识别 */
|
||
export function tokenHintOf(token: string): string {
|
||
return `${token.slice(0, 13)}…`;
|
||
}
|
||
|
||
/** 从 \`X-ClassIsland-Token\` 或 \`Authorization: Bearer\` 中取出设备令牌 */
|
||
export function extractDeviceToken(headerValue: string | undefined, custom: unknown): string | null {
|
||
if (typeof custom === 'string' && custom.trim()) return custom.trim();
|
||
if (!headerValue) return null;
|
||
const [scheme, value] = headerValue.split(' ');
|
||
if (!value || scheme.toLowerCase() !== 'bearer') return null;
|
||
return value.trim();
|
||
}
|
||
|
||
/** 设备上下文(与 Express.Request.device 的结构一致) */
|
||
export interface DeviceContext {
|
||
deviceId: string;
|
||
classId: string;
|
||
name: string;
|
||
enabled: boolean;
|
||
syncScheduleToServer: boolean;
|
||
mirrorScheduleToClassIsland: boolean;
|
||
/** 是否有一次待办的「下发课表到教室」(Web 端点按钮时置位,拉取成功即清除) */
|
||
mirrorRequested: boolean;
|
||
}
|
||
|
||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||
export function authenticateDevice(): RequestHandler {
|
||
return (req, _res, next) => {
|
||
void (async () => {
|
||
try {
|
||
const token = extractDeviceToken(req.headers.authorization, req.headers['x-classisland-token']);
|
||
if (!token) throw ApiError.unauthorized('缺少设备令牌(X-ClassIsland-Token)');
|
||
|
||
const record = await prisma.integrationDevice.findFirst({
|
||
where: { tokenHash: hashDeviceToken(token) },
|
||
select: {
|
||
id: true,
|
||
classId: true,
|
||
name: true,
|
||
enabled: true,
|
||
syncScheduleToServer: true,
|
||
mirrorScheduleToClassIsland: true,
|
||
requestMirrorSchedule: true,
|
||
},
|
||
});
|
||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||
|
||
const context: DeviceContext = {
|
||
deviceId: record.id,
|
||
classId: record.classId,
|
||
name: record.name,
|
||
enabled: record.enabled,
|
||
syncScheduleToServer: record.syncScheduleToServer,
|
||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||
mirrorRequested: record.requestMirrorSchedule === true,
|
||
};
|
||
req.device = context;
|
||
next();
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
})();
|
||
};
|
||
}
|
||
|
||
/** 取出当前设备上下文,未鉴权时抛 401 */
|
||
export function getDevice(req: Request): DeviceContext {
|
||
const device = req.device;
|
||
if (!device) throw ApiError.unauthorized('缺少设备上下文');
|
||
return device;
|
||
}
|
||
|
||
/** 设备被管理员停用:插件上报与下发一律拒绝(403 而不是 401,便于插件提示"请管理员开启") */
|
||
export function assertDeviceEnabled(device: DeviceContext): void {
|
||
if (!device.enabled) {
|
||
throw ApiError.forbidden('该设备已在「ClassIsland 联动」中被停用,请联系管理员开启');
|
||
}
|
||
}
|
||
|
||
/** 生成的令牌字符串的字面量前缀,供测试与文档引用 */
|
||
export const DEVICE_TOKEN_PREFIX = 'chci_';
|