Files
ClassHelper/packages/server/src/modules/integrations/integrations.module.ts
T
LoveChengke 817af02357 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,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

236 lines
8.8 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 { Router } from 'express';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as gradeService from '../grades/grades.service.js';
import { studentGradeQuerySchema } from '../grades/grades.schemas.js';
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
import {
ackNotificationSchema,
classIslandReportSchema,
createDeviceSchema,
deviceIdParamSchema,
listDevicesQuerySchema,
sendNotificationSchema,
updateDeviceSchema,
type ClassIslandReportInput,
type CreateDeviceInput,
type SendNotificationInput,
type UpdateDeviceInput,
} from './integrations.schemas.js';
import * as integrationService from './integrations.service.js';
const router = Router();
/* ================================================================== 插件侧
* 用**设备令牌**鉴权(X-ClassIsland-Token / Authorization: Bearer),不是 JWT 会话。
* 路由放在文件最前面,避免被下面的 authenticate() 拦住;每个路由单独挂 authenticateDevice()。
*/
/** POST /api/integrations/classisland/report - 插件上报当前状态 +(可选)课表 */
router.post(
'/classisland/report',
authenticateDevice(),
validate({ body: classIslandReportSchema }),
async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
const result = await integrationService.applyReport(device, validatedBody<ClassIslandReportInput>(req));
sendOk(res, result, '上报成功');
},
);
/** GET /api/integrations/classisland/pending - 插件拉取尚未确认的下发提醒(离线补齐) */
router.get('/classisland/pending', authenticateDevice(), async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
sendOk(res, await integrationService.listPendingNotifications(device), '获取待提醒成功');
});
/** POST /api/integrations/classisland/ack - 插件确认提醒已弹出(之后不再补发) */
router.post(
'/classisland/ack',
authenticateDevice(),
validate({ body: ackNotificationSchema }),
async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
await integrationService.ackNotification(device, validatedBody<{ id: string }>(req).id);
sendOk(res, { acked: true }, '已确认');
},
);
/** GET /api/integrations/classisland/class-plan - 插件拉取本班课表(镜像回 ClassIsland) */
router.get('/classisland/class-plan', authenticateDevice(), async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
if (!device.mirrorScheduleToClassIsland) {
sendOk(res, null, '该设备未开启「把班级课表镜像到 ClassIsland」');
return;
}
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
});
/**
* GET /api/integrations/classisland/student-grades?studentNo= - 班级端按学号查成绩明细
*
* 给"插件形态的 ClassHelper 班级端"留的通道:走设备令牌而不是 JWT。
* 受**同一个班级开关**约束(`Class.studentGradeQueryEnabled`),与桌面客户端走的是同一条 service。
*/
router.get(
'/classisland/student-grades',
authenticateDevice(),
validate({ query: studentGradeQuerySchema }),
async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
const { studentNo } = validatedQuery<{ studentNo?: string }>(req);
if (!studentNo) throw ApiError.badRequest('请提供学号(studentNo)');
// 设备令牌没有"账号",构造一个等价于 ClassHelper 班级端的会话载荷
const session = {
sub: device.classId,
username: '',
name: '',
role: 'CLASS_DEVICE' as const,
classId: device.classId,
classSession: true,
};
sendOk(
res,
await gradeService.getStudentGradeDetail(session, studentNo, device.classId),
'获取学生成绩明细成功',
);
},
);
/* ================================================================== Web 管理端
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
*/
router.use(authenticate());
/**
* GET /api/integrations/devices?classId= - 联动设备列表(含最后一次上报的状态快照)
*
* 仅管理员/教师可见:设备列表属于管理面,学生也不需要知道教室机器接了哪台设备;
* 教师只能看到自己可访问班级的设备(service 内按 classScope 过滤)。
*/
router.get(
'/devices',
requirePermission('device:manage'),
validate({ query: listDevicesQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await integrationService.listDevices(getAuthUser(req), classId), '获取设备列表成功');
},
);
/**
* GET /api/integrations/classisland/status - **ClassHelper 班级端在线状态**(教师网页)
*
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
*/
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
});
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post(
'/devices',
requirePermission('device:manage'),
validate({ body: createDeviceSchema }),
async (req, res) => {
const result = await integrationService.createDevice(
getAuthUser(req),
validatedBody<CreateDeviceInput>(req),
);
sendCreated(res, result, '设备已创建,请把令牌填入插件设置(只显示这一次)');
},
);
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
router.patch(
'/devices/:id',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await integrationService.updateDevice(getAuthUser(req), id, validatedBody<UpdateDeviceInput>(req)),
'设备已更新',
);
},
);
/**
* POST /api/integrations/devices/:id/request-schedule - 「从教室机器获取一次课表」
*
* **这是现在取教室课表的唯一入口**(原来的自动回传已默认关闭,见
* `IntegrationDevice.syncScheduleToServer`)。Web 端点击前会弹警告对话框,
* 说明它会用教室机器的课表覆盖本班现有课表。
* 置一个待办标记,插件在下一次心跳看到就立刻把课表推上来。
*/
router.post(
'/devices/:id/request-schedule',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const result = await integrationService.requestScheduleReport(getAuthUser(req), id);
sendOk(res, result, result.nextHeartbeatHint);
},
);
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post(
'/devices/:id/token',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await integrationService.resetDeviceToken(getAuthUser(req), id), '令牌已重置');
},
);
/** DELETE /api/integrations/devices/:id - 删除设备 */
router.delete(
'/devices/:id',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await integrationService.deleteDevice(getAuthUser(req), id);
sendOk(res, { id }, '设备已删除');
},
);
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
router.post(
'/classisland/notify',
requirePermission('classisland:notify'),
validate({ body: sendNotificationSchema }),
async (req, res) => {
const result = await integrationService.sendNotification(
getAuthUser(req),
validatedBody<SendNotificationInput>(req),
);
sendOk(
res,
result,
result.skipped === 'channel-client'
? '该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland'
: result.targetCount === 0
? '已保存提醒,但该班级还没有已接入的 ClassIsland 设备'
: `已下发到 ${result.delivered} 台 ClassIsland 设备`,
);
},
);
export const integrationsModule = defineModule({
name: 'integrations',
basePath: '/integrations',
router,
});