瀏覽代碼

fix(workDayStatistics): 完善getPythonBin,Windows下用py -0p查找实际Python路径,修复导出功能

ethanfly 3 天之前
父節點
當前提交
d7dd912d75
共有 3 個文件被更改,包括 236 次插入8 次删除
  1. 154 2
      client/electron/local-services.ts
  2. 61 0
      client/electron/main.ts
  3. 21 6
      server/src/routes/workDayStatistics.ts

+ 154 - 2
client/electron/local-services.ts

@@ -17,14 +17,59 @@ interface ServiceProcess {
   name: string;
   port: number;
   ready: boolean;
+  /** 重启跟踪 */
+  restartCount: number;
+  lastRestartTime: number;
+  /** Python 内存监控 */
+  lastMemoryMB?: number;
 }
 
+/** 重启管理器:防止频繁重启被系统杀后无限循环 */
+class RestartManager {
+  private counts: Map<string, number[]> = new Map();
+
+  /**
+   * 记录一次退出,返回是否允许重启
+   * @param key 服务标识
+   * @param maxRestarts 允许的最大重启次数
+   * @param windowMs 时间窗口(毫秒)
+   */
+  canRestart(key: string, maxRestarts = 5, windowMs = 5 * 60 * 1000): boolean {
+    const now = Date.now();
+    const times = this.counts.get(key) || [];
+    // 清理窗口外的记录
+    const recent = times.filter(t => now - t < windowMs);
+    recent.push(now);
+    this.counts.set(key, recent);
+    const allowed = recent.length <= maxRestarts;
+    if (!allowed) {
+      log('WARN', `[RestartManager] ${key} 重启过于频繁(${recent.length}次/${windowMs / 1000}s),暂停重启`);
+    }
+    return allowed;
+  }
+
+  /** 获取某个服务的重启间隔(指数退避) */
+  getBackoffMs(key: string, baseMs = 5000): number {
+    const times = this.counts.get(key) || [];
+    const recent = times.filter(t => Date.now() - t < 300000); // 5分钟内
+    // 指数退避:5s, 10s, 20s, 40s, 60s(最多60s)
+    const backoff = Math.min(baseMs * Math.pow(2, recent.length - 1), 60000);
+    return backoff;
+  }
+
+  reset(key: string): void {
+    this.counts.delete(key);
+  }
+}
+
+const restartMgr = new RestartManager();
+
 const NODE_PORT = 14890;
 const PYTHON_PORT = 14981;
 
 const services: { node: ServiceProcess; python: ServiceProcess } = {
-  node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false },
-  python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false },
+  node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false, restartCount: 0, lastRestartTime: 0 },
+  python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false, restartCount: 0, lastRestartTime: 0 },
 };
 
 // ==================== 文件日志 ====================
@@ -216,6 +261,8 @@ function startNodeServer(): void {
     HOST: '127.0.0.1',
     PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
     USE_REDIS_QUEUE: 'false',
+    // 限制 Node 服务端最大堆内存 768MB,防止无限增长被系统杀
+    NODE_OPTIONS: '--max-old-space-size=768',
   };
 
   // 打包模式下,uploads 目录使用 userData(可写),避免 Program Files 权限问题
@@ -270,6 +317,22 @@ function startNodeServer(): void {
       log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
       services.node.process = null;
       services.node.ready = false;
+
+      // ===== 自动重启:被系统杀(code非0)或异常退出 =====
+      const key = 'node-server';
+      const isUnexpected = code !== 0 || signal !== null;
+      if (isUnexpected && restartMgr.canRestart(key)) {
+        const backoff = restartMgr.getBackoffMs(key);
+        log('WARN', `[Node] 异常退出,${backoff / 1000}s 后自动重启...`);
+        setTimeout(() => {
+          restartMgr.reset(key);
+          log('INFO', `[Node] 自动重启第 ${services.node.restartCount + 1} 次`);
+          startNodeServer();
+        }, backoff);
+        services.node.restartCount++;
+      } else if (!restartMgr.canRestart(key)) {
+        log('ERROR', '[Node] 重启次数过多,停止自动重启,请检查问题');
+      }
     });
 
     services.node.process = child;
@@ -296,6 +359,7 @@ function startPythonService(): void {
     ...process.env as Record<string, string>,
     PYTHONUNBUFFERED: '1',
     PYTHONIOENCODING: 'utf-8',
+    PYTHONDONTWRITEBYTECODE: '1',
   };
 
   const pwPath = getPlaywrightBrowsersPath();
@@ -336,14 +400,96 @@ function startPythonService(): void {
       log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
       services.python.process = null;
       services.python.ready = false;
+
+      // ===== 自动重启:被系统杀或异常退出 =====
+      const key = 'python-service';
+      const isUnexpected = code !== 0 || signal !== null;
+      if (isUnexpected && restartMgr.canRestart(key)) {
+        const backoff = restartMgr.getBackoffMs(key);
+        log('WARN', `[Python] 异常退出,${backoff / 1000}s 后自动重启...`);
+        setTimeout(() => {
+          restartMgr.reset(key);
+          log('INFO', `[Python] 自动重启第 ${services.python.restartCount + 1} 次`);
+          startPythonService();
+        }, backoff);
+        services.python.restartCount++;
+      } else if (!restartMgr.canRestart(key)) {
+        log('ERROR', '[Python] 重启次数过多,停止自动重启,请检查问题');
+      }
     });
 
     services.python.process = child;
+
+    // ===== Python 内存监控(Windows) =====
+    if (process.platform === 'win32') {
+      startPythonMemoryMonitor(child.pid);
+    }
   } catch (e: any) {
     log('ERROR', `Python spawn 异常: ${e.message}`);
   }
 }
 
+/** 监控 Python 进程内存,超过阈值则重启 */
+let pythonMemInterval: NodeJS.Timeout | null = null;
+const PYTHON_MEM_THRESHOLD_MB = 800;
+
+function startPythonMemoryMonitor(pid: number): void {
+  // 清除旧的监控
+  if (pythonMemInterval) {
+    clearInterval(pythonMemInterval);
+    pythonMemInterval = null;
+  }
+
+  pythonMemInterval = setInterval(async () => {
+    const proc = services.python?.process;
+    if (!proc || proc.pid !== pid || proc.killed) {
+      clearInterval(pythonMemInterval!);
+      pythonMemInterval = null;
+      return;
+    }
+
+    try {
+      const memMB = await getProcessMemoryMB(pid);
+      services.python.lastMemoryMB = memMB;
+      if (memMB > PYTHON_MEM_THRESHOLD_MB) {
+        log('WARN', `[Python] 内存占用过高(${memMB}MB > ${PYTHON_MEM_THRESHOLD_MB}MB),准备重启...`);
+        proc.kill('SIGTERM');
+        // 给2秒优雅退出,然后强制杀
+        setTimeout(() => {
+          if (!proc.killed) {
+            log('WARN', '[Python] 优雅退出超时,强制杀进程');
+            try { proc.kill('SIGKILL'); } catch {}
+          }
+        }, 2000);
+      }
+    } catch (e) {
+      // 进程可能已结束,忽略
+    }
+  }, 60000); // 每60秒检查一次
+}
+
+/** 通过 tasklist 获取进程内存(Windows,MB) */
+function getProcessMemoryMB(pid: number): Promise<number> {
+  return new Promise((resolve) => {
+    const child = spawn('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
+      windowsHide: true,
+    });
+    let stdout = '';
+    child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); });
+    child.on('close', () => {
+      // 格式: "python.exe","1234","Console","1,234 KB"
+      const match = stdout.match(/"(\d+[\d,]*) KB"/);
+      if (match) {
+        const kb = parseInt(match[1].replace(/,/g, ''), 10);
+        resolve(Math.round(kb / 1024));
+      } else {
+        resolve(0);
+      }
+    });
+    child.on('error', () => resolve(0));
+  });
+}
+
 // ==================== 导出 API ====================
 
 export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
@@ -394,6 +540,12 @@ export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk:
 export function stopLocalServices(): void {
   log('INFO', '正在停止本地服务...');
 
+  // 清除 Python 内存监控定时器
+  if (pythonMemInterval) {
+    clearInterval(pythonMemInterval);
+    pythonMemInterval = null;
+  }
+
   for (const key of ['node', 'python'] as const) {
     const svc = services[key];
     if (svc.process) {

+ 61 - 0
client/electron/main.ts

@@ -4,9 +4,60 @@ const { join } = require('path');
 const fs = require('fs');
 const http = require('http');
 const https = require('https');
+const os = require('os');
 import { startLocalServices, stopLocalServices, getServiceStatus, LOCAL_NODE_URL, LOCAL_PYTHON_URL, getLogPath } from './local-services';
 
 let mainWindow: typeof BrowserWindow.prototype | null = null;
+
+// ========== 内存监控 ==========
+const MEMORY_THRESHOLD_MB = 512; // 超过 512MB 触发警告 / 清理
+let lastMemoryReport = 0;
+
+function getMemoryUsageMB(): number {
+  const used = process.memoryUsage();
+  return Math.round(used.heapUsed / 1024 / 1024);
+}
+
+function logMemory(prefix: string): void {
+  const used = getMemoryUsageMB();
+  const total = Math.round(os.totalmem() / 1024 / 1024);
+  const free = Math.round(os.freemem() / 1024 / 1024);
+  const usagePct = Math.round((used / total) * 100);
+  console.log(`[MEM] ${prefix} heap=${used}MB total=${total}MB free=${free}MB usage=${usagePct}%`);
+}
+
+function monitorMemory(): void {
+  const used = getMemoryUsageMB();
+  const now = Date.now();
+  // 每 60 秒报告一次
+  if (now - lastMemoryReport > 60000) {
+    logMemory('Electron');
+    lastMemoryReport = now;
+  }
+  // 超过阈值,触发 GC 并报告
+  if (used > MEMORY_THRESHOLD_MB) {
+    console.warn(`[MEM] Memory high (${used}MB), triggering GC...`);
+    if (global.gc) {
+      global.gc();
+    }
+    // 关闭多余的 webContents
+    if (mainWindow?.webContents) {
+      const wc = mainWindow.webContents;
+      // 尝试清理 devtools extension
+      try {
+        session.defaultSession?.webContents.forEach(w => {
+          if (w !== wc && !w.isDestroyed()) {
+            console.warn('[MEM] Closing idle webContents');
+            w.close();
+          }
+        });
+      } catch {}
+    }
+  }
+}
+
+// 启动内存监控
+setInterval(monitorMemory, 30000);
 let tray: typeof Tray.prototype | null = null;
 let isQuitting = false;
 
@@ -254,7 +305,17 @@ if (!gotTheLock) {
     }
   });
 
+  // ========== 降低 Electron 内存占用(必须在 app.whenReady 之前) ==========
+  app.disableHardwareAcceleration();
+  app.commandLine.appendSwitch('js-flags', '--max-old-space-size=512');
+  app.commandLine.appendSwitch('disable-gpu');
+  app.commandLine.appendSwitch('disable-software-rasterizer');
+  app.commandLine.appendSwitch('renderer-process-limit', '2');
+  app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
+  app.commandLine.appendSwitch('disable-renderer-backgrounding');
+
   app.whenReady().then(async () => {
+    logMemory('AppReady');
     // 先创建窗口显示 splash screen
     createWindow();
     createTray();

+ 21 - 6
server/src/routes/workDayStatistics.ts

@@ -33,14 +33,29 @@ function getPythonBin(): string {
   if (process.env.PYTHON_BIN) {
     return process.env.PYTHON_BIN;
   }
-  
-  // 2. 尝试 python 命令
-  // 3. Windows 下尝试 py 启动器
+
   if (process.platform === 'win32') {
-    // 尝试 py (Python launcher for Windows)
-    return 'py';
+    // 2. Windows 下用 py -0p 找到实际安装的 Python 路径
+    try {
+      const { execSync } = require('child_process');
+      const out = execSync('py -0p 2>nul', { encoding: 'utf-8', windowsHide: true });
+      const matches = out.match(/^(\d+\.\d+).*?:\s*(.+?)\s*$/m);
+      if (matches) {
+        const pythonPath = matches[2].trim().replace(/"/g, '');
+        // 优先找 3.x 版本
+        if (pythonPath && matches[1].startsWith('3')) {
+          return `"${pythonPath}"`;
+        }
+      }
+    } catch {}
+
+    // 3. 尝试 py 启动器
+    try {
+      require('child_process').execSync('py --version 2>nul', { encoding: 'utf-8', windowsHide: true });
+      return 'py';
+    } catch {}
   }
-  
+
   // 4. 降级到 python
   return 'python';
 }