local-services.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  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. const NODE_PORT = 14890;
  21. const PYTHON_PORT = 14981;
  22. const services: { node: ServiceProcess; python: ServiceProcess } = {
  23. node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false },
  24. python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false },
  25. };
  26. // ==================== 文件日志 ====================
  27. let logFilePath = '';
  28. function getLogFilePath(): string {
  29. if (!logFilePath) {
  30. try {
  31. const logDir = app.getPath('userData');
  32. logFilePath = path.join(logDir, 'local-services.log');
  33. } catch {
  34. logFilePath = path.join(process.cwd(), 'local-services.log');
  35. }
  36. }
  37. return logFilePath;
  38. }
  39. function log(level: string, ...args: unknown[]): void {
  40. const ts = new Date().toISOString();
  41. const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
  42. if (level === 'ERROR') {
  43. console.error(msg);
  44. } else {
  45. console.log(msg);
  46. }
  47. try {
  48. fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8');
  49. } catch { /* ignore */ }
  50. }
  51. // ==================== 核心工具 ====================
  52. function isPackaged(): boolean {
  53. return app.isPackaged;
  54. }
  55. function getServerDir(): string {
  56. if (isPackaged()) {
  57. return path.join(process.resourcesPath, 'server');
  58. }
  59. return path.resolve(__dirname, '..', '..', 'server');
  60. }
  61. function getPythonDir(): string {
  62. return path.join(getServerDir(), 'python');
  63. }
  64. function findPython(): string {
  65. const pythonDir = getPythonDir();
  66. const isWin = process.platform === 'win32';
  67. // 优先使用 embeddable Python(完全独立,不依赖系统 Python 安装路径)
  68. const embedPython = isWin
  69. ? path.join(pythonDir, 'python-embed', 'python.exe')
  70. : path.join(pythonDir, 'python-embed', 'bin', 'python');
  71. if (fs.existsSync(embedPython)) return embedPython;
  72. // 回退到 venv Python
  73. const venvPython = isWin
  74. ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe')
  75. : path.join(pythonDir, 'venv', 'bin', 'python');
  76. if (fs.existsSync(venvPython)) return venvPython;
  77. return isWin ? 'python' : 'python3';
  78. }
  79. function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
  80. const serverDir = getServerDir();
  81. const distEntry = path.join(serverDir, 'dist', 'app.js');
  82. const srcEntry = path.join(serverDir, 'src', 'app.ts');
  83. const isWin = process.platform === 'win32';
  84. if (isPackaged()) {
  85. return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
  86. }
  87. if (fs.existsSync(distEntry)) {
  88. return { cmd: 'node', args: [distEntry] };
  89. }
  90. const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
  91. if (fs.existsSync(tsxBin)) {
  92. return { cmd: tsxBin, args: [srcEntry] };
  93. }
  94. return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
  95. }
  96. function checkPort(port: number): Promise<boolean> {
  97. return new Promise((resolve) => {
  98. const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => {
  99. res.resume();
  100. resolve(true);
  101. });
  102. req.on('error', () => resolve(false));
  103. req.on('timeout', () => { req.destroy(); resolve(false); });
  104. });
  105. }
  106. async function waitForService(port: number, timeoutMs = 30000): Promise<boolean> {
  107. const start = Date.now();
  108. while (Date.now() - start < timeoutMs) {
  109. if (await checkPort(port)) return true;
  110. await new Promise((r) => setTimeout(r, 1000));
  111. }
  112. return false;
  113. }
  114. function getEnvFilePath(): string {
  115. return path.join(getServerDir(), '.env');
  116. }
  117. function getPlaywrightBrowsersPath(): string | null {
  118. if (isPackaged()) {
  119. const bundled = path.join(process.resourcesPath, 'playwright');
  120. if (fs.existsSync(bundled)) return bundled;
  121. }
  122. return null;
  123. }
  124. // ==================== 诊断:列出 resources 目录结构 ====================
  125. function dumpResourcesDir(): void {
  126. if (!isPackaged()) return;
  127. try {
  128. const resDir = process.resourcesPath;
  129. log('INFO', `resources 目录: ${resDir}`);
  130. const topEntries = fs.readdirSync(resDir);
  131. log('INFO', `resources/ 内容: ${topEntries.join(', ')}`);
  132. const serverDir = path.join(resDir, 'server');
  133. if (fs.existsSync(serverDir)) {
  134. const serverEntries = fs.readdirSync(serverDir);
  135. log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`);
  136. const distDir = path.join(serverDir, 'dist');
  137. if (fs.existsSync(distDir)) {
  138. const distEntries = fs.readdirSync(distDir).slice(0, 20);
  139. log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`);
  140. } else {
  141. log('ERROR', 'resources/server/dist/ 不存在!');
  142. }
  143. const pyDir = path.join(serverDir, 'python');
  144. if (fs.existsSync(pyDir)) {
  145. const pyEntries = fs.readdirSync(pyDir);
  146. log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`);
  147. } else {
  148. log('ERROR', 'resources/server/python/ 不存在!');
  149. }
  150. const nmDir = path.join(serverDir, 'node_modules');
  151. log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`);
  152. } else {
  153. log('ERROR', 'resources/server/ 目录不存在!');
  154. }
  155. } catch (e: any) {
  156. log('ERROR', `诊断目录结构失败: ${e.message}`);
  157. }
  158. }
  159. // ==================== 启动服务 ====================
  160. function startNodeServer(): void {
  161. const serverDir = getServerDir();
  162. if (!fs.existsSync(serverDir)) {
  163. log('ERROR', `server 目录不存在: ${serverDir}`);
  164. return;
  165. }
  166. const { cmd, args, useElectronAsNode } = findNodeRunner();
  167. log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`);
  168. log('INFO', `工作目录: ${serverDir}`);
  169. const distApp = path.join(serverDir, 'dist', 'app.js');
  170. log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`);
  171. const envFile = getEnvFilePath();
  172. log('INFO', `.env 存在: ${fs.existsSync(envFile)}`);
  173. const env: Record<string, string> = {
  174. ...process.env as Record<string, string>,
  175. PORT: String(NODE_PORT),
  176. HOST: '127.0.0.1',
  177. PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
  178. USE_REDIS_QUEUE: 'false',
  179. };
  180. // 打包模式下,uploads 目录使用 userData(可写),避免 Program Files 权限问题
  181. if (isPackaged()) {
  182. const uploadsDir = path.join(app.getPath('userData'), 'uploads');
  183. if (!fs.existsSync(uploadsDir)) {
  184. try { fs.mkdirSync(uploadsDir, { recursive: true }); } catch { /* ignore */ }
  185. }
  186. env.UPLOAD_PATH = uploadsDir;
  187. log('INFO', `UPLOAD_PATH: ${uploadsDir}`);
  188. }
  189. if (useElectronAsNode) {
  190. env.ELECTRON_RUN_AS_NODE = '1';
  191. log('INFO', 'ELECTRON_RUN_AS_NODE=1');
  192. }
  193. const pwPath = getPlaywrightBrowsersPath();
  194. if (pwPath) {
  195. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  196. }
  197. try {
  198. const child = spawn(cmd, args, {
  199. cwd: serverDir,
  200. env,
  201. stdio: ['ignore', 'pipe', 'pipe'],
  202. shell: false,
  203. windowsHide: true,
  204. });
  205. log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
  206. child.on('error', (err: Error) => {
  207. log('ERROR', `Node spawn error: ${err.message}`);
  208. });
  209. child.stdout?.on('data', (data: Buffer) => {
  210. const msg = data.toString().trim();
  211. if (msg) log('NODE', msg);
  212. if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
  213. services.node.ready = true;
  214. }
  215. });
  216. child.stderr?.on('data', (data: Buffer) => {
  217. const msg = data.toString().trim();
  218. if (msg) log('NODE-ERR', msg);
  219. });
  220. child.on('exit', (code: number | null, signal: string | null) => {
  221. log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
  222. services.node.process = null;
  223. services.node.ready = false;
  224. });
  225. services.node.process = child;
  226. } catch (e: any) {
  227. log('ERROR', `Node spawn 异常: ${e.message}`);
  228. }
  229. }
  230. function startPythonService(): void {
  231. const pythonDir = getPythonDir();
  232. const appPy = path.join(pythonDir, 'app.py');
  233. if (!fs.existsSync(appPy)) {
  234. log('ERROR', `Python 入口不存在: ${appPy}`);
  235. return;
  236. }
  237. const pythonCmd = findPython();
  238. const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
  239. log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
  240. log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
  241. log('INFO', `工作目录: ${pythonDir}`);
  242. const env: Record<string, string> = {
  243. ...process.env as Record<string, string>,
  244. PYTHONUNBUFFERED: '1',
  245. PYTHONIOENCODING: 'utf-8',
  246. };
  247. const pwPath = getPlaywrightBrowsersPath();
  248. if (pwPath) {
  249. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  250. log('INFO', `Playwright 浏览器路径: ${pwPath}`);
  251. }
  252. try {
  253. const child = spawn(pythonCmd, args, {
  254. cwd: pythonDir,
  255. env,
  256. stdio: ['ignore', 'pipe', 'pipe'],
  257. shell: false,
  258. windowsHide: true,
  259. });
  260. log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
  261. child.on('error', (err: Error) => {
  262. log('ERROR', `Python spawn error: ${err.message}`);
  263. });
  264. child.stdout?.on('data', (data: Buffer) => {
  265. const msg = data.toString().trim();
  266. if (msg) log('PYTHON', msg);
  267. if (msg.includes('Running on') || msg.includes('启动服务')) {
  268. services.python.ready = true;
  269. }
  270. });
  271. child.stderr?.on('data', (data: Buffer) => {
  272. const msg = data.toString().trim();
  273. if (msg) log('PYTHON-ERR', msg);
  274. });
  275. child.on('exit', (code: number | null, signal: string | null) => {
  276. log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
  277. services.python.process = null;
  278. services.python.ready = false;
  279. });
  280. services.python.process = child;
  281. } catch (e: any) {
  282. log('ERROR', `Python spawn 异常: ${e.message}`);
  283. }
  284. }
  285. // ==================== 导出 API ====================
  286. export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
  287. // 清空旧日志
  288. try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
  289. log('INFO', '========== 启动本地服务 ==========');
  290. log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
  291. log('INFO', `execPath: ${process.execPath}`);
  292. log('INFO', `resourcesPath: ${process.resourcesPath}`);
  293. log('INFO', `Server 目录: ${getServerDir()}`);
  294. log('INFO', `Python 目录: ${getPythonDir()}`);
  295. log('INFO', `日志文件: ${getLogFilePath()}`);
  296. dumpResourcesDir();
  297. const [nodeAlive, pythonAlive] = await Promise.all([
  298. checkPort(NODE_PORT),
  299. checkPort(PYTHON_PORT),
  300. ]);
  301. if (nodeAlive) {
  302. log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
  303. services.node.ready = true;
  304. } else {
  305. startNodeServer();
  306. }
  307. if (pythonAlive) {
  308. log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
  309. services.python.ready = true;
  310. } else {
  311. startPythonService();
  312. }
  313. const [nodeOk, pythonOk] = await Promise.all([
  314. services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
  315. services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
  316. ]);
  317. services.node.ready = nodeOk;
  318. services.python.ready = pythonOk;
  319. log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
  320. return { nodeOk, pythonOk };
  321. }
  322. export function stopLocalServices(): void {
  323. log('INFO', '正在停止本地服务...');
  324. for (const key of ['node', 'python'] as const) {
  325. const svc = services[key];
  326. if (svc.process) {
  327. log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
  328. try {
  329. if (process.platform === 'win32') {
  330. spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
  331. } else {
  332. svc.process.kill('SIGTERM');
  333. }
  334. } catch (e: any) {
  335. log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
  336. }
  337. svc.process = null;
  338. svc.ready = false;
  339. }
  340. }
  341. }
  342. export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
  343. return {
  344. node: { ready: services.node.ready, port: NODE_PORT },
  345. python: { ready: services.python.ready, port: PYTHON_PORT },
  346. };
  347. }
  348. export function getLogPath(): string {
  349. return getLogFilePath();
  350. }
  351. export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  352. export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;