refactor: DevTools → ethend 重命名 + 加入生产环境

- 目录 devtools/ → ethend/
- CLI 脚本 devtools.sh/.bat → ethend.sh/.bat
- 环境变量 DEVTOOLS_PORT → ETHEND_PORT
- docker-compose.yml 新增 ethend 服务(生产部署)
- 同步更新全部文档、注释和配置文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 09:32:36 +08:00
parent 27187997b3
commit 365f5ceb2f
30 changed files with 455 additions and 189 deletions
+198
View File
@@ -0,0 +1,198 @@
/**
* 管理控制台配置
* 定义各服务的启动参数、端口、健康检查等
*/
import { fileURLToPath } from 'url';
import path from 'path';
import fs from 'fs';
import os from 'os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../..');
const isWin = os.platform() === 'win32';
// 读取 backend/.env 文件,将值合并到 process.env(不覆盖已有的环境变量)
// 这样 ethend 启动各服务时能传递用户配置的凭据
function loadEnvFile() {
const envPath = path.join(ROOT, 'backend', '.env');
try {
const content = fs.readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.substring(0, eqIdx).trim();
const val = trimmed.substring(eqIdx + 1).trim();
if (key && val !== undefined) {
process.env[key] = process.env[key] || val;
}
}
} catch {
// .env 文件不存在,使用默认值
}
}
loadEnvFile();
/** 跨平台 Go 二进制路径 */
function findGoBin() {
// 优先使用环境变量
if (process.env.GOROOT) return path.join(process.env.GOROOT, 'bin', 'go');
// Windows 常见路径
const candidates = isWin
? ['C:\\Program Files\\Go\\bin\\go.exe', 'C:\\Go\\bin\\go.exe', 'go']
: ['/usr/local/go/bin/go', '/usr/bin/go', 'go'];
for (const p of candidates) {
if (p === 'go' || fs.existsSync(p)) return p;
}
return 'go';
}
const GO_BIN = findGoBin();
export const ETHEND_PORT = process.env.ETHEND_PORT || 9090;
export const LOGS_DIR = path.resolve(__dirname, '../logs');
export const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8080';
export const PLUGIN_MANAGER_URL = process.env.PLUGIN_MANAGER_URL || 'http://localhost:8094';
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'cyrene-dev-admin';
export const SERVICES = {
'ai-core': {
name: 'AI-Core',
cwd: path.join(ROOT, 'backend/ai-core'),
command: './main',
env: {
AI_CORE_PORT: '8081',
PERSONA_DIR: './internal/persona',
SEARXNG_URL: process.env.SEARXNG_URL || 'http://localhost:8088',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
ENABLE_BACKGROUND_THINKING: process.env.ENABLE_BACKGROUND_THINKING || 'true',
},
healthUrl: 'http://localhost:8081/api/v1/health',
port: 8081,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'iot-debug-service': {
name: 'IoT Debug',
cwd: path.join(ROOT, 'backend/iot-debug-service'),
command: './main',
env: {
IOT_DEBUG_PORT: '8083',
},
healthUrl: 'http://localhost:8083/api/v1/health',
port: 8083,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
gateway: {
name: 'Gateway',
cwd: path.join(ROOT, 'backend/gateway'),
command: './main',
env: {
GATEWAY_PORT: '8080',
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
AI_CORE_URL: 'http://localhost:8081',
MEMORY_SERVICE_URL: process.env.MEMORY_SERVICE_URL || 'http://localhost:8091',
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'true',
},
healthUrl: 'http://localhost:8080/api/v1/health',
port: 8080,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'memory-service': {
name: '记忆服务',
cwd: path.join(ROOT, 'backend/memory-service'),
command: './main',
env: {
PORT: '8091',
DB_URL: process.env.DB_URL || 'postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable',
},
healthUrl: 'http://localhost:8091/api/v1/health',
port: 8091,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'voice-service': {
name: '语音识别服务',
cwd: path.join(ROOT, 'backend/voice-service'),
command: './main',
env: {
PORT: '8093',
WHISPER_BINARY: './whisper.cpp/main',
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
WHISPER_LANGUAGE: 'zh',
DASHSCOPE_API_KEY: process.env.DASHSCOPE_API_KEY || '',
DASHSCOPE_STT_MODEL: 'gummy-chat-v1',
},
healthUrl: 'http://localhost:8093/api/v1/health',
port: 8093,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'plugin-manager': {
name: '插件管理器',
cwd: path.join(ROOT, 'backend/plugin-manager'),
command: './main',
env: {
PORT: '8094',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
},
healthUrl: 'http://localhost:8094/api/v1/health',
port: 8094,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'platform-bridge': {
name: '多平台桥接',
cwd: path.join(ROOT, 'backend/platform-bridge'),
command: './main',
env: {
PORT: '8095',
AI_CORE_URL: 'http://localhost:8081',
QQ_BOT_PORT: process.env.QQ_BOT_PORT || '8096',
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
TELEGRAM_WEBHOOK_URL: process.env.TELEGRAM_WEBHOOK_URL || '',
QQ_ADMIN_UID: process.env.QQ_ADMIN_UID || '',
TELEGRAM_ADMIN_UID: process.env.TELEGRAM_ADMIN_UID || '',
},
healthUrl: 'http://localhost:8095/health',
port: 8095,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
frontend: {
name: 'Frontend',
cwd: path.join(ROOT, 'frontend/web'),
command: 'npx',
args: ['vite', '--host', '0.0.0.0'],
env: {
PATH: process.env.PATH,
},
healthUrl: 'http://localhost:5173',
port: 5173,
nodeBin: 'node',
npmBin: 'npx',
// frontend不需要预编译,dev server即可
buildCommand: null,
},
};
/** 各服务默认的日志文件路径 */
export function logFile(serviceId) {
return path.join(LOGS_DIR, `${serviceId}.log`);
}
+1752
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
/**
* 性能监控模块
* 监控各服务进程的 CPU、内存使用情况
*/
import pidusage from 'pidusage';
import { processManager } from './process-manager.js';
import { SERVICES } from './config.js';
class PerformanceMonitor {
constructor() {
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
this.history = new Map();
this.interval = null;
// Ring buffer for actual HTTP request latencies (ms)
this.latencyBuffer = [];
this.maxLatencySamples = 500;
for (const id of Object.keys(SERVICES)) {
this.history.set(id, []);
}
}
/** Record an HTTP request duration (ms). Called by middleware. */
recordLatency(durationMs) {
this.latencyBuffer.push(durationMs);
if (this.latencyBuffer.length > this.maxLatencySamples) {
this.latencyBuffer.splice(0, this.latencyBuffer.length - this.maxLatencySamples);
}
}
/** Get average request latency from recent samples. Returns null if no data. */
getAverageLatency() {
if (this.latencyBuffer.length === 0) return null;
const sum = this.latencyBuffer.reduce((a, b) => a + b, 0);
return Math.round(sum / this.latencyBuffer.length);
}
/**
* 开始定期采样 (每3秒)
*/
start() {
if (this.interval) return;
this.interval = setInterval(() => this.sample(), 3000);
this.interval.unref(); // 不阻止进程退出
}
/**
* 停止采样
*/
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
/**
* 采样一次
*/
async sample() {
for (const [id, info] of processManager.processes) {
if (!info.pid) continue;
try {
const stats = await pidusage(info.pid);
const history = this.history.get(id);
history.push({
ts: Date.now(),
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
});
// 保留最近300条 (约15分钟)
if (history.length > 300) {
history.splice(0, history.length - 300);
}
} catch {
// 进程可能已退出
}
}
}
/**
* 获取当前性能快照
*/
async getSnapshot() {
const result = {};
for (const [id, info] of processManager.processes) {
if (!info.pid) {
result[id] = { pid: null, cpu: 0, mem: 0 };
continue;
}
try {
const stats = await pidusage(info.pid);
result[id] = {
pid: info.pid,
cpu: Math.round(stats.cpu * 100) / 100,
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
elapsed: stats.elapsed,
};
} catch {
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
}
}
return result;
}
/**
* 获取历史数据
*/
getHistory(serviceId) {
return this.history.get(serviceId) || [];
}
/**
* 获取所有服务的历史数据
*/
getAllHistory() {
const result = {};
for (const id of this.history.keys()) {
result[id] = this.history.get(id);
}
return result;
}
/**
* 更新仪表盘数据 — 返回聚合的性能摘要供首页仪表盘使用
* 调用方负责将数据渲染到 #performance-dashboard 元素
* @returns {object} 仪表盘性能摘要
*/
async updateDashboard() {
const snapshot = await this.getSnapshot();
const entries = Object.entries(snapshot);
let totalCpu = 0, totalMem = 0, activeCount = 0;
for (const [, p] of entries) {
totalCpu += p.cpu || 0;
totalMem += p.mem || 0;
if (p.pid) activeCount++;
}
const avgCpu = entries.length > 0 ? Math.round(totalCpu / entries.length * 10) / 10 : 0;
const totalMemRounded = Math.round(totalMem * 100) / 100;
// 计算平均请求延迟 (基于实际 HTTP 请求耗时,非进程 uptime)
const avgLatencyMs = this.getAverageLatency();
// 获取最近历史用于趋势判断
const recentHistory = this.getAllHistory();
let trendCpu = 'stable', trendMem = 'stable';
for (const [, hist] of Object.entries(recentHistory)) {
if (hist.length < 5) continue;
const recent = hist.slice(-5);
const firstCpu = recent[0].cpu, lastCpu = recent[recent.length - 1].cpu;
const firstMem = recent[0].mem, lastMem = recent[recent.length - 1].mem;
if (lastCpu > firstCpu * 1.15) trendCpu = 'up';
else if (lastCpu < firstCpu * 0.85) trendCpu = 'down';
if (lastMem > firstMem * 1.15) trendMem = 'up';
else if (lastMem < firstMem * 0.85) trendMem = 'down';
}
return {
timestamp: Date.now(),
summary: {
avgCpu,
totalMemMB: totalMemRounded,
activeProcesses: activeCount,
monitoredServices: entries.length,
avgLatencyMs,
trend: { cpu: trendCpu, mem: trendMem },
},
perService: snapshot,
};
}
}
export const performanceMonitor = new PerformanceMonitor();
+631
View File
@@ -0,0 +1,631 @@
/**
* 进程管理器
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
*/
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
import fs from 'fs';
import net from 'net';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { SERVICES, logFile } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../..');
const DB_PORTS = [5432, 5433, 5434];
const DB_COMPOSE_FILE = path.join(ROOT, 'docker-compose.dev.db.yml');
const isWin = os.platform() === 'win32';
// ---- Docker 检测缓存 ----
let _dockerCache = null;
let _dockerCacheTime = 0;
const DOCKER_CACHE_TTL = 5000; // 5秒缓存,避免每次 status 轮询都执行 docker ps
/**
* 检测哪些服务运行在 Docker 容器中
* 通过 docker ps 获取所有容器端口映射,与 SERVICES 端口匹配
* @returns {Map<string, {containerName: string, containerId: string, status: string}>}
*/
function detectDockerServices() {
const now = Date.now();
if (_dockerCache && (now - _dockerCacheTime) < DOCKER_CACHE_TTL) {
return _dockerCache;
}
const result = new Map();
try {
const out = execSync('docker ps --format "{{.ID}}\\t{{.Names}}\\t{{.Ports}}\\t{{.Status}}"', {
timeout: 5000,
stdio: 'pipe',
}).toString().trim();
if (!out) return result;
for (const line of out.split('\n')) {
const [containerId, containerName, ports, status] = line.split('\t');
if (!ports) continue;
// 解析端口映射: "0.0.0.0:8080->8080/tcp, :::8080->8080/tcp"
const hostPorts = new Set();
for (const m of ports.matchAll(/:(\d+)->/g)) {
hostPorts.add(parseInt(m[1]));
}
// 匹配 SERVICES 中定义的端口
for (const [svcId, svc] of Object.entries(SERVICES)) {
if (svc.port && hostPorts.has(svc.port)) {
result.set(svcId, {
containerName,
containerId: containerId.substring(0, 12),
status,
});
}
}
}
} catch {
// Docker 不可用,返回空 Map
}
_dockerCache = result;
_dockerCacheTime = now;
return result;
}
/** 清除 Docker 缓存(用于手动刷新状态) */
export function clearDockerCache() {
_dockerCache = null;
_dockerCacheTime = 0;
}
/**
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
*/
function releasePort(port) {
return new Promise((resolve) => {
const sock = new net.Socket();
sock.setTimeout(1000);
sock.on('connect', () => {
sock.destroy();
// 端口被占用,尝试释放
try {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
} catch { /* ignore */ }
setTimeout(resolve, 500);
});
sock.on('error', () => {
sock.destroy();
resolve(); // 端口空闲
});
sock.on('timeout', () => {
sock.destroy();
resolve();
});
sock.connect(port, '127.0.0.1');
});
}
/**
* 检查端口是否可连接 (TCP connect, 超时2秒)
*/
function isPortOpen(port) {
return new Promise((resolve) => {
const sock = new net.Socket();
sock.setTimeout(2000);
sock.on('connect', () => { sock.destroy(); resolve(true); });
sock.on('error', () => { sock.destroy(); resolve(false); });
sock.on('timeout', () => { sock.destroy(); resolve(false); });
sock.connect(port, '127.0.0.1');
});
}
/**
* 确保数据库在线
* 检查 DB_PORTS 中至少有一个端口可用,若不可用则尝试 docker compose up
* 等待最多 30 秒检查数据库就绪
* @param {string} serviceId - 正在启动的服务 ID
* @param {EventEmitter} emitter - 用于发送日志事件
*/
async function ensureDBOnline(serviceId, emitter) {
// 1. 快速检查:任意数据库端口是否已在线
for (const port of DB_PORTS) {
if (await isPortOpen(port)) {
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已在线`);
return;
}
}
// 2. 数据库不在线,尝试 docker compose up
emitter.emit('log', serviceId, 'system', '数据库未启动,正在通过 Docker Compose 启动...');
try {
execSync(`docker compose -f "${DB_COMPOSE_FILE}" up -d`, {
timeout: 60000,
stdio: 'pipe',
});
emitter.emit('log', serviceId, 'system', 'Docker Compose 启动命令已执行,等待数据库就绪...');
} catch (err) {
const stderr = err.stderr?.toString() || err.message;
emitter.emit('log', serviceId, 'error', `Docker Compose 启动失败: ${stderr}`);
}
// 3. 等待最多 30 秒检查数据库就绪
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
for (const port of DB_PORTS) {
if (await isPortOpen(port)) {
emitter.emit('log', serviceId, 'system', `数据库端口 ${port} 已就绪 (等待 ${i + 1}s)`);
return;
}
}
}
// 4. 30 秒后仍不可用
emitter.emit('log', serviceId, 'error', '⚠️ 数据库无法启动,请手动检查 Docker。将继续启动后端服务...');
}
class ProcessManager extends EventEmitter {
constructor() {
super();
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
this.processes = new Map();
for (const id of Object.keys(SERVICES)) {
this.processes.set(id, {
process: null,
status: 'stopped',
startTime: null,
pid: null,
buildLog: [],
});
}
}
/**
* 启动服务
*/
async start(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
// Docker 管理的服务拒绝本地操作
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
}
const procInfo = this.processes.get(serviceId);
if (procInfo.process) {
throw new Error(`${svc.name} 已在运行中`);
}
// 对需要数据库的服务做前置检查
if (['gateway', 'ai-core', 'memory-service', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
await ensureDBOnline(serviceId, this);
}
// 启动前释放端口,避免 "address already in use"
if (svc.port) {
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
await releasePort(svc.port);
}
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
procInfo.status = 'starting';
procInfo.buildLog = [];
// 确保日志目录存在
const logPath = logFile(serviceId);
const logDir = path.dirname(logPath);
fs.mkdirSync(logDir, { recursive: true });
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
// 确定二进制路径或命令
let command, args;
if (svc.command === './main') {
command = isWin ? './main.exe' : './main';
args = svc.args || [];
} else if (svc.command === 'npx') {
command = isWin ? 'npx.cmd' : (svc.npmBin || 'npx');
args = svc.args || [];
} else {
command = svc.command;
args = svc.args || [];
}
// 对使用 npm 的服务,检查 node_modules 是否完整
if (svc.command === 'npx' || svc.command === 'node') {
const modulesDir = path.join(svc.cwd, 'node_modules');
const installMarker = path.join(modulesDir, '.package-lock.json');
if (!fs.existsSync(installMarker)) {
// 如果 node_modules 目录存在但不完整,先删除
if (fs.existsSync(modulesDir)) {
this.emit('log', serviceId, 'system', 'node_modules 不完整,正在清理...');
fs.rmSync(modulesDir, { recursive: true, force: true });
}
this.emit('log', serviceId, 'system', '正在运行 npm install...');
// Windows: npm.cmd batch file has module resolution issues when
// cwd has a node_modules directory, even if empty. Use node+npm-cli.js directly.
const installCmd = isWin
? `"${process.execPath}" "${path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')}" install`
: 'npm install';
try {
execSync(installCmd, { cwd: svc.cwd, timeout: 120000, stdio: 'pipe' });
this.emit('log', serviceId, 'system', 'npm install 完成');
} catch (err) {
const stderr = err.stderr?.toString() || err.message;
this.emit('log', serviceId, 'error', `npm install 失败: ${stderr}`);
throw new Error(`npm install 失败: ${stderr}`);
}
}
}
const env = { ...process.env, ...svc.env };
// .cmd/.bat on Windows needs shell:true
const needsShell = isWin && (command.endsWith('.cmd') || command.endsWith('.bat'));
const child = spawn(command, args, {
cwd: svc.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: needsShell,
});
child.stdout.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stdout', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
logStream.write(text);
this.emit('log', serviceId, 'stderr', text);
});
// spawn() 返回后进程已启动,立即记录 PID 和状态
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
procInfo.pid = child.pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = child;
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
child.on('error', (err) => {
const msg = `进程错误: ${err.message}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'error', msg);
procInfo.status = 'error';
procInfo.process = null;
procInfo.pid = null;
});
child.on('close', (code) => {
const msg = `进程退出,退出码: ${code}`;
logStream.write(msg + '\n');
this.emit('log', serviceId, 'system', msg);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
logStream.end();
});
return { success: true, message: `${svc.name} 启动中...` };
}
/**
* 停止服务
*/
async stop(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请使用 docker compose 控制` };
}
const procInfo = this.processes.get(serviceId);
if (!procInfo.process) {
// 可能已经崩溃了,重置状态
procInfo.status = 'stopped';
return { success: true, message: `${svc.name} 未在运行` };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
// 强制杀死
if (procInfo.process) {
procInfo.process.kill('SIGKILL');
}
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已强制停止` });
}, 5000);
procInfo.process.on('close', () => {
clearTimeout(timeout);
procInfo.status = 'stopped';
procInfo.process = null;
procInfo.pid = null;
resolve({ success: true, message: `${svc.name} 已停止` });
});
procInfo.process.kill('SIGTERM');
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
});
}
/**
* 重启服务
*/
async restart(serviceId) {
if (detectDockerServices().has(serviceId)) {
const svc = SERVICES[serviceId];
return { success: false, error: 'docker_managed', message: `${svc?.name || serviceId} 由 Docker 管理` };
}
await this.stop(serviceId);
await new Promise((r) => setTimeout(r, 1000));
return this.start(serviceId);
}
/**
* 构建服务 (Go服务需要预编译)
*/
async build(serviceId) {
const svc = SERVICES[serviceId];
if (!svc) throw new Error(`未知服务: ${serviceId}`);
if (detectDockerServices().has(serviceId)) {
return { success: false, error: 'docker_managed', message: `${svc.name} 由 Docker 管理,请在容器内构建或重建镜像` };
}
if (!svc.buildCommand) {
return { success: false, message: `${svc.name} 不需要预编译` };
}
const procInfo = this.processes.get(serviceId);
procInfo.status = 'building';
procInfo.buildLog = [];
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
return new Promise((resolve) => {
const buildCmd = svc.goBin || svc.buildCommand;
const buildArgs = svc.buildArgs || [];
const child = spawn(buildCmd, buildArgs, {
cwd: svc.cwd,
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct', GOWORK: 'off' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
procInfo.status = 'stopped';
procInfo.buildLog = [
...stdout.split('\n').filter(Boolean),
...stderr.split('\n').filter(Boolean),
];
if (code === 0) {
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
resolve({ success: true, message: `${svc.name} 编译成功` });
} else {
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
}
});
child.on('error', (err) => {
procInfo.status = 'stopped';
resolve({ success: false, message: `编译错误: ${err.message}` });
});
});
}
/**
* 获取所有服务状态
*/
getStatus() {
const dockerSvcs = detectDockerServices();
const result = {};
for (const [id, info] of this.processes) {
const svc = SERVICES[id];
const docker = dockerSvcs.get(id);
let source = 'none';
if (docker) {
source = 'docker';
} else if (info.status === 'running' || info.status === 'starting') {
source = 'local';
}
result[id] = {
name: svc.name,
status: docker ? 'running' : info.status, // Docker 容器总是 running
pid: docker ? null : info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
source,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
return result;
}
/**
* 获取单个服务状态
*/
getServiceStatus(serviceId) {
const info = this.processes.get(serviceId);
if (!info) return null;
const svc = SERVICES[serviceId];
const dockerSvcs = detectDockerServices();
const docker = dockerSvcs.get(serviceId);
let source = 'none';
if (docker) {
source = 'docker';
} else if (info.status === 'running' || info.status === 'starting') {
source = 'local';
}
return {
name: svc.name,
status: docker ? 'running' : info.status,
pid: docker ? null : info.pid,
startTime: info.startTime,
uptime: info.startTime ? Date.now() - info.startTime : 0,
port: svc.port,
healthUrl: svc.healthUrl,
source,
...(docker ? { containerName: docker.containerName, containerId: docker.containerId } : {}),
};
}
/**
* 停止所有服务
*/
async stopAll() {
const results = [];
const dockerSvcs = detectDockerServices();
for (const id of Object.keys(SERVICES)) {
if (dockerSvcs.has(id)) {
const svc = SERVICES[id];
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理,跳过停止`, docker: true });
continue;
}
try {
const r = await this.stop(id);
results.push({ id, ...r });
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
/**
* 尝试接管已运行的服务 (通过健康检查端点)
* 如果服务已在运行,直接标记为 running 而不是杀死重启
*/
async tryAdopt(serviceId) {
const svc = SERVICES[serviceId];
if (!svc || !svc.healthUrl) return false;
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const procInfo = this.processes.get(serviceId);
// 尝试通过 fuser 获取 PID
let pid = null;
try {
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
const match = out.match(/(\d+)/);
if (match) pid = parseInt(match[1]);
} catch { /* ignore */ }
procInfo.pid = pid;
procInfo.startTime = Date.now();
procInfo.status = 'running';
procInfo.process = null; // 不是我们的子进程,但标记为已接管
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
return true;
}
} catch { /* 未运行或不可达 */ }
return false;
}
/**
* 检查服务是否需要编译 (Go 服务且二进制不存在)
*/
needsBuild(serviceId) {
const svc = SERVICES[serviceId];
if (!svc || !svc.buildCommand) return false;
const binaryPath = path.join(svc.cwd, 'main');
const exePath = binaryPath + '.exe';
return !fs.existsSync(binaryPath) && !fs.existsSync(exePath);
}
/**
* 按顺序启动所有服务 (memory → iot → voice → ai-core → gateway → frontend)
* 每步等待健康检查通过后再启动下一个
*/
async startAllSequential() {
const order = ['memory-service', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
const results = [];
const dockerSvcs = detectDockerServices();
for (const id of order) {
const svc = SERVICES[id];
// Docker 管理的服务:跳过
if (dockerSvcs.has(id)) {
const d = dockerSvcs.get(id);
this.emit('log', id, 'system', `${svc.name} 由 Docker 容器 ${d.containerName} 管理,跳过本地启动`);
results.push({ id, success: true, message: `${svc.name} 由 Docker 管理 (${d.containerName}),已跳过`, docker: true });
continue;
}
// 先尝试接管已运行的服务
const adopted = await this.tryAdopt(id);
if (adopted) {
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
continue;
}
// 编译检查: 如果 Go 服务二进制不存在,先编译
if (this.needsBuild(id)) {
this.emit('log', id, 'system', `未找到编译产物,正在编译 ${svc.name}...`);
const buildResult = await this.build(id);
if (!buildResult.success) {
const errMsg = `编译失败: ${buildResult.message}`;
this.emit('log', id, 'error', errMsg);
results.push({ id, success: false, message: errMsg });
continue;
}
this.emit('log', id, 'system', `${svc.name} 编译完成`);
}
// 启动服务
try {
const r = await this.start(id);
results.push({ id, ...r });
// 等待健康检查通过
if (svc.healthUrl) {
let healthy = false;
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000));
try {
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
if (resp.ok) { healthy = true; break; }
} catch { /* continue waiting */ }
}
if (!healthy) {
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
} else {
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
// Gateway 和 AI-Core 启动后额外等待 2 秒,确保内部路由和 Handler 完全初始化
if (id === 'gateway' || id === 'ai-core') {
await new Promise((r) => setTimeout(r, 2000));
this.emit('log', id, 'system', `${svc.name} 已就绪 (额外等待 2s 确保服务稳定)`);
}
}
}
} catch (err) {
results.push({ id, success: false, message: err.message });
}
}
return results;
}
}
export const processManager = new ProcessManager();