250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
/**
|
||
* 机器人智慧服务系统 · 命令服务 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'}`);
|
||
});
|