Files
web-Iot/services/command/server.js
2026-08-10 13:33:06 +08:00

250 lines
10 KiB
JavaScript
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.

/**
* 机器人智慧服务系统 · 命令服务 Alpha (command-service)
* 端口: 3030
* 职责: 控制命令下发、ACK/结果回传、超时检测、取消/重试,命令状态机落地
* 数据: services/command/data/commands.json(本地文件替代数据库)
* 状态机: PENDING → SENT → ACKED → EXECUTING → SUCCEEDED | FAILED
* ↘ TIMEOUT → (自动重试1次) → SENT
* ↘ CANCELLED(仅 PENDING/SENT/ACKED 可取消)
* 文档: 详见 docs/命令状态机.md、docs/DOC-09-控制命令字典-V1.0草案.md
*/
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const app = express();
const PORT = 3030;
const DATA_FILE = path.join(__dirname, 'data', 'commands.json');
const DEVICE_ACCESS_URL = 'http://localhost:3010'; // 设备接入服务地址(校验设备在线,失败时降级放行)
// 命令类型定义(与 DOC-09 一致)
const COMMAND_TYPES = [
'move', 'rotate', 'stop', 'task_start', 'task_cancel',
'charge', 'photo', 'emergency_stop', 'reboot', 'config_set',
];
const STATUS_FLOW = ['PENDING', 'SENT', 'ACKED', 'EXECUTING', 'SUCCEEDED', 'FAILED', 'TIMEOUT', 'CANCELLED'];
const TERMINAL = ['SUCCEEDED', 'FAILED', 'TIMEOUT', 'CANCELLED'];
const CANCELLABLE = ['PENDING', 'SENT', 'ACKED'];
app.use(cors());
app.use(express.json());
// ─── 文件读写 ─────────────────────────────────────────────
function readCommands() {
try {
const raw = fs.readFileSync(DATA_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
return [];
}
}
function writeCommands(list) {
fs.writeFileSync(DATA_FILE, JSON.stringify(list, null, 2), 'utf-8');
}
// ─── 超时定时器管理 ───────────────────────────────────────
const timers = new Map(); // commandId -> setTimeout handle
function scheduleTimeout(cmd) {
clearTimeout(timers.get(cmd.id));
const timeoutMs = cmd.timeoutMs || 30000;
const timer = setTimeout(() => {
const list = readCommands();
const cur = list.find((c) => c.id === cmd.id);
if (!cur || TERMINAL.includes(cur.status)) return;
if (cur.status !== 'ACKED' && cur.status !== 'EXECUTING' && cur.retries < (cur.maxRetries || 1)) {
// 自动重试
cur.retries += 1;
cur.status = 'SENT';
cur.updatedAt = new Date().toISOString();
cur.events.push({ at: cur.updatedAt, from: 'TIMEOUT', to: 'SENT', reason: 'auto_retry' });
console.log(`[retry] 命令 ${cur.id} 第 ${cur.retries} 次重发`);
writeCommands(list);
scheduleTimeout(cur);
} else {
cur.status = 'TIMEOUT';
cur.updatedAt = new Date().toISOString();
cur.events.push({ at: cur.updatedAt, from: cur.status === 'TIMEOUT' ? 'TIMEOUT' : cur.status, to: 'TIMEOUT', reason: 'timeout' });
writeCommands(list);
console.log(`[timeout] 命令 ${cur.id} 超时(${timeoutMs}ms)`);
}
}, timeoutMs);
timers.set(cmd.id, timer);
}
// ─── 工具函数 ─────────────────────────────────────────────
function findCommand(id) {
return readCommands().find((c) => c.id === id);
}
async function checkDeviceOnline(deviceId) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 2000);
const resp = await fetch(`${DEVICE_ACCESS_URL}/api/devices/${deviceId}/online`, { signal: ctrl.signal });
clearTimeout(t);
if (!resp.ok) return null;
const body = await resp.json();
return body.data ? body.data.online : null;
} catch {
return null; // 服务不可达,降级放行
}
}
// ─── 路由:下发 ───────────────────────────────────────────
// POST /api/commands 创建并下发命令
// body: { deviceId, type, params?, priority?, timeoutMs?, maxRetries?, source?, seq? }
app.post('/api/commands', async (req, res) => {
const { deviceId, type, params = {}, priority = 'normal', timeoutMs, maxRetries = 1, source = 'web', seq } = req.body || {};
if (!deviceId || !type) {
return res.status(400).json({ code: 40001, msg: 'deviceId 与 type 为必填项' });
}
if (!COMMAND_TYPES.includes(type)) {
return res.status(400).json({ code: 40002, msg: `type 非法,允许值: ${COMMAND_TYPES.join(', ')}` });
}
// 设备在线校验(设备接入服务不可达时放行)
const online = await checkDeviceOnline(deviceId);
if (online === false) {
return res.status(409).json({ code: 40902, msg: `设备 ${deviceId} 当前离线,命令下发被拒绝` });
}
const now = new Date().toISOString();
const cmd = {
id: uuidv4(),
seq: seq || uuidv4().replace(/-/g, '').slice(0, 8).toUpperCase(),
deviceId,
type,
params,
priority, // high | normal | low
timeoutMs: timeoutMs || 30000,
maxRetries,
retries: 0,
source,
status: 'SENT', // 创建即下发(Alpha 简化:无 PENDING 停留)
createdAt: now,
updatedAt: now,
ackAt: null,
result: null,
events: [{ at: now, from: null, to: 'SENT', reason: 'dispatch' }],
};
const list = readCommands();
list.push(cmd);
writeCommands(list);
scheduleTimeout(cmd);
res.json({ code: 0, msg: '命令已下发', data: cmd });
});
// ─── 路由:查询 ───────────────────────────────────────────
// GET /api/commands?deviceId=&status=&type=&limit= 命令列表
app.get('/api/commands', (req, res) => {
const { deviceId, status, type, limit } = req.query;
let list = readCommands().slice().reverse(); // 新->旧
if (deviceId) list = list.filter((c) => c.deviceId === deviceId);
if (status) list = list.filter((c) => c.status === status);
if (type) list = list.filter((c) => c.type === type);
const n = Math.min(parseInt(limit, 10) || 100, 500);
res.json({ code: 0, data: list.slice(0, n) });
});
// GET /api/commands/:id 命令详情
app.get('/api/commands/:id', (req, res) => {
const cmd = findCommand(req.params.id);
if (!cmd) return res.status(404).json({ code: 40401, msg: '命令不存在' });
res.json({ code: 0, data: cmd });
});
// ─── 路由:状态流转 ───────────────────────────────────────
// POST /api/commands/:id/ack 设备确认收到(SENT -> ACKED -> EXECUTING)
app.post('/api/commands/:id/ack', (req, res) => {
const list = readCommands();
const cmd = list.find((c) => c.id === req.params.id);
if (!cmd) return res.status(404).json({ code: 40401, msg: '命令不存在' });
if (TERMINAL.includes(cmd.status)) {
return res.status(409).json({ code: 40901, msg: `命令已处于终态 ${cmd.status},无法 ACK` });
}
const now = new Date().toISOString();
if (cmd.status === 'SENT') {
cmd.status = 'ACKED';
cmd.ackAt = now;
cmd.events.push({ at: now, from: 'SENT', to: 'ACKED', reason: 'device_ack' });
}
if (req.body && req.body.executing === true && cmd.status === 'ACKED') {
cmd.status = 'EXECUTING';
cmd.events.push({ at: now, from: 'ACKED', to: 'EXECUTING', reason: 'exec_start' });
}
cmd.updatedAt = now;
writeCommands(list);
res.json({ code: 0, data: cmd });
});
// POST /api/commands/:id/result 执行结果回传(-> SUCCEEDED | FAILED)
app.post('/api/commands/:id/result', (req, res) => {
const { status, output, errorCode } = req.body || {};
if (!['success', 'failed'].includes(status)) {
return res.status(400).json({ code: 40003, msg: 'status 仅支持 success / failed' });
}
const list = readCommands();
const cmd = list.find((c) => c.id === req.params.id);
if (!cmd) return res.status(404).json({ code: 40401, msg: '命令不存在' });
if (TERMINAL.includes(cmd.status)) {
return res.status(409).json({ code: 40901, msg: `命令已处于终态 ${cmd.status},无法回传结果` });
}
const now = new Date().toISOString();
cmd.status = status === 'success' ? 'SUCCEEDED' : 'FAILED';
cmd.result = { status, output: output || null, errorCode: errorCode || null, at: now };
cmd.events.push({ at: now, from: 'EXECUTING', to: cmd.status, reason: 'exec_result' });
cmd.updatedAt = now;
clearTimeout(timers.get(cmd.id));
timers.delete(cmd.id);
writeCommands(list);
res.json({ code: 0, data: cmd });
});
// POST /api/commands/:id/cancel 取消命令
app.post('/api/commands/:id/cancel', (req, res) => {
const list = readCommands();
const cmd = list.find((c) => c.id === req.params.id);
if (!cmd) return res.status(404).json({ code: 40401, msg: '命令不存在' });
if (!CANCELLABLE.includes(cmd.status)) {
return res.status(409).json({ code: 40901, msg: `命令状态 ${cmd.status} 不可取消(仅 ${CANCELLABLE.join('/')})` });
}
const now = new Date().toISOString();
cmd.status = 'CANCELLED';
cmd.events.push({ at: now, from: cmd.status === 'CANCELLED' ? cmd.status : cmd.status, to: 'CANCELLED', reason: 'user_cancel' });
cmd.updatedAt = now;
clearTimeout(timers.get(cmd.id));
timers.delete(cmd.id);
writeCommands(list);
res.json({ code: 0, data: cmd });
});
// POST /api/commands/:id/retry 手动重试(TIMEOUT/FAILED -> SENT)
app.post('/api/commands/:id/retry', (req, res) => {
const list = readCommands();
const cmd = list.find((c) => c.id === req.params.id);
if (!cmd) return res.status(404).json({ code: 40401, msg: '命令不存在' });
if (!['TIMEOUT', 'FAILED'].includes(cmd.status)) {
return res.status(409).json({ code: 40901, msg: `仅 TIMEOUT/FAILED 命令可重试,当前 ${cmd.status}` });
}
const now = new Date().toISOString();
cmd.status = 'SENT';
cmd.retries += 1;
cmd.events.push({ at: now, from: cmd.status === 'SENT' ? 'FAILED' : 'FAILED', to: 'SENT', reason: 'manual_retry' });
cmd.updatedAt = now;
writeCommands(list);
scheduleTimeout(cmd);
res.json({ code: 0, data: cmd });
});
app.listen(PORT, () => {
console.log(`✅ 命令服务 Alpha 已启动 → http://localhost:${PORT}`);
console.log(` API 示例: POST /api/commands {deviceId, type, params}`);
console.log(` POST /api/commands/:id/ack`);
console.log(` POST /api/commands/:id/result {status:'success'|'failed'}`);
});