local-services.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. /**
  2. * 本地服务管理器
  3. * 在 Electron 主进程中自动启动和管理 Node 服务端与 Python 发布服务
  4. *
  5. * 路径策略:
  6. * 开发模式:直接引用源码目录 (project-root/server/)
  7. * 打包模式:引用 extraResources 打包进来的 resources/server/
  8. */
  9. const { spawn, execSync } = require('child_process');
  10. const path = require('path');
  11. const fs = require('fs');
  12. const http = require('http');
  13. const { app } = require('electron');
  14. interface ServiceProcess {
  15. process: ReturnType<typeof spawn> | null;
  16. name: string;
  17. port: number;
  18. ready: boolean;
  19. /** 重启跟踪 */
  20. restartCount: number;
  21. lastRestartTime: number;
  22. /** Python 内存监控 */
  23. lastMemoryMB?: number;
  24. }
  25. /** 重启管理器:防止频繁重启被系统杀后无限循环 */
  26. class RestartManager {
  27. private counts: Map<string, number[]> = new Map();
  28. /**
  29. * 记录一次退出,返回是否允许重启
  30. * @param key 服务标识
  31. * @param maxRestarts 允许的最大重启次数
  32. * @param windowMs 时间窗口(毫秒)
  33. */
  34. canRestart(key: string, maxRestarts = 5, windowMs = 5 * 60 * 1000): boolean {
  35. const now = Date.now();
  36. const times = this.counts.get(key) || [];
  37. // 清理窗口外的记录
  38. const recent = times.filter(t => now - t < windowMs);
  39. recent.push(now);
  40. this.counts.set(key, recent);
  41. const allowed = recent.length <= maxRestarts;
  42. if (!allowed) {
  43. log('WARN', `[RestartManager] ${key} 重启过于频繁(${recent.length}次/${windowMs / 1000}s),暂停重启`);
  44. }
  45. return allowed;
  46. }
  47. /** 获取某个服务的重启间隔(指数退避) */
  48. getBackoffMs(key: string, baseMs = 5000): number {
  49. const times = this.counts.get(key) || [];
  50. const recent = times.filter(t => Date.now() - t < 300000); // 5分钟内
  51. // 指数退避:5s, 10s, 20s, 40s, 60s(最多60s)
  52. const backoff = Math.min(baseMs * Math.pow(2, recent.length - 1), 60000);
  53. return backoff;
  54. }
  55. reset(key: string): void {
  56. this.counts.delete(key);
  57. }
  58. }
  59. const restartMgr = new RestartManager();
  60. const NODE_PORT = 14890;
  61. const PYTHON_PORT = 14981;
  62. const services: { node: ServiceProcess; python: ServiceProcess } = {
  63. node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false, restartCount: 0, lastRestartTime: 0 },
  64. python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false, restartCount: 0, lastRestartTime: 0 },
  65. };
  66. // ==================== 文件日志 ====================
  67. let logFilePath = '';
  68. function getLogFilePath(): string {
  69. if (!logFilePath) {
  70. try {
  71. const logDir = app.getPath('userData');
  72. logFilePath = path.join(logDir, 'local-services.log');
  73. } catch {
  74. logFilePath = path.join(process.cwd(), 'local-services.log');
  75. }
  76. }
  77. return logFilePath;
  78. }
  79. function log(level: string, ...args: unknown[]): void {
  80. const ts = new Date().toISOString();
  81. const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
  82. if (level === 'ERROR') {
  83. console.error(msg);
  84. } else {
  85. console.log(msg);
  86. }
  87. try {
  88. fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8');
  89. } catch { /* ignore */ }
  90. }
  91. // ==================== 核心工具 ====================
  92. function isPackaged(): boolean {
  93. return app.isPackaged;
  94. }
  95. function getServerDir(): string {
  96. if (isPackaged()) {
  97. return path.join(process.resourcesPath, 'server');
  98. }
  99. return path.resolve(__dirname, '..', '..', 'server');
  100. }
  101. function getPythonDir(): string {
  102. return path.join(getServerDir(), 'python');
  103. }
  104. function findPython(): string {
  105. const pythonDir = getPythonDir();
  106. const isWin = process.platform === 'win32';
  107. // 优先使用 embeddable Python(完全独立,不依赖系统 Python 安装路径)
  108. const embedPython = isWin
  109. ? path.join(pythonDir, 'python-embed', 'python.exe')
  110. : path.join(pythonDir, 'python-embed', 'bin', 'python');
  111. if (fs.existsSync(embedPython)) return embedPython;
  112. // 回退到 venv Python
  113. const venvPython = isWin
  114. ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe')
  115. : path.join(pythonDir, 'venv', 'bin', 'python');
  116. if (fs.existsSync(venvPython)) return venvPython;
  117. return isWin ? 'python' : 'python3';
  118. }
  119. function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
  120. const serverDir = getServerDir();
  121. const distEntry = path.join(serverDir, 'dist', 'app.js');
  122. const srcEntry = path.join(serverDir, 'src', 'app.ts');
  123. const isWin = process.platform === 'win32';
  124. if (isPackaged()) {
  125. return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
  126. }
  127. if (fs.existsSync(distEntry)) {
  128. return { cmd: 'node', args: [distEntry] };
  129. }
  130. const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
  131. if (fs.existsSync(tsxBin)) {
  132. return { cmd: tsxBin, args: [srcEntry] };
  133. }
  134. return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
  135. }
  136. function checkPort(port: number): Promise<boolean> {
  137. return new Promise((resolve) => {
  138. const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => {
  139. res.resume();
  140. resolve(true);
  141. });
  142. req.on('error', () => resolve(false));
  143. req.on('timeout', () => { req.destroy(); resolve(false); });
  144. });
  145. }
  146. async function waitForService(port: number, timeoutMs = 30000): Promise<boolean> {
  147. const start = Date.now();
  148. while (Date.now() - start < timeoutMs) {
  149. if (await checkPort(port)) return true;
  150. await new Promise((r) => setTimeout(r, 1000));
  151. }
  152. return false;
  153. }
  154. function getEnvFilePath(): string {
  155. return path.join(getServerDir(), '.env');
  156. }
  157. function getPlaywrightBrowsersPath(): string | null {
  158. if (isPackaged()) {
  159. const bundled = path.join(process.resourcesPath, 'playwright');
  160. if (fs.existsSync(bundled)) return bundled;
  161. }
  162. return null;
  163. }
  164. // ==================== 诊断:列出 resources 目录结构 ====================
  165. function dumpResourcesDir(): void {
  166. if (!isPackaged()) return;
  167. try {
  168. const resDir = process.resourcesPath;
  169. log('INFO', `resources 目录: ${resDir}`);
  170. const topEntries = fs.readdirSync(resDir);
  171. log('INFO', `resources/ 内容: ${topEntries.join(', ')}`);
  172. const serverDir = path.join(resDir, 'server');
  173. if (fs.existsSync(serverDir)) {
  174. const serverEntries = fs.readdirSync(serverDir);
  175. log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`);
  176. const distDir = path.join(serverDir, 'dist');
  177. if (fs.existsSync(distDir)) {
  178. const distEntries = fs.readdirSync(distDir).slice(0, 20);
  179. log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`);
  180. } else {
  181. log('ERROR', 'resources/server/dist/ 不存在!');
  182. }
  183. const pyDir = path.join(serverDir, 'python');
  184. if (fs.existsSync(pyDir)) {
  185. const pyEntries = fs.readdirSync(pyDir);
  186. log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`);
  187. } else {
  188. log('ERROR', 'resources/server/python/ 不存在!');
  189. }
  190. const nmDir = path.join(serverDir, 'node_modules');
  191. log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`);
  192. } else {
  193. log('ERROR', 'resources/server/ 目录不存在!');
  194. }
  195. } catch (e: any) {
  196. log('ERROR', `诊断目录结构失败: ${e.message}`);
  197. }
  198. }
  199. // ==================== 启动服务 ====================
  200. function startNodeServer(): void {
  201. const serverDir = getServerDir();
  202. if (!fs.existsSync(serverDir)) {
  203. log('ERROR', `server 目录不存在: ${serverDir}`);
  204. return;
  205. }
  206. const { cmd, args, useElectronAsNode } = findNodeRunner();
  207. log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`);
  208. log('INFO', `工作目录: ${serverDir}`);
  209. const distApp = path.join(serverDir, 'dist', 'app.js');
  210. log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`);
  211. const envFile = getEnvFilePath();
  212. log('INFO', `.env 存在: ${fs.existsSync(envFile)}`);
  213. const env: Record<string, string> = {
  214. ...process.env as Record<string, string>,
  215. PORT: String(NODE_PORT),
  216. HOST: '127.0.0.1',
  217. PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
  218. USE_REDIS_QUEUE: 'false',
  219. // 限制 Node 服务端最大堆内存 768MB,防止无限增长被系统杀
  220. NODE_OPTIONS: '--max-old-space-size=768',
  221. };
  222. // 打包模式下,uploads 目录使用 userData(可写),避免 Program Files 权限问题
  223. if (isPackaged()) {
  224. const uploadsDir = path.join(app.getPath('userData'), 'uploads');
  225. if (!fs.existsSync(uploadsDir)) {
  226. try { fs.mkdirSync(uploadsDir, { recursive: true }); } catch { /* ignore */ }
  227. }
  228. env.UPLOAD_PATH = uploadsDir;
  229. log('INFO', `UPLOAD_PATH: ${uploadsDir}`);
  230. }
  231. if (useElectronAsNode) {
  232. env.ELECTRON_RUN_AS_NODE = '1';
  233. log('INFO', 'ELECTRON_RUN_AS_NODE=1');
  234. }
  235. const pwPath = getPlaywrightBrowsersPath();
  236. if (pwPath) {
  237. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  238. }
  239. try {
  240. const child = spawn(cmd, args, {
  241. cwd: serverDir,
  242. env,
  243. stdio: ['ignore', 'pipe', 'pipe'],
  244. shell: false,
  245. windowsHide: true,
  246. });
  247. log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
  248. child.on('error', (err: Error) => {
  249. log('ERROR', `Node spawn error: ${err.message}`);
  250. });
  251. child.stdout?.on('data', (data: Buffer) => {
  252. const msg = data.toString().trim();
  253. if (msg) log('NODE', msg);
  254. if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
  255. services.node.ready = true;
  256. }
  257. });
  258. child.stderr?.on('data', (data: Buffer) => {
  259. const msg = data.toString().trim();
  260. if (msg) log('NODE-ERR', msg);
  261. });
  262. child.on('exit', (code: number | null, signal: string | null) => {
  263. log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
  264. services.node.process = null;
  265. services.node.ready = false;
  266. // ===== 自动重启:被系统杀(code非0)或异常退出 =====
  267. const key = 'node-server';
  268. const isUnexpected = code !== 0 || signal !== null;
  269. if (isUnexpected && restartMgr.canRestart(key)) {
  270. const backoff = restartMgr.getBackoffMs(key);
  271. log('WARN', `[Node] 异常退出,${backoff / 1000}s 后自动重启...`);
  272. setTimeout(() => {
  273. restartMgr.reset(key);
  274. log('INFO', `[Node] 自动重启第 ${services.node.restartCount + 1} 次`);
  275. startNodeServer();
  276. }, backoff);
  277. services.node.restartCount++;
  278. } else if (!restartMgr.canRestart(key)) {
  279. log('ERROR', '[Node] 重启次数过多,停止自动重启,请检查问题');
  280. }
  281. });
  282. services.node.process = child;
  283. } catch (e: any) {
  284. log('ERROR', `Node spawn 异常: ${e.message}`);
  285. }
  286. }
  287. function startPythonService(): void {
  288. const pythonDir = getPythonDir();
  289. const appPy = path.join(pythonDir, 'app.py');
  290. if (!fs.existsSync(appPy)) {
  291. log('ERROR', `Python 入口不存在: ${appPy}`);
  292. return;
  293. }
  294. const pythonCmd = findPython();
  295. const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
  296. log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
  297. log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
  298. log('INFO', `工作目录: ${pythonDir}`);
  299. const env: Record<string, string> = {
  300. ...process.env as Record<string, string>,
  301. PYTHONUNBUFFERED: '1',
  302. PYTHONIOENCODING: 'utf-8',
  303. PYTHONDONTWRITEBYTECODE: '1',
  304. };
  305. const pwPath = getPlaywrightBrowsersPath();
  306. if (pwPath) {
  307. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  308. log('INFO', `Playwright 浏览器路径: ${pwPath}`);
  309. }
  310. try {
  311. const child = spawn(pythonCmd, args, {
  312. cwd: pythonDir,
  313. env,
  314. stdio: ['ignore', 'pipe', 'pipe'],
  315. shell: false,
  316. windowsHide: true,
  317. });
  318. log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
  319. child.on('error', (err: Error) => {
  320. log('ERROR', `Python spawn error: ${err.message}`);
  321. });
  322. child.stdout?.on('data', (data: Buffer) => {
  323. const msg = data.toString().trim();
  324. if (msg) log('PYTHON', msg);
  325. if (msg.includes('Running on') || msg.includes('启动服务')) {
  326. services.python.ready = true;
  327. }
  328. });
  329. child.stderr?.on('data', (data: Buffer) => {
  330. const msg = data.toString().trim();
  331. if (msg) log('PYTHON-ERR', msg);
  332. });
  333. child.on('exit', (code: number | null, signal: string | null) => {
  334. log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
  335. services.python.process = null;
  336. services.python.ready = false;
  337. // ===== 自动重启:被系统杀或异常退出 =====
  338. const key = 'python-service';
  339. const isUnexpected = code !== 0 || signal !== null;
  340. if (isUnexpected && restartMgr.canRestart(key)) {
  341. const backoff = restartMgr.getBackoffMs(key);
  342. log('WARN', `[Python] 异常退出,${backoff / 1000}s 后自动重启...`);
  343. setTimeout(() => {
  344. restartMgr.reset(key);
  345. log('INFO', `[Python] 自动重启第 ${services.python.restartCount + 1} 次`);
  346. startPythonService();
  347. }, backoff);
  348. services.python.restartCount++;
  349. } else if (!restartMgr.canRestart(key)) {
  350. log('ERROR', '[Python] 重启次数过多,停止自动重启,请检查问题');
  351. }
  352. });
  353. services.python.process = child;
  354. // ===== Python 内存监控(Windows) =====
  355. if (process.platform === 'win32') {
  356. startPythonMemoryMonitor(child.pid);
  357. }
  358. } catch (e: any) {
  359. log('ERROR', `Python spawn 异常: ${e.message}`);
  360. }
  361. }
  362. /** 监控 Python 进程内存,超过阈值则重启 */
  363. let pythonMemInterval: NodeJS.Timeout | null = null;
  364. const PYTHON_MEM_THRESHOLD_MB = 800;
  365. function startPythonMemoryMonitor(pid: number): void {
  366. // 清除旧的监控
  367. if (pythonMemInterval) {
  368. clearInterval(pythonMemInterval);
  369. pythonMemInterval = null;
  370. }
  371. pythonMemInterval = setInterval(async () => {
  372. const proc = services.python?.process;
  373. if (!proc || proc.pid !== pid || proc.killed) {
  374. clearInterval(pythonMemInterval!);
  375. pythonMemInterval = null;
  376. return;
  377. }
  378. try {
  379. const memMB = await getProcessMemoryMB(pid);
  380. services.python.lastMemoryMB = memMB;
  381. if (memMB > PYTHON_MEM_THRESHOLD_MB) {
  382. log('WARN', `[Python] 内存占用过高(${memMB}MB > ${PYTHON_MEM_THRESHOLD_MB}MB),准备重启...`);
  383. proc.kill('SIGTERM');
  384. // 给2秒优雅退出,然后强制杀
  385. setTimeout(() => {
  386. if (!proc.killed) {
  387. log('WARN', '[Python] 优雅退出超时,强制杀进程');
  388. try { proc.kill('SIGKILL'); } catch {}
  389. }
  390. }, 2000);
  391. }
  392. } catch (e) {
  393. // 进程可能已结束,忽略
  394. }
  395. }, 60000); // 每60秒检查一次
  396. }
  397. /** 通过 tasklist 获取进程内存(Windows,MB) */
  398. function getProcessMemoryMB(pid: number): Promise<number> {
  399. return new Promise((resolve) => {
  400. const child = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
  401. windowsHide: true,
  402. });
  403. let stdout = '';
  404. child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
  405. child.on('close', () => {
  406. // 格式: "python.exe","1234","Console","1,234 KB"
  407. const match = stdout.match(/"(\d+[\d,]*) KB"/);
  408. if (match) {
  409. const kb = parseInt(match[1].replace(/,/g, ''), 10);
  410. resolve(Math.round(kb / 1024));
  411. } else {
  412. resolve(0);
  413. }
  414. });
  415. child.on('error', () => resolve(0));
  416. });
  417. }
  418. // ==================== 导出 API ====================
  419. export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
  420. // 清空旧日志
  421. try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
  422. log('INFO', '========== 启动本地服务 ==========');
  423. log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
  424. log('INFO', `execPath: ${process.execPath}`);
  425. log('INFO', `resourcesPath: ${process.resourcesPath}`);
  426. log('INFO', `Server 目录: ${getServerDir()}`);
  427. log('INFO', `Python 目录: ${getPythonDir()}`);
  428. log('INFO', `日志文件: ${getLogFilePath()}`);
  429. dumpResourcesDir();
  430. const [nodeAlive, pythonAlive] = await Promise.all([
  431. checkPort(NODE_PORT),
  432. checkPort(PYTHON_PORT),
  433. ]);
  434. if (nodeAlive) {
  435. log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
  436. services.node.ready = true;
  437. } else {
  438. startNodeServer();
  439. }
  440. if (pythonAlive) {
  441. log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
  442. services.python.ready = true;
  443. } else {
  444. startPythonService();
  445. }
  446. const [nodeOk, pythonOk] = await Promise.all([
  447. services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
  448. services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
  449. ]);
  450. services.node.ready = nodeOk;
  451. services.python.ready = pythonOk;
  452. log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
  453. return { nodeOk, pythonOk };
  454. }
  455. export function stopLocalServices(): void {
  456. log('INFO', '正在停止本地服务...');
  457. // 清除 Python 内存监控定时器
  458. if (pythonMemInterval) {
  459. clearInterval(pythonMemInterval);
  460. pythonMemInterval = null;
  461. }
  462. for (const key of ['node', 'python'] as const) {
  463. const svc = services[key];
  464. if (svc.process) {
  465. log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
  466. try {
  467. if (process.platform === 'win32') {
  468. spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
  469. } else {
  470. svc.process.kill('SIGTERM');
  471. }
  472. } catch (e: any) {
  473. log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
  474. }
  475. svc.process = null;
  476. svc.ready = false;
  477. }
  478. }
  479. }
  480. export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
  481. return {
  482. node: { ready: services.node.ready, port: NODE_PORT },
  483. python: { ready: services.python.ready, port: PYTHON_PORT },
  484. };
  485. }
  486. export function getLogPath(): string {
  487. return getLogFilePath();
  488. }
  489. export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  490. export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;