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