背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。
客户端:执行失败会谎报成功
- `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**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
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 };
|
||
}
|