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:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
type ClientCommandAckRequest,
|
||||
type ClientCommandDto,
|
||||
type ClientCommandKind,
|
||||
type ClientDeviceDto,
|
||||
type ClientHeartbeatResponse,
|
||||
type ClientPushResult,
|
||||
type ClientTargetResult,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import * as clientReleases from '../update/client-releases.service.js';
|
||||
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
|
||||
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
|
||||
type DeviceRow = {
|
||||
id: string;
|
||||
classId: string | null;
|
||||
deviceKey: string;
|
||||
name: string;
|
||||
clientVersion: string;
|
||||
platform: string;
|
||||
autoUpdate: boolean;
|
||||
updateTargetVersion: string;
|
||||
pendingVersion: string;
|
||||
lastHeartbeatAt: Date | null;
|
||||
lastIp: string;
|
||||
stateNote: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
type CommandRow = {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
kind: string;
|
||||
payload: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
ackedAt: Date | null;
|
||||
result: string;
|
||||
};
|
||||
|
||||
/** 备注存 JSON 文本(`payload` 字段是预留结构,见 schema 注释) */
|
||||
function encodePayload(note: string): string {
|
||||
return JSON.stringify({ note });
|
||||
}
|
||||
|
||||
function decodePayload(raw: string): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { note?: unknown };
|
||||
return typeof parsed.note === 'string' ? parsed.note : '';
|
||||
} catch {
|
||||
// 兼容手工写进库的纯文本
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function toCommandDto(row: CommandRow): ClientCommandDto {
|
||||
const kind = row.kind as ClientCommandKind;
|
||||
const label = CLIENT_COMMAND_LABELS[kind] ?? row.kind;
|
||||
const note = decodePayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
kind,
|
||||
payload: note ? `${label}(${note})` : label,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
|
||||
ackedAt: row.ackedAt ? row.ackedAt.toISOString() : null,
|
||||
result: row.result,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待执行的命令:未回执、未撤销、未过期 */
|
||||
function pendingCommandWhere(deviceIds: string[], now: Date) {
|
||||
return {
|
||||
deviceId: { in: deviceIds },
|
||||
ackedAt: null,
|
||||
cancelledAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(results: ClientTargetResult[]): ClientPushResult {
|
||||
const succeeded = results.filter((item) => item.ok).length;
|
||||
return { total: results.length, succeeded, failed: results.length - succeeded, results };
|
||||
}
|
||||
|
||||
/** 班级端会话(含班级账号)的代表主体就是那个班;教师/管理员个人电脑不绑定班级 */
|
||||
function resolveClassId(user: TokenPayload): string | null {
|
||||
return user.classSession || user.role === 'CLASS_DEVICE' ? user.sub : null;
|
||||
}
|
||||
|
||||
async function findPendingCommand(deviceId: string, now: Date): Promise<CommandRow | null> {
|
||||
const rows = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere([deviceId], now),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 1,
|
||||
})) as unknown as CommandRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端上报心跳,服务端在同一响应里带回
|
||||
* 「要不要更新 + 目标版本的安装包信息 + 一条待执行命令」。
|
||||
*/
|
||||
export async function heartbeat(
|
||||
user: TokenPayload,
|
||||
input: ClientHeartbeatInput,
|
||||
ip: string,
|
||||
): Promise<ClientHeartbeatResponse> {
|
||||
const now = new Date();
|
||||
const heartbeatAt = {
|
||||
classId: resolveClassId(user),
|
||||
name: input.name?.trim() || '未命名设备',
|
||||
clientVersion: input.clientVersion?.trim() ?? '',
|
||||
platform: input.platform?.trim() ?? '',
|
||||
pendingVersion: input.pendingVersion?.trim() ?? '',
|
||||
stateNote: input.stateNote?.trim() ?? '',
|
||||
lastHeartbeatAt: now,
|
||||
lastIp: ip,
|
||||
};
|
||||
|
||||
const device = (await prisma.clientDevice.upsert({
|
||||
where: { deviceKey: input.deviceKey },
|
||||
create: { deviceKey: input.deviceKey, ...heartbeatAt },
|
||||
update: heartbeatAt,
|
||||
})) as unknown as DeviceRow;
|
||||
|
||||
const targetVersion = device.updateTargetVersion.trim();
|
||||
const [command, update] = await Promise.all([
|
||||
findPendingCommand(device.id, now),
|
||||
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
serverTime: now.toISOString(),
|
||||
updateTargetVersion: targetVersion || null,
|
||||
update,
|
||||
command: command ? toCommandDto(command) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||
export async function ackCommand(
|
||||
commandId: string,
|
||||
input: ClientCommandAckRequest,
|
||||
): Promise<ClientCommandDto> {
|
||||
const command = (await prisma.clientCommand.findUnique({
|
||||
where: { id: commandId },
|
||||
include: { device: { select: { deviceKey: true, name: true } } },
|
||||
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
|
||||
if (!command) throw ApiError.notFound('命令不存在');
|
||||
|
||||
if (command.device.deviceKey !== input.deviceKey) {
|
||||
throw ApiError.forbidden('这条命令不是发给这台电脑的');
|
||||
}
|
||||
// 幂等:客户端重试回执不报错,也不覆盖第一次的结果
|
||||
if (command.ackedAt) return toCommandDto(command);
|
||||
|
||||
const detail = input.result?.trim();
|
||||
const result = `${input.ok ? '已执行' : '执行失败'}${detail ? `:${detail}` : ''}`;
|
||||
const updated = (await prisma.clientCommand.update({
|
||||
where: { id: commandId },
|
||||
data: { ackedAt: new Date(), result },
|
||||
})) as unknown as CommandRow;
|
||||
|
||||
logger.info(`远控命令回执:${command.device.name || command.device.deviceKey} ${result}`);
|
||||
return toCommandDto(updated);
|
||||
}
|
||||
|
||||
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
|
||||
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: options.classId ? { classId: options.classId } : {},
|
||||
include: { class: { select: { id: true, name: true } } },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})) as unknown as DeviceRow[];
|
||||
|
||||
const now = new Date();
|
||||
const commands = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere(
|
||||
rows.map((row) => row.id),
|
||||
now,
|
||||
),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as unknown as CommandRow[];
|
||||
|
||||
// 每台设备只留最早的一条(与心跳取的是同一条,界面上看到的才是真会执行的那条)
|
||||
const pendingByDevice = new Map<string, CommandRow>();
|
||||
for (const command of commands) {
|
||||
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const heartbeat = row.lastHeartbeatAt;
|
||||
const pending = pendingByDevice.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
className: row.class?.name ?? null,
|
||||
deviceKey: row.deviceKey,
|
||||
name: row.name,
|
||||
clientVersion: row.clientVersion,
|
||||
platform: row.platform,
|
||||
autoUpdate: row.autoUpdate,
|
||||
updateTargetVersion: row.updateTargetVersion,
|
||||
pendingVersion: row.pendingVersion,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
lastIp: row.lastIp,
|
||||
stateNote: row.stateNote,
|
||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
|
||||
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
|
||||
const wanted = [...new Set(deviceIds)];
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: wanted } },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
|
||||
*
|
||||
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
|
||||
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。
|
||||
*/
|
||||
export async function createCommands(
|
||||
user: TokenPayload,
|
||||
input: CreateClientCommandInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const targets = await findTargetDevices(input.deviceIds);
|
||||
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
|
||||
const note = input.note?.trim() ?? '';
|
||||
const results: ClientTargetResult[] = [];
|
||||
|
||||
for (const deviceId of [...new Set(input.deviceIds)]) {
|
||||
const device = targets.get(deviceId);
|
||||
if (!device) {
|
||||
results.push({
|
||||
deviceId,
|
||||
deviceName: '',
|
||||
ok: false,
|
||||
code: 'DEVICE_NOT_FOUND',
|
||||
message: '设备不存在(可能已被移除)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.clientCommand.updateMany({
|
||||
where: { deviceId, ackedAt: null, cancelledAt: null },
|
||||
data: { cancelledAt: new Date(), result: '已被新的命令取代' },
|
||||
});
|
||||
await prisma.clientCommand.create({
|
||||
data: { deviceId, kind: input.kind, payload: encodePayload(note), createdBy: user.sub, expiresAt },
|
||||
});
|
||||
results.push({ deviceId, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
`下发远控命令 ${input.kind}:目标 ${summary.total} 台,成功 ${summary.succeeded} 台(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
|
||||
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
const deviceIds = [...new Set(input.deviceIds ?? [])];
|
||||
if (deviceIds.length > 0) {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: deviceIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
const classIds = [...new Set(input.classIds ?? [])];
|
||||
if (classIds.length > 0) {
|
||||
return (await prisma.clientDevice.findMany({
|
||||
where: { classId: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/updates - 统一下发客户端更新
|
||||
*
|
||||
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
|
||||
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
|
||||
* (上课时段 / 窗口可见都不装)。
|
||||
*/
|
||||
export async function pushUpdate(
|
||||
user: TokenPayload,
|
||||
input: PushClientUpdateInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const clear = input.clear === true;
|
||||
let target = '';
|
||||
|
||||
if (!clear) {
|
||||
target = (input.version ?? '').trim() || (await clientReleases.latestReleaseVersion());
|
||||
if (!target) {
|
||||
throw ApiError.badRequest('服务端还没有缓存任何客户端安装包,请先下载或登记一份再推送');
|
||||
}
|
||||
const info = await clientReleases.resolveUpdateInfo(target);
|
||||
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
|
||||
}
|
||||
|
||||
const devices = await resolveUpdateTargets(input);
|
||||
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
|
||||
|
||||
const results: ClientTargetResult[] = [];
|
||||
for (const device of devices) {
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: device.id },
|
||||
data: { updateTargetVersion: target },
|
||||
});
|
||||
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
clear
|
||||
? `取消更新下发:${summary.total} 台设备(发起人=${user.username})`
|
||||
: `推送客户端更新 v${target}:${summary.total} 台设备(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
|
||||
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
|
||||
const device = await prisma.clientDevice.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
});
|
||||
if (!device) throw ApiError.notFound('设备不存在');
|
||||
await prisma.clientDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`移除客户端设备:${device.name || device.deviceKey}`);
|
||||
return { id: deviceId };
|
||||
}
|
||||
Reference in new issue
Block a user