local-services.ts 12 KB

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