| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- /**
- * 本地服务管理器
- * 在 Electron 主进程中自动启动和管理 Node 服务端与 Python 发布服务
- *
- * 路径策略:
- * 开发模式:直接引用源码目录 (project-root/server/)
- * 打包模式:引用 extraResources 打包进来的 resources/server/
- */
- const { spawn, execSync } = require('child_process');
- const path = require('path');
- const fs = require('fs');
- const http = require('http');
- const { app } = require('electron');
- interface ServiceProcess {
- process: ReturnType<typeof spawn> | null;
- 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, restartCount: 0, lastRestartTime: 0 },
- python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false, restartCount: 0, lastRestartTime: 0 },
- };
- // ==================== 文件日志 ====================
- let logFilePath = '';
- function getLogFilePath(): string {
- if (!logFilePath) {
- try {
- const logDir = app.getPath('userData');
- logFilePath = path.join(logDir, 'local-services.log');
- } catch {
- logFilePath = path.join(process.cwd(), 'local-services.log');
- }
- }
- return logFilePath;
- }
- function log(level: string, ...args: unknown[]): void {
- const ts = new Date().toISOString();
- const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
- if (level === 'ERROR') {
- console.error(msg);
- } else {
- console.log(msg);
- }
- try {
- fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8');
- } catch { /* ignore */ }
- }
- // ==================== 核心工具 ====================
- function isPackaged(): boolean {
- return app.isPackaged;
- }
- function getServerDir(): string {
- if (isPackaged()) {
- return path.join(process.resourcesPath, 'server');
- }
- return path.resolve(__dirname, '..', '..', 'server');
- }
- function getPythonDir(): string {
- return path.join(getServerDir(), 'python');
- }
- function findPython(): string {
- const pythonDir = getPythonDir();
- const isWin = process.platform === 'win32';
- // 优先使用 embeddable Python(完全独立,不依赖系统 Python 安装路径)
- const embedPython = isWin
- ? path.join(pythonDir, 'python-embed', 'python.exe')
- : path.join(pythonDir, 'python-embed', 'bin', 'python');
- if (fs.existsSync(embedPython)) return embedPython;
- // 回退到 venv Python
- const venvPython = isWin
- ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe')
- : path.join(pythonDir, 'venv', 'bin', 'python');
- if (fs.existsSync(venvPython)) return venvPython;
- return isWin ? 'python' : 'python3';
- }
- function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
- const serverDir = getServerDir();
- const distEntry = path.join(serverDir, 'dist', 'app.js');
- const srcEntry = path.join(serverDir, 'src', 'app.ts');
- const isWin = process.platform === 'win32';
- if (isPackaged()) {
- return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
- }
- if (fs.existsSync(distEntry)) {
- return { cmd: 'node', args: [distEntry] };
- }
- const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
- if (fs.existsSync(tsxBin)) {
- return { cmd: tsxBin, args: [srcEntry] };
- }
- return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
- }
- function checkPort(port: number): Promise<boolean> {
- return new Promise((resolve) => {
- const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => {
- res.resume();
- resolve(true);
- });
- req.on('error', () => resolve(false));
- req.on('timeout', () => { req.destroy(); resolve(false); });
- });
- }
- async function waitForService(port: number, timeoutMs = 30000): Promise<boolean> {
- const start = Date.now();
- while (Date.now() - start < timeoutMs) {
- if (await checkPort(port)) return true;
- await new Promise((r) => setTimeout(r, 1000));
- }
- return false;
- }
- function getEnvFilePath(): string {
- return path.join(getServerDir(), '.env');
- }
- function getPlaywrightBrowsersPath(): string | null {
- if (isPackaged()) {
- const bundled = path.join(process.resourcesPath, 'playwright');
- if (fs.existsSync(bundled)) return bundled;
- }
- return null;
- }
- // ==================== 诊断:列出 resources 目录结构 ====================
- function dumpResourcesDir(): void {
- if (!isPackaged()) return;
- try {
- const resDir = process.resourcesPath;
- log('INFO', `resources 目录: ${resDir}`);
- const topEntries = fs.readdirSync(resDir);
- log('INFO', `resources/ 内容: ${topEntries.join(', ')}`);
- const serverDir = path.join(resDir, 'server');
- if (fs.existsSync(serverDir)) {
- const serverEntries = fs.readdirSync(serverDir);
- log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`);
- const distDir = path.join(serverDir, 'dist');
- if (fs.existsSync(distDir)) {
- const distEntries = fs.readdirSync(distDir).slice(0, 20);
- log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`);
- } else {
- log('ERROR', 'resources/server/dist/ 不存在!');
- }
- const pyDir = path.join(serverDir, 'python');
- if (fs.existsSync(pyDir)) {
- const pyEntries = fs.readdirSync(pyDir);
- log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`);
- } else {
- log('ERROR', 'resources/server/python/ 不存在!');
- }
- const nmDir = path.join(serverDir, 'node_modules');
- log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`);
- } else {
- log('ERROR', 'resources/server/ 目录不存在!');
- }
- } catch (e: any) {
- log('ERROR', `诊断目录结构失败: ${e.message}`);
- }
- }
- // ==================== 启动服务 ====================
- function startNodeServer(): void {
- const serverDir = getServerDir();
- if (!fs.existsSync(serverDir)) {
- log('ERROR', `server 目录不存在: ${serverDir}`);
- return;
- }
- const { cmd, args, useElectronAsNode } = findNodeRunner();
- log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`);
- log('INFO', `工作目录: ${serverDir}`);
- const distApp = path.join(serverDir, 'dist', 'app.js');
- log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`);
- const envFile = getEnvFilePath();
- log('INFO', `.env 存在: ${fs.existsSync(envFile)}`);
- const env: Record<string, string> = {
- ...process.env as Record<string, string>,
- PORT: String(NODE_PORT),
- 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',
- // 强制 Node.js 服务内的 Python 调用使用 embeddable Python(含 openpyxl)
- PYTHON_BIN: findPython(),
- };
- // 打包模式下,uploads 目录使用 userData(可写),避免 Program Files 权限问题
- if (isPackaged()) {
- const uploadsDir = path.join(app.getPath('userData'), 'uploads');
- if (!fs.existsSync(uploadsDir)) {
- try { fs.mkdirSync(uploadsDir, { recursive: true }); } catch { /* ignore */ }
- }
- env.UPLOAD_PATH = uploadsDir;
- log('INFO', `UPLOAD_PATH: ${uploadsDir}`);
- }
- if (useElectronAsNode) {
- env.ELECTRON_RUN_AS_NODE = '1';
- log('INFO', 'ELECTRON_RUN_AS_NODE=1');
- }
- const pwPath = getPlaywrightBrowsersPath();
- if (pwPath) {
- env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
- }
- try {
- const child = spawn(cmd, args, {
- cwd: serverDir,
- env,
- stdio: ['ignore', 'pipe', 'pipe'],
- shell: false,
- windowsHide: true,
- });
- log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
- child.on('error', (err: Error) => {
- log('ERROR', `Node spawn error: ${err.message}`);
- });
- child.stdout?.on('data', (data: Buffer) => {
- const msg = data.toString().trim();
- if (msg) log('NODE', msg);
- if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
- services.node.ready = true;
- }
- });
- child.stderr?.on('data', (data: Buffer) => {
- const msg = data.toString().trim();
- if (msg) log('NODE-ERR', msg);
- });
- child.on('exit', (code: number | null, signal: string | null) => {
- 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;
- } catch (e: any) {
- log('ERROR', `Node spawn 异常: ${e.message}`);
- }
- }
- function startPythonService(): void {
- const pythonDir = getPythonDir();
- const appPy = path.join(pythonDir, 'app.py');
- if (!fs.existsSync(appPy)) {
- log('ERROR', `Python 入口不存在: ${appPy}`);
- return;
- }
- const pythonCmd = findPython();
- const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
- log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
- log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
- log('INFO', `工作目录: ${pythonDir}`);
- const env: Record<string, string> = {
- ...process.env as Record<string, string>,
- PYTHONUNBUFFERED: '1',
- PYTHONIOENCODING: 'utf-8',
- PYTHONDONTWRITEBYTECODE: '1',
- // 强制 Python Playwright 使用无头模式,避免弹出有头浏览器
- HEADLESS: 'true',
- };
- const pwPath = getPlaywrightBrowsersPath();
- if (pwPath) {
- env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
- log('INFO', `Playwright 浏览器路径: ${pwPath}`);
- }
- try {
- const child = spawn(pythonCmd, args, {
- cwd: pythonDir,
- env,
- stdio: ['ignore', 'pipe', 'pipe'],
- shell: false,
- windowsHide: true,
- });
- log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
- child.on('error', (err: Error) => {
- log('ERROR', `Python spawn error: ${err.message}`);
- });
- child.stdout?.on('data', (data: Buffer) => {
- const msg = data.toString().trim();
- if (msg) log('PYTHON', msg);
- if (msg.includes('Running on') || msg.includes('启动服务')) {
- services.python.ready = true;
- }
- });
- child.stderr?.on('data', (data: Buffer) => {
- const msg = data.toString().trim();
- if (msg) log('PYTHON-ERR', msg);
- });
- child.on('exit', (code: number | null, signal: string | null) => {
- 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 }> {
- // 清空旧日志
- try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
- log('INFO', '========== 启动本地服务 ==========');
- log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
- log('INFO', `execPath: ${process.execPath}`);
- log('INFO', `resourcesPath: ${process.resourcesPath}`);
- log('INFO', `Server 目录: ${getServerDir()}`);
- log('INFO', `Python 目录: ${getPythonDir()}`);
- log('INFO', `日志文件: ${getLogFilePath()}`);
- dumpResourcesDir();
- const [nodeAlive, pythonAlive] = await Promise.all([
- checkPort(NODE_PORT),
- checkPort(PYTHON_PORT),
- ]);
- if (nodeAlive) {
- log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
- services.node.ready = true;
- } else {
- startNodeServer();
- }
- if (pythonAlive) {
- log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
- services.python.ready = true;
- } else {
- startPythonService();
- }
- const [nodeOk, pythonOk] = await Promise.all([
- services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
- services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
- ]);
- services.node.ready = nodeOk;
- services.python.ready = pythonOk;
- log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
- return { nodeOk, 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) {
- log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
- try {
- if (process.platform === 'win32') {
- spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
- } else {
- svc.process.kill('SIGTERM');
- }
- } catch (e: any) {
- log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
- }
- svc.process = null;
- svc.ready = false;
- }
- }
- }
- export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
- return {
- node: { ready: services.node.ready, port: NODE_PORT },
- python: { ready: services.python.ready, port: PYTHON_PORT },
- };
- }
- export function getLogPath(): string {
- return getLogFilePath();
- }
- export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
- export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;
|