local-services.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  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. // 强制 Node.js 服务内的 Python 调用使用 embeddable Python(含 openpyxl)
  222. PYTHON_BIN: findPython(),
  223. };
  224. // 打包模式下,uploads 目录使用 userData(可写),避免 Program Files 权限问题
  225. if (isPackaged()) {
  226. const uploadsDir = path.join(app.getPath('userData'), 'uploads');
  227. if (!fs.existsSync(uploadsDir)) {
  228. try { fs.mkdirSync(uploadsDir, { recursive: true }); } catch { /* ignore */ }
  229. }
  230. env.UPLOAD_PATH = uploadsDir;
  231. log('INFO', `UPLOAD_PATH: ${uploadsDir}`);
  232. }
  233. if (useElectronAsNode) {
  234. env.ELECTRON_RUN_AS_NODE = '1';
  235. log('INFO', 'ELECTRON_RUN_AS_NODE=1');
  236. }
  237. const pwPath = getPlaywrightBrowsersPath();
  238. if (pwPath) {
  239. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  240. }
  241. try {
  242. const child = spawn(cmd, args, {
  243. cwd: serverDir,
  244. env,
  245. stdio: ['ignore', 'pipe', 'pipe'],
  246. shell: false,
  247. windowsHide: true,
  248. });
  249. log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
  250. child.on('error', (err: Error) => {
  251. log('ERROR', `Node spawn error: ${err.message}`);
  252. });
  253. child.stdout?.on('data', (data: Buffer) => {
  254. const msg = data.toString().trim();
  255. if (msg) log('NODE', msg);
  256. if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
  257. services.node.ready = true;
  258. }
  259. });
  260. child.stderr?.on('data', (data: Buffer) => {
  261. const msg = data.toString().trim();
  262. if (msg) log('NODE-ERR', msg);
  263. });
  264. child.on('exit', (code: number | null, signal: string | null) => {
  265. log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
  266. services.node.process = null;
  267. services.node.ready = false;
  268. // ===== 自动重启:被系统杀(code非0)或异常退出 =====
  269. const key = 'node-server';
  270. const isUnexpected = code !== 0 || signal !== null;
  271. if (isUnexpected && restartMgr.canRestart(key)) {
  272. const backoff = restartMgr.getBackoffMs(key);
  273. log('WARN', `[Node] 异常退出,${backoff / 1000}s 后自动重启...`);
  274. setTimeout(() => {
  275. restartMgr.reset(key);
  276. log('INFO', `[Node] 自动重启第 ${services.node.restartCount + 1} 次`);
  277. startNodeServer();
  278. }, backoff);
  279. services.node.restartCount++;
  280. } else if (!restartMgr.canRestart(key)) {
  281. log('ERROR', '[Node] 重启次数过多,停止自动重启,请检查问题');
  282. }
  283. });
  284. services.node.process = child;
  285. } catch (e: any) {
  286. log('ERROR', `Node spawn 异常: ${e.message}`);
  287. }
  288. }
  289. function startPythonService(): void {
  290. const pythonDir = getPythonDir();
  291. const appPy = path.join(pythonDir, 'app.py');
  292. if (!fs.existsSync(appPy)) {
  293. log('ERROR', `Python 入口不存在: ${appPy}`);
  294. return;
  295. }
  296. const pythonCmd = findPython();
  297. const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
  298. log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
  299. log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
  300. log('INFO', `工作目录: ${pythonDir}`);
  301. const env: Record<string, string> = {
  302. ...process.env as Record<string, string>,
  303. PYTHONUNBUFFERED: '1',
  304. PYTHONIOENCODING: 'utf-8',
  305. PYTHONDONTWRITEBYTECODE: '1',
  306. };
  307. const pwPath = getPlaywrightBrowsersPath();
  308. if (pwPath) {
  309. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  310. log('INFO', `Playwright 浏览器路径: ${pwPath}`);
  311. }
  312. try {
  313. const child = spawn(pythonCmd, args, {
  314. cwd: pythonDir,
  315. env,
  316. stdio: ['ignore', 'pipe', 'pipe'],
  317. shell: false,
  318. windowsHide: true,
  319. });
  320. log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
  321. child.on('error', (err: Error) => {
  322. log('ERROR', `Python spawn error: ${err.message}`);
  323. });
  324. child.stdout?.on('data', (data: Buffer) => {
  325. const msg = data.toString().trim();
  326. if (msg) log('PYTHON', msg);
  327. if (msg.includes('Running on') || msg.includes('启动服务')) {
  328. services.python.ready = true;
  329. }
  330. });
  331. child.stderr?.on('data', (data: Buffer) => {
  332. const msg = data.toString().trim();
  333. if (msg) log('PYTHON-ERR', msg);
  334. });
  335. child.on('exit', (code: number | null, signal: string | null) => {
  336. log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
  337. services.python.process = null;
  338. services.python.ready = false;
  339. // ===== 自动重启:被系统杀或异常退出 =====
  340. const key = 'python-service';
  341. const isUnexpected = code !== 0 || signal !== null;
  342. if (isUnexpected && restartMgr.canRestart(key)) {
  343. const backoff = restartMgr.getBackoffMs(key);
  344. log('WARN', `[Python] 异常退出,${backoff / 1000}s 后自动重启...`);
  345. setTimeout(() => {
  346. restartMgr.reset(key);
  347. log('INFO', `[Python] 自动重启第 ${services.python.restartCount + 1} 次`);
  348. startPythonService();
  349. }, backoff);
  350. services.python.restartCount++;
  351. } else if (!restartMgr.canRestart(key)) {
  352. log('ERROR', '[Python] 重启次数过多,停止自动重启,请检查问题');
  353. }
  354. });
  355. services.python.process = child;
  356. // ===== Python 内存监控(Windows) =====
  357. if (process.platform === 'win32') {
  358. startPythonMemoryMonitor(child.pid);
  359. }
  360. } catch (e: any) {
  361. log('ERROR', `Python spawn 异常: ${e.message}`);
  362. }
  363. }
  364. /** 监控 Python 进程内存,超过阈值则重启 */
  365. let pythonMemInterval: NodeJS.Timeout | null = null;
  366. const PYTHON_MEM_THRESHOLD_MB = 800;
  367. function startPythonMemoryMonitor(pid: number): void {
  368. // 清除旧的监控
  369. if (pythonMemInterval) {
  370. clearInterval(pythonMemInterval);
  371. pythonMemInterval = null;
  372. }
  373. pythonMemInterval = setInterval(async () => {
  374. const proc = services.python?.process;
  375. if (!proc || proc.pid !== pid || proc.killed) {
  376. clearInterval(pythonMemInterval!);
  377. pythonMemInterval = null;
  378. return;
  379. }
  380. try {
  381. const memMB = await getProcessMemoryMB(pid);
  382. services.python.lastMemoryMB = memMB;
  383. if (memMB > PYTHON_MEM_THRESHOLD_MB) {
  384. log('WARN', `[Python] 内存占用过高(${memMB}MB > ${PYTHON_MEM_THRESHOLD_MB}MB),准备重启...`);
  385. proc.kill('SIGTERM');
  386. // 给2秒优雅退出,然后强制杀
  387. setTimeout(() => {
  388. if (!proc.killed) {
  389. log('WARN', '[Python] 优雅退出超时,强制杀进程');
  390. try { proc.kill('SIGKILL'); } catch {}
  391. }
  392. }, 2000);
  393. }
  394. } catch (e) {
  395. // 进程可能已结束,忽略
  396. }
  397. }, 60000); // 每60秒检查一次
  398. }
  399. /** 通过 tasklist 获取进程内存(Windows,MB) */
  400. function getProcessMemoryMB(pid: number): Promise<number> {
  401. return new Promise((resolve) => {
  402. const child = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
  403. windowsHide: true,
  404. });
  405. let stdout = '';
  406. child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
  407. child.on('close', () => {
  408. // 格式: "python.exe","1234","Console","1,234 KB"
  409. const match = stdout.match(/"(\d+[\d,]*) KB"/);
  410. if (match) {
  411. const kb = parseInt(match[1].replace(/,/g, ''), 10);
  412. resolve(Math.round(kb / 1024));
  413. } else {
  414. resolve(0);
  415. }
  416. });
  417. child.on('error', () => resolve(0));
  418. });
  419. }
  420. // ==================== 导出 API ====================
  421. export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
  422. // 清空旧日志
  423. try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
  424. log('INFO', '========== 启动本地服务 ==========');
  425. log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
  426. log('INFO', `execPath: ${process.execPath}`);
  427. log('INFO', `resourcesPath: ${process.resourcesPath}`);
  428. log('INFO', `Server 目录: ${getServerDir()}`);
  429. log('INFO', `Python 目录: ${getPythonDir()}`);
  430. log('INFO', `日志文件: ${getLogFilePath()}`);
  431. dumpResourcesDir();
  432. const [nodeAlive, pythonAlive] = await Promise.all([
  433. checkPort(NODE_PORT),
  434. checkPort(PYTHON_PORT),
  435. ]);
  436. if (nodeAlive) {
  437. log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
  438. services.node.ready = true;
  439. } else {
  440. startNodeServer();
  441. }
  442. if (pythonAlive) {
  443. log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
  444. services.python.ready = true;
  445. } else {
  446. startPythonService();
  447. }
  448. const [nodeOk, pythonOk] = await Promise.all([
  449. services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
  450. services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
  451. ]);
  452. services.node.ready = nodeOk;
  453. services.python.ready = pythonOk;
  454. log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
  455. return { nodeOk, pythonOk };
  456. }
  457. export function stopLocalServices(): void {
  458. log('INFO', '正在停止本地服务...');
  459. // 清除 Python 内存监控定时器
  460. if (pythonMemInterval) {
  461. clearInterval(pythonMemInterval);
  462. pythonMemInterval = null;
  463. }
  464. for (const key of ['node', 'python'] as const) {
  465. const svc = services[key];
  466. if (svc.process) {
  467. log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
  468. try {
  469. if (process.platform === 'win32') {
  470. spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
  471. } else {
  472. svc.process.kill('SIGTERM');
  473. }
  474. } catch (e: any) {
  475. log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
  476. }
  477. svc.process = null;
  478. svc.ready = false;
  479. }
  480. }
  481. }
  482. export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
  483. return {
  484. node: { ready: services.node.ready, port: NODE_PORT },
  485. python: { ready: services.python.ready, port: PYTHON_PORT },
  486. };
  487. }
  488. export function getLogPath(): string {
  489. return getLogFilePath();
  490. }
  491. export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  492. export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;