功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
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,
|
||
});
|