'use strict'; // 智慧运营中台 · 引导入口 // 职责仅剩:装配共享状态(server/context.js)→ 挂载路由(server/routes/)→ 监听端口。 // 端口被旧实例占用时自动回收;业务逻辑全部在 server/ 目录下。 const http = require('http'); const path = require('path'); const { execFile } = require('child_process'); const { createContext } = require('./server/context.js'); const { createRouter } = require('./server/router.js'); const { createCrawlService } = require('./server/services/crawl.js'); const { createGenerationService } = require('./server/services/generation.js'); const { createAutoSyncService } = require('./server/services/auto-sync.js'); const { registerRoutes } = require('./server/routes/index.js'); const HOST = process.env.HOST || '127.0.0.1'; const ctx = createContext(); ctx.crawl = createCrawlService(ctx); ctx.generation = createGenerationService(ctx); ctx.autoSync = createAutoSyncService(ctx); const app = createRouter(); registerRoutes(app, ctx); const server = http.createServer((req, res) => { app.dispatch(req, res); }); // 恢复上次未完成的生成任务(重启后仍可轮询已提交的任务) ctx.generation.loadPersistedJobs(); // PORT 环境变量可临时覆盖 config.json 的端口(多实例/调试用,不落盘) const LISTEN_PORT = Number(process.env.PORT || ctx.config.port) || ctx.config.port; function runCmd(cmd, args, timeoutMs = 8000) { return new Promise((resolve) => { execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => resolve(err ? '' : String(stdout || ''))); }); } /** 找出正在监听指定端口的 PID 列表 */ async function pidsListeningOnPort(port) { if (process.platform === 'win32') { const out = await runCmd('powershell', ['-NoProfile', '-Command', `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) -join ','`]); return out.trim().split(',').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0); } const out = await runCmd('lsof', ['-ti', `tcp:${port}`, '-s', 'TCP:LISTEN']); return out.trim().split('\n').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0); } /** 校验该 PID 是否是「本项目的 server.js」(命令行 + 工作目录双重确认,避免误杀无关进程) */ async function isOurServerProcess(pid) { try { if (process.platform === 'win32') { const out = await runCmd('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`]); return /server\.js/i.test(out) && !!out; } const cmd = await runCmd('ps', ['-p', String(pid), '-o', 'command=']); if (!/server\.js/i.test(cmd)) return false; const cwdOut = await runCmd('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']); const cwdLine = cwdOut.split('\n').find((l) => l.startsWith('n')) || ''; const cwdPath = cwdLine.slice(1); return !cwdPath || cwdPath === __dirname || cwdPath.startsWith(__dirname + path.sep) || cmd.includes(__dirname); } catch { return false; } } /** 端口被占时,自动退出本项目遗留的旧实例并等待端口释放 */ async function reclaimPortFromStaleInstance(port) { const pids = await pidsListeningOnPort(port); let killedAny = false; for (const pid of pids) { if (pid === process.pid) continue; if (!(await isOurServerProcess(pid))) continue; try { process.kill(pid, 'SIGTERM'); killedAny = true; console.log(`已自动退出旧实例(PID ${pid}),正在重新启动...`); } catch { /* 无权限等,忽略 */ } } if (!killedAny) return false; for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 200)); if (!(await pidsListeningOnPort(port)).length) return true; } // SIGTERM 没退干净 → 强杀兜底 for (const pid of await pidsListeningOnPort(port)) { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } } await new Promise((r) => setTimeout(r, 400)); return !(await pidsListeningOnPort(port)).length; } function printPortBusyHelp() { console.error(`启动失败:端口 ${LISTEN_PORT} 已被其他程序占用(非本工具实例),无法自动处理。`); console.error('可任选其一:'); console.error(` · 退出占用该端口的程序:macOS/Linux 执行 lsof -ti tcp:${LISTEN_PORT} 查 PID,Windows 执行 netstat -ano | findstr :${LISTEN_PORT}`); console.error(` · 或修改 config.json 里的 port(或以 PORT=xxxx 环境变量临时指定)后再启动。`); } let listenAttempts = 0; const MAX_LISTEN_ATTEMPTS = 3; server.on('error', async (err) => { if (err && err.code === 'EADDRINUSE' && listenAttempts < MAX_LISTEN_ATTEMPTS) { listenAttempts += 1; console.log(`端口 ${LISTEN_PORT} 被占用,正在检测是否为本工具的旧实例...`); let reclaimed = false; try { reclaimed = await reclaimPortFromStaleInstance(LISTEN_PORT); } catch { /* 检测失败按未回收处理 */ } if (reclaimed) { server.listen(LISTEN_PORT, HOST); return; } printPortBusyHelp(); process.exit(1); } if (err && err.code === 'EADDRINUSE') { printPortBusyHelp(); process.exit(1); } throw err; }); server.listen(LISTEN_PORT, HOST, () => { const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`; ctx.logs.pushSystemLog(`服务已启动:${url}`, 'success'); console.log(`智慧运营中台已启动: ${url}`); // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c; // the empty "" argument is the window-title placeholder start expects before the URL. execFile('cmd', ['/c', 'start', '', url], () => {}); }); // ---------- 优雅停机:Ctrl+C / kill 时先落盘任务与采集状态,再退出 ---------- let shuttingDown = false; function shutdown(signal) { if (shuttingDown) { process.exit(1); } // 连按两次 Ctrl+C 强制退出 shuttingDown = true; console.log(`\n收到 ${signal},正在保存状态并退出...`); try { ctx.generation.flushPersisted(); } catch { /* ignore */ } try { ctx.crawl.flushState(); } catch { /* ignore */ } try { if (server.closeIdleConnections) server.closeIdleConnections(); } catch { /* ignore */ } server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 3000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));