Files
ClassHelper/packages/server/src/modules/clients/clients.service.ts
T
LoveChengke b7cdc7c9ea fix(clients,client,web): 远控指令的两个修复 —— 执行失败不再谎报成功 + 「上次指令」让"点了没反应"可见(1.3.2)
背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。

客户端:执行失败会谎报成功
- `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在下一个 tick 发一个异步 `error` 事件 ——
  只包 `try/catch` 抓不到,代码一路走到 `return { ok: true }`,回执写着「已下达系统命令」,
  Web 端看起来一切正常而机器纹丝不动
- 现在等一个 200ms 的窗口确认没有立刻报错,失败如实回执(`client-agent.ts` 的 `executeCommand`)

Web 端:点了没生效,界面上什么都不说
- 一条命令被**取消 / 过期**之后就从 `pendingCommand` 里消失,页面留白 ——
  而最常见的两种"没反应"恰恰是「本机没人确认(客户端 60 秒自动按取消)」与「下达时机器离线」
- `GET /clients/devices` 增加 `lastCommand`(最近一条,不论状态);
  「客户端」页的设备表多一列「上次指令」,由 `commandStateText()` 翻成一句人话
  (已执行 / 本机用户取消 / 已过期 —— 下达时机器不在线 / 待执行)
- 下发成功的提示补上「60 秒内没人点确认就按取消处理」

文档
- AGENTS.md §5 第 69 条记下这两条,并写明排查同类反馈的顺序:**先看那台机器的客户端版本是否 ≥ 1.3.0**
  (远控命令的处理 `client-agent.ts` 是 1.3.0 才有的,旧客户端连心跳都不发)
- docs/reference.md 4.15 的表格补上「二次确认」的准确口径与新增的「上次指令」列

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**、客户端冒烟 **90/90**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
2026-10-11 17:00:11 +08:00

387 lines
14 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 {
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);
}
// 「最近一条命令」:待执行的那条在取消 / 超时之后就从 pendingCommand 里消失了,
// Web 端于是看不出"我刚点的锁屏到底怎么了"。取最近 500 条(够覆盖一次批量下发)按设备分组。
const recent = (await prisma.clientCommand.findMany({
where: { deviceId: { in: rows.map((row) => row.id) } },
orderBy: { createdAt: 'desc' },
take: 500,
})) as unknown as CommandRow[];
const lastByDevice = new Map<string, CommandRow>();
for (const command of recent) {
if (!lastByDevice.has(command.deviceId)) lastByDevice.set(command.deviceId, command);
}
return rows.map((row) => {
const heartbeat = row.lastHeartbeatAt;
const pending = pendingByDevice.get(row.id);
const last = lastByDevice.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,
lastCommand: last ? toCommandDto(last) : 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 };
}