server.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. 'use strict';
  2. // 智慧运营中台 · 引导入口
  3. // 职责仅剩:装配共享状态(server/context.js)→ 挂载路由(server/routes/)→ 监听端口。
  4. // 端口被旧实例占用时自动回收;业务逻辑全部在 server/ 目录下。
  5. const http = require('http');
  6. const path = require('path');
  7. const { execFile } = require('child_process');
  8. const { createContext } = require('./server/context.js');
  9. const { createRouter } = require('./server/router.js');
  10. const { makeTokenGuard, makeOriginGuard } = require('./server/middleware.js');
  11. const { sendJson } = require('./server/util.js');
  12. const { createCrawlService } = require('./server/services/crawl.js');
  13. const { createGenerationService } = require('./server/services/generation.js');
  14. const { createAutoSyncService } = require('./server/services/auto-sync.js');
  15. const { createAitableService } = require('./server/services/aitable.js');
  16. const { registerRoutes } = require('./server/routes/index.js');
  17. const HOST = process.env.HOST || '127.0.0.1';
  18. const ctx = createContext();
  19. ctx.crawl = createCrawlService(ctx);
  20. ctx.generation = createGenerationService(ctx);
  21. ctx.autoSync = createAutoSyncService(ctx);
  22. ctx.aitable = createAitableService(ctx);
  23. const app = createRouter();
  24. registerRoutes(app, ctx);
  25. // 局域网访问口令:config.accessToken(或环境变量 ACCESS_TOKEN)非空时启用
  26. const tokenGuard = makeTokenGuard(ctx.config);
  27. // 本地 CSRF 防护:带 Origin 的写请求必须同源(浏览器跨站 no-cors POST 被拒)
  28. const originGuard = makeOriginGuard();
  29. const server = http.createServer((req, res) => {
  30. const startedAt = Date.now();
  31. res.on('finish', () => {
  32. const ms = Date.now() - startedAt;
  33. if (ctx.config.httpLog || res.statusCode >= 500 || ms > 1000) {
  34. console.log(`[http] ${req.method} ${req.url} -> ${res.statusCode} ${ms}ms`);
  35. }
  36. });
  37. if (!originGuard(req)) {
  38. return sendJson(res, 403, { error: '已拒绝跨站写请求(Origin 与服务地址不符)' });
  39. }
  40. try {
  41. const parsed = new URL(req.url, `http://${req.headers.host}`);
  42. if (!tokenGuard(req, parsed.pathname, parsed.searchParams)) {
  43. return sendJson(res, 401, { error: '需要访问口令(accessToken)' });
  44. }
  45. } catch { /* URL 非法时交给 dispatch 返回 400 */ }
  46. app.dispatch(req, res);
  47. });
  48. // 恢复上次未完成的生成任务(重启后仍可轮询已提交的任务)
  49. ctx.generation.loadPersistedJobs();
  50. // PORT 环境变量可临时覆盖 config.json 的端口(多实例/调试用,不落盘)
  51. const LISTEN_PORT = Number(process.env.PORT || ctx.config.port) || ctx.config.port;
  52. function runCmd(cmd, args, timeoutMs = 8000) {
  53. return new Promise((resolve) => {
  54. execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => resolve(err ? '' : String(stdout || '')));
  55. });
  56. }
  57. /** 找出正在监听指定端口的 PID 列表 */
  58. async function pidsListeningOnPort(port) {
  59. if (process.platform === 'win32') {
  60. const out = await runCmd('powershell', ['-NoProfile', '-Command',
  61. `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) -join ','`]);
  62. return out.trim().split(',').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
  63. }
  64. const out = await runCmd('lsof', ['-ti', `tcp:${port}`, '-s', 'TCP:LISTEN']);
  65. return out.trim().split('\n').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
  66. }
  67. /** 校验该 PID 是否是「本项目的 server.js」(命令行 + 工作目录双重确认,避免误杀无关进程) */
  68. async function isOurServerProcess(pid) {
  69. try {
  70. if (process.platform === 'win32') {
  71. const out = await runCmd('powershell', ['-NoProfile', '-Command',
  72. `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`]);
  73. return /server\.js/i.test(out) && !!out;
  74. }
  75. const cmd = await runCmd('ps', ['-p', String(pid), '-o', 'command=']);
  76. if (!/server\.js/i.test(cmd)) return false;
  77. const cwdOut = await runCmd('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
  78. const cwdLine = cwdOut.split('\n').find((l) => l.startsWith('n')) || '';
  79. const cwdPath = cwdLine.slice(1);
  80. return !cwdPath || cwdPath === __dirname || cwdPath.startsWith(__dirname + path.sep) || cmd.includes(__dirname);
  81. } catch {
  82. return false;
  83. }
  84. }
  85. /** 端口被占时,自动退出本项目遗留的旧实例并等待端口释放 */
  86. async function reclaimPortFromStaleInstance(port) {
  87. const pids = await pidsListeningOnPort(port);
  88. let killedAny = false;
  89. for (const pid of pids) {
  90. if (pid === process.pid) continue;
  91. if (!(await isOurServerProcess(pid))) continue;
  92. try {
  93. process.kill(pid, 'SIGTERM');
  94. killedAny = true;
  95. console.log(`已自动退出旧实例(PID ${pid}),正在重新启动...`);
  96. } catch { /* 无权限等,忽略 */ }
  97. }
  98. if (!killedAny) return false;
  99. for (let i = 0; i < 20; i++) {
  100. await new Promise((r) => setTimeout(r, 200));
  101. if (!(await pidsListeningOnPort(port)).length) return true;
  102. }
  103. // SIGTERM 没退干净 → 强杀兜底
  104. for (const pid of await pidsListeningOnPort(port)) {
  105. try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
  106. }
  107. await new Promise((r) => setTimeout(r, 400));
  108. return !(await pidsListeningOnPort(port)).length;
  109. }
  110. function printPortBusyHelp() {
  111. console.error(`启动失败:端口 ${LISTEN_PORT} 已被其他程序占用(非本工具实例),无法自动处理。`);
  112. console.error('可任选其一:');
  113. console.error(` · 退出占用该端口的程序:macOS/Linux 执行 lsof -ti tcp:${LISTEN_PORT} 查 PID,Windows 执行 netstat -ano | findstr :${LISTEN_PORT}`);
  114. console.error(` · 或修改 config.json 里的 port(或以 PORT=xxxx 环境变量临时指定)后再启动。`);
  115. }
  116. let listenAttempts = 0;
  117. const MAX_LISTEN_ATTEMPTS = 3;
  118. server.on('error', async (err) => {
  119. if (err && err.code === 'EADDRINUSE' && listenAttempts < MAX_LISTEN_ATTEMPTS) {
  120. listenAttempts += 1;
  121. console.log(`端口 ${LISTEN_PORT} 被占用,正在检测是否为本工具的旧实例...`);
  122. let reclaimed = false;
  123. try {
  124. reclaimed = await reclaimPortFromStaleInstance(LISTEN_PORT);
  125. } catch { /* 检测失败按未回收处理 */ }
  126. if (reclaimed) {
  127. server.listen(LISTEN_PORT, HOST);
  128. return;
  129. }
  130. printPortBusyHelp();
  131. process.exit(1);
  132. }
  133. if (err && err.code === 'EADDRINUSE') {
  134. printPortBusyHelp();
  135. process.exit(1);
  136. }
  137. throw err;
  138. });
  139. server.listen(LISTEN_PORT, HOST, () => {
  140. const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`;
  141. ctx.logs.pushSystemLog(`服务已启动:${url}`, 'success');
  142. console.log(`智慧运营中台已启动: ${url}`);
  143. // 启动后自动打开浏览器(按平台选命令;失败静默,不影响服务)
  144. try {
  145. if (process.platform === 'darwin') execFile('open', [url], () => {});
  146. else if (process.platform === 'win32') {
  147. // "start" is a cmd.exe builtin (not a standalone executable), so it must be run
  148. // via cmd /c; the empty "" argument is the window-title placeholder start expects.
  149. execFile('cmd', ['/c', 'start', '', url], () => {});
  150. } else execFile('xdg-open', [url], () => {});
  151. } catch { /* ignore */ }
  152. });
  153. // ---------- 优雅停机:Ctrl+C / kill 时先落盘任务与采集状态,再退出 ----------
  154. let shuttingDown = false;
  155. function shutdown(signal) {
  156. if (shuttingDown) { process.exit(1); } // 连按两次 Ctrl+C 强制退出
  157. shuttingDown = true;
  158. console.log(`\n收到 ${signal},正在保存状态并退出...`);
  159. try { ctx.generation.flushPersisted(); } catch { /* ignore */ }
  160. try { ctx.crawl.flushState(); } catch { /* ignore */ }
  161. try { if (server.closeIdleConnections) server.closeIdleConnections(); } catch { /* ignore */ }
  162. server.close(() => process.exit(0));
  163. setTimeout(() => process.exit(0), 3000).unref();
  164. }
  165. process.on('SIGTERM', () => shutdown('SIGTERM'));
  166. process.on('SIGINT', () => shutdown('SIGINT'));