Files
ClassHelper/packages/server/src/modules/integrations/device-auth.ts
T
LoveChengke 227117ccb3 fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因
- 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**。
2026-10-11 16:21:38 +08:00

105 lines
4.0 KiB
TypeScript
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.
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_';