const { spawn, exec, execFile } = require('child_process'); const path = require('path'); const fs = require('fs'); const { resolveRuntimeStrategy } = require('./path-utils'); /** * MySQL 进程管理器 * 使用进程模式启动 MySQL,无需管理员权限 */ class MySQLProcessManager { constructor(logWindowManager = null) { const isDev = !process.resourcesPath; const baseDir = isDev ? path.join(__dirname, '..') : path.dirname(process.resourcesPath); const pathStrategy = resolveRuntimeStrategy(baseDir); this.baseDir = baseDir; this.pathStrategy = pathStrategy; this.sourceMysqlPath = path.join(baseDir, 'mysql'); this.mysqlPath = pathStrategy.usesSafePaths ? path.join(pathStrategy.safeRuntimeRoot, 'mysql') : this.sourceMysqlPath; this.binPath = path.join(this.mysqlPath, 'bin'); this.dataPath = path.join(this.mysqlPath, 'data'); this.configFile = path.join(this.mysqlPath, 'my.ini'); this.processRecordFile = path.join(this.mysqlPath, '.running-process.json'); this.logWindowManager = logWindowManager; // 进程模式:保存 MySQL 进程引用 this.mysqlProcess = null; this.currentPort = null; } // 睡眠函数 sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 日志输出辅助方法 log(type, message) { console.log(`[MySQL] ${message}`); if (this.logWindowManager) { this.logWindowManager.addLog(type, `[MySQL] ${message}`); } } normalizeComparablePath(target = '') { return String(target).replace(/"/g, '').replace(/\//g, '\\').toLowerCase(); } saveProcessRecord(record = {}) { try { fs.mkdirSync(path.dirname(this.processRecordFile), { recursive: true }); fs.writeFileSync(this.processRecordFile, JSON.stringify(record, null, 2), 'utf-8'); } catch (error) { this.log('warn', `写入 MySQL 进程标记失败: ${error.message}`); } } getProcessRecord() { try { if (!fs.existsSync(this.processRecordFile)) { return null; } return JSON.parse(fs.readFileSync(this.processRecordFile, 'utf-8')); } catch (error) { this.log('warn', `读取 MySQL 进程标记失败: ${error.message}`); return null; } } clearProcessRecord() { try { if (fs.existsSync(this.processRecordFile)) { fs.unlinkSync(this.processRecordFile); } } catch (error) { this.log('warn', `清理 MySQL 进程标记失败: ${error.message}`); } } execFileCommand(command, args = []) { return new Promise((resolve, reject) => { execFile(command, args, { encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => { if (error) { const err = new Error(error.message || stderr || stdout || 'Command execution failed'); err.code = error.code; err.stdout = stdout; err.stderr = stderr; reject(err); } else { resolve({ stdout, stderr }); } }); }); } async getProcessInfoByPid(pid) { if (!pid) { return null; } try { const script = `$proc = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($proc) { [PSCustomObject]@{ executablePath = $proc.ExecutablePath; commandLine = $proc.CommandLine; name = $proc.Name } | ConvertTo-Json -Compress }`; const { stdout } = await this.execFileCommand('powershell.exe', ['-NoProfile', '-Command', script]); const raw = (stdout || '').trim(); return raw ? JSON.parse(raw) : null; } catch (error) { return null; } } async isOwnMySQLProcess(pid, record = this.getProcessRecord()) { const processInfo = await this.getProcessInfoByPid(pid); if (!processInfo) { return false; } const expectedExe = this.normalizeComparablePath(path.join(this.binPath, 'mysqld.exe')); const expectedConfig = this.normalizeComparablePath((record && record.configFile) || this.configFile); const executablePath = this.normalizeComparablePath(processInfo.executablePath); const commandLine = this.normalizeComparablePath(processInfo.commandLine); if (executablePath !== expectedExe) { return false; } // 关键校验:仅在命令行绑定到当前应用自己的 my.ini 时才允许清理。 return !expectedConfig || commandLine.includes(`--defaults-file=${expectedConfig}`) || commandLine.includes(expectedConfig); } async waitForProcessExit(pid, timeoutMs = 3000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const processInfo = await this.getProcessInfoByPid(pid); if (!processInfo) { return true; } await this.sleep(200); } return false; } async terminateTrackedProcess(record = this.getProcessRecord(), reason = '正在停止 MySQL 进程...') { const pid = this.mysqlProcess?.pid || record?.pid; const port = this.currentPort || record?.port || this.readPortFromConfig(); if (!pid) { this.log('system', '未找到可清理的 MySQL 进程标记'); this.clearProcessRecord(); return false; } const isOwnProcess = await this.isOwnMySQLProcess(pid, record); if (!isOwnProcess) { this.log('warn', `PID ${pid} 不是当前应用的 MySQL 进程,跳过清理`); this.clearProcessRecord(); if (this.mysqlProcess && this.mysqlProcess.pid === pid) { this.mysqlProcess = null; } return false; } this.log('system', `${reason} PID=${pid}${port ? `, 端口=${port}` : ''}`); // 优先按当前应用自己的端口优雅关闭,避免强杀带来的数据损坏风险。 if (port) { try { const mysqladmin = path.join(this.binPath, 'mysqladmin.exe'); await this.execCommand(`"${mysqladmin}" -u root -P ${port} shutdown`); const exited = await this.waitForProcessExit(pid, 5000); if (exited) { this.log('success', 'MySQL 进程已优雅关闭'); this.clearProcessRecord(); if (this.mysqlProcess && this.mysqlProcess.pid === pid) { this.mysqlProcess = null; } this.currentPort = null; return true; } } catch (error) { this.log('warn', `优雅关闭失败,准备按 PID 清理: ${error.message}`); } } await this.execCommand(`taskkill /F /T /PID ${pid}`); await this.waitForProcessExit(pid, 3000); this.log('success', 'MySQL 进程已按 PID 清理'); this.clearProcessRecord(); if (this.mysqlProcess && this.mysqlProcess.pid === pid) { this.mysqlProcess = null; } this.currentPort = null; return true; } /** * 递归复制目录。 * 仅当文件不存在、大小变化或源文件时间更新时才覆盖,避免每次启动全量重写。 */ copyDirectorySync(sourceDir, targetDir, options = {}) { const { excludeTopLevelDirs = new Set(), excludeFileNames = new Set(), excludeFileExtensions = new Set() } = options; if (!fs.existsSync(sourceDir)) { return; } if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); entries.forEach((entry) => { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); const isTopLevelEntry = sourceDir === this.sourceMysqlPath; if (entry.isDirectory()) { if (isTopLevelEntry && excludeTopLevelDirs.has(entry.name)) { return; } this.copyDirectorySync(sourcePath, targetPath, options); return; } const extension = path.extname(entry.name).toLowerCase(); if (excludeFileNames.has(entry.name) || excludeFileExtensions.has(extension)) { return; } let shouldCopy = !fs.existsSync(targetPath); if (!shouldCopy) { const sourceStat = fs.statSync(sourcePath); const targetStat = fs.statSync(targetPath); shouldCopy = sourceStat.size !== targetStat.size || sourceStat.mtimeMs > targetStat.mtimeMs; } if (shouldCopy) { fs.copyFileSync(sourcePath, targetPath); } }); } /** * 非 ASCII 路径下,将 MySQL 运行环境同步到英文安全目录。 */ ensureRuntimeEnvironment() { if (!this.pathStrategy.usesSafePaths) { return; } this.log('system', '检测到应用路径包含非 ASCII 字符,启用 MySQL 英文安全运行目录'); this.log('system', `MySQL 源目录: ${this.sourceMysqlPath}`); this.log('system', `MySQL 运行目录: ${this.mysqlPath}`); this.log('system', `MySQL 数据目录: ${this.dataPath}`); fs.mkdirSync(this.mysqlPath, { recursive: true }); fs.mkdirSync(path.dirname(this.dataPath), { recursive: true }); this.copyDirectorySync(this.sourceMysqlPath, this.mysqlPath, { excludeTopLevelDirs: new Set(['data', 'data_backup']), excludeFileNames: new Set(['my.ini', 'mysql_error.log', 'mysql_slow.log']), excludeFileExtensions: new Set(['.pid', '.err']) }); this.ensureSeedData(); } /** * 首次进入英文安全路径模式时,将打包内预置数据库复制到安全数据目录。 */ ensureSeedData() { const hasTargetData = fs.existsSync(this.dataPath) && fs.readdirSync(this.dataPath).length > 0; if (hasTargetData) { this.log('system', '检测到已有 MySQL 安全数据目录,直接复用'); return; } const sourceDataPath = path.join(this.sourceMysqlPath, 'data'); if (fs.existsSync(sourceDataPath) && fs.readdirSync(sourceDataPath).length > 0) { this.log('system', '首次启动,正在复制预置 MySQL 数据到安全数据目录...'); this.copyDirectorySync(sourceDataPath, this.dataPath); this.log('success', '预置 MySQL 数据复制完成'); return; } fs.mkdirSync(this.dataPath, { recursive: true }); this.log('warn', '未找到预置 MySQL 数据目录,后续将按空库初始化'); } /** * 执行命令并返回Promise */ execCommand(command) { return new Promise((resolve, reject) => { exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => { if (error) { const err = new Error(error.message || stderr || stdout || 'Command execution failed'); err.code = error.code; err.stdout = stdout; err.stderr = stderr; reject(err); } else { resolve({ stdout, stderr }); } }); }); } /** * 读取配置文件中的端口 */ readPortFromConfig() { try { if (fs.existsSync(this.configFile)) { const content = fs.readFileSync(this.configFile, 'utf-8'); const match = content.match(/port\s*=\s*(\d+)/); return match ? parseInt(match[1]) : 3306; } } catch (error) { console.error('[MySQL] Failed to read port from config:', error); } return 3306; } /** * 生成 my.ini 配置文件 */ generateMyIni(port) { const basedir = this.mysqlPath.replace(/\\/g, '/'); const datadir = this.dataPath.replace(/\\/g, '/'); const config = `[mysqld] # 基础路径配置 basedir=${basedir} datadir=${datadir} # 网络配置 port=${port} bind-address=0.0.0.0 # 字符集配置 character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci # 性能优化 max_connections=200 innodb_buffer_pool_size=128M innodb_log_file_size=64M # 日志配置 log_error=${basedir}/mysql_error.log slow_query_log=1 slow_query_log_file=${basedir}/mysql_slow.log long_query_time=2 # 安全配置 sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION [client] default-character-set=utf8mb4 `; fs.writeFileSync(this.configFile, config, 'utf-8'); this.log('system', `生成配置文件,端口: ${port}`); } /** * 初始化数据库 */ async initializeDatabase() { return new Promise((resolve, reject) => { this.log('system', '正在初始化 MySQL 数据库...'); const mysqld = path.join(this.binPath, 'mysqld.exe'); const initProcess = spawn(mysqld, [ '--defaults-file=' + this.configFile, '--initialize-insecure' ], { cwd: this.mysqlPath, stdio: 'pipe' }); initProcess.stdout.on('data', (data) => { this.log('system', data.toString().trim()); }); initProcess.stderr.on('data', (data) => { this.log('system', data.toString().trim()); }); initProcess.on('close', (code) => { if (code === 0) { this.log('success', '数据库初始化成功'); resolve(); } else { reject(new Error(`数据库初始化失败,退出码: ${code}`)); } }); initProcess.on('error', (err) => { reject(err); }); }); } /** * 检查数据库是否已初始化 */ isDatabaseInitialized() { return fs.existsSync(this.dataPath) && fs.readdirSync(this.dataPath).length > 0; } /** * 检查是否有残留的 mysqld 进程 */ async checkAndKillOrphanProcess() { const record = this.getProcessRecord(); if (!record || !record.pid) { return; } const isOwnProcess = await this.isOwnMySQLProcess(record.pid, record); if (!isOwnProcess) { this.log('warn', `检测到失效的 MySQL 进程标记 PID=${record.pid},已跳过清理并移除标记`); this.clearProcessRecord(); return; } this.log('warn', `检测到当前应用上次遗留的 MySQL 进程 PID=${record.pid},开始定向清理...`); await this.terminateTrackedProcess(record, '正在清理当前应用遗留的 MySQL 进程...'); } /** * 进程模式:启动 MySQL 进程 */ async startMySQLProcess(port) { return new Promise((resolve, reject) => { const mysqld = path.join(this.binPath, 'mysqld.exe'); this.log('system', '正在启动 MySQL 进程...'); this.log('system', `可执行文件: ${mysqld}`); this.log('system', `配置文件: ${this.configFile}`); // 检查文件是否存在 if (!fs.existsSync(mysqld)) { return reject(new Error(`mysqld.exe 不存在: ${mysqld}`)); } if (!fs.existsSync(this.configFile)) { return reject(new Error(`配置文件不存在: ${this.configFile}`)); } const mysqlProcess = spawn(mysqld, [ `--defaults-file=${this.configFile}`, '--console' ], { cwd: this.mysqlPath, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); this.mysqlProcess = mysqlProcess; this.currentPort = port; this.saveProcessRecord({ pid: mysqlProcess.pid, port, executablePath: mysqld, configFile: this.configFile, mysqlPath: this.mysqlPath, createdAt: new Date().toISOString() }); let startupComplete = false; let startupTimeout = null; // 监听 stderr(MySQL 主要输出在 stderr) mysqlProcess.stderr.on('data', (data) => { const msg = data.toString().trim(); if (msg) { // 检查启动成功标志 if (msg.includes('ready for connections') || msg.includes('port:')) { if (!startupComplete) { startupComplete = true; if (startupTimeout) clearTimeout(startupTimeout); this.log('success', 'MySQL 进程已就绪'); resolve(true); } } // 输出日志(过滤一些不重要的信息) if (!msg.includes('[Note]') || msg.includes('error') || msg.includes('ready')) { this.log('system', `[mysqld] ${msg}`); } } }); mysqlProcess.stdout.on('data', (data) => { const msg = data.toString().trim(); if (msg) { this.log('system', `[mysqld] ${msg}`); } }); mysqlProcess.on('error', (err) => { this.log('error', `MySQL 进程启动失败: ${err.message}`); this.clearProcessRecord(); this.mysqlProcess = null; reject(err); }); mysqlProcess.on('exit', (code, signal) => { this.log('system', `MySQL 进程退出,代码: ${code}, 信号: ${signal}`); const record = this.getProcessRecord(); if (record && record.pid === mysqlProcess.pid) { this.clearProcessRecord(); } this.mysqlProcess = null; this.currentPort = null; // 如果还没完成启动就退出了,说明启动失败 if (!startupComplete) { reject(new Error(`MySQL 进程异常退出,代码: ${code}`)); } }); // 超时处理(30秒) startupTimeout = setTimeout(() => { if (!startupComplete && mysqlProcess) { // 进程还在运行,认为启动成功 startupComplete = true; this.log('success', 'MySQL 进程启动完成(超时检测)'); resolve(true); } }, 30000); }); } /** * 进程模式:停止 MySQL 进程 */ async stopMySQLProcess() { const record = this.getProcessRecord(); if (!this.mysqlProcess && !record) { this.log('system', 'MySQL 进程未运行'); return; } await this.terminateTrackedProcess(record, '正在停止当前应用自己的 MySQL 进程...'); } /** * 检查 MySQL 进程是否在运行 */ isMySQLRunning() { return this.mysqlProcess !== null && !this.mysqlProcess.killed; } /** * 主流程:确保 MySQL 运行(进程模式) * @param {function} findAvailablePort - 查找可用端口的函数 * @param {function} waitForPort - 等待端口就绪的函数 * @returns {Promise} 返回 MySQL 运行的端口 */ async ensureServiceRunning(findAvailablePort, waitForPort) { this.ensureRuntimeEnvironment(); this.log('system', '开始 MySQL 进程检查流程(进程模式)'); this.log('system', `路径策略: ${this.pathStrategy.usesSafePaths ? '英文安全路径模式' : '应用目录直启模式'}`); this.log('system', `MySQL 路径: ${this.mysqlPath}`); this.log('system', `配置文件: ${this.configFile}`); // 1. 检查是否已有 MySQL 进程在运行 if (this.isMySQLRunning()) { const port = this.currentPort || this.readPortFromConfig(); this.log('success', `MySQL 进程已在运行,端口: ${port}`); return port; } // 2. 检查并清理残留进程 await this.checkAndKillOrphanProcess(); // 3. 查找可用端口 const port = await findAvailablePort(3306, 100); this.log('system', `找到可用端口: ${port}`); // 4. 检查数据库是否已初始化 if (!this.isDatabaseInitialized()) { this.log('system', '数据库未初始化,开始初始化...'); this.generateMyIni(port); await this.initializeDatabase(); } else { this.log('system', '检测到已有数据库目录'); // 更新配置文件(可能端口变了) this.generateMyIni(port); } // 5. 启动 MySQL 进程 this.log('system', '启动 MySQL 进程...'); await this.startMySQLProcess(port); // 6. 等待端口就绪 const finalPort = this.readPortFromConfig(); this.currentPort = finalPort; this.log('system', `等待端口 ${finalPort} 就绪...`); const portReady = await waitForPort(finalPort, 30000); if (!portReady) { throw new Error(`MySQL 端口 ${finalPort} 未能在 30 秒内就绪`); } this.log('success', `MySQL 进程启动成功,端口: ${finalPort}`); return finalPort; } } module.exports = MySQLProcessManager;