Files
pqs-9100_client/scripts/mysql-service-manager.js
2025-11-27 20:50:59 +08:00

681 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { spawn, exec } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* MySQL Windows 服务管理器
* 将MySQL安装为Windows服务 mysql9100持久运行
*/
class MySQLServiceManager {
constructor(logWindowManager = null) {
const isDev = !process.resourcesPath;
const baseDir = isDev
? path.join(__dirname, '..')
: path.dirname(process.resourcesPath);
this.mysqlPath = path.join(baseDir, 'mysql');
this.binPath = path.join(this.mysqlPath, 'bin');
this.dataPath = path.join(this.mysqlPath, 'data');
this.serviceName = 'mysql9100';
this.configFile = path.join(this.mysqlPath, 'my.ini'); // 使用标准的 my.ini
this.logWindowManager = logWindowManager; // 用于输出日志到文件
}
// 睡眠函数
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 日志输出辅助方法
log(type, message) {
console.log(`[MySQL Service] ${message}`);
if (this.logWindowManager) {
this.logWindowManager.addLog(type, `[MySQL] ${message}`);
}
}
/**
* 执行命令并返回Promise
*/
execCommand(command) {
return new Promise((resolve, reject) => {
exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => {
if (error) {
// 创建标准 Error 对象,确保有 message 属性
const err = new Error(error.message || stderr || stdout || 'Command execution failed');
err.code = error.code;
err.stdout = stdout;
err.stderr = stderr;
err.command = command;
err.originalError = error;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
}
/**
* 检查服务是否存在
*/
async checkServiceExists() {
try {
const { stdout } = await this.execCommand(`sc query ${this.serviceName}`);
return stdout.includes('SERVICE_NAME');
} catch (error) {
return false;
}
}
/**
* 获取服务状态
* @returns {string} 'RUNNING' | 'STOPPED' | 'NOT_EXISTS'
*/
async getServiceStatus() {
try {
const { stdout } = await this.execCommand(`sc query ${this.serviceName}`);
if (stdout.includes('RUNNING')) {
return 'RUNNING';
} else if (stdout.includes('STOPPED')) {
return 'STOPPED';
} else {
return 'UNKNOWN';
}
} catch (error) {
return 'NOT_EXISTS';
}
}
/**
* 检查服务路径是否匹配当前环境
* @returns {Promise<boolean>} true=路径匹配false=路径不匹配
*/
async checkServicePath() {
try {
const { stdout } = await this.execCommand(`sc qc ${this.serviceName}`);
// 从输出中提取 BINARY_PATH_NAME
const match = stdout.match(/BINARY_PATH_NAME\s*:\s*(.+)/i);
if (!match) {
return false;
}
const binaryPath = match[1].trim();
const expectedPath = path.join(this.binPath, 'mysqld.exe');
// 检查路径是否包含当前的 MySQL 目录
const pathMatches = binaryPath.toLowerCase().includes(this.mysqlPath.toLowerCase());
this.log('system', `服务路径检查:`);
this.log('system', ` 当前环境: ${this.mysqlPath}`);
this.log('system', ` 服务路径: ${binaryPath}`);
this.log('system', ` 路径匹配: ${pathMatches ? '是' : '否'}`);
return pathMatches;
} catch (error) {
this.log('warn', `检查服务路径失败: ${error.message}`);
return false;
}
}
/**
* 读取配置文件中的端口
*/
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 Service] 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');
console.log('[MySQL Service] Generated my.ini for port:', port);
}
/**
* 初始化数据库
*/
async initializeDatabase() {
return new Promise((resolve, reject) => {
console.log('[MySQL Service] Initializing MySQL database...');
const mysqld = path.join(this.binPath, 'mysqld.exe');
const initProcess = spawn(mysqld, [
'--defaults-file=' + this.configFile,
'--initialize-insecure'
], {
cwd: this.mysqlPath,
stdio: 'inherit'
});
initProcess.on('close', (code) => {
if (code === 0) {
console.log('[MySQL Service] Database initialized successfully');
resolve();
} else {
reject(new Error(`Database initialization failed with code ${code}`));
}
});
});
}
/**
* 安装MySQL服务
*/
async installService() {
try {
const mysqld = path.join(this.binPath, 'mysqld.exe');
// 使用绝对路径和完整参数,避免环境变量干扰
// 关键:使用 --defaults-file 的绝对路径,确保不会读取系统 MySQL 的配置
const absoluteConfigPath = path.resolve(this.configFile);
const absoluteMysqldPath = path.resolve(mysqld);
// 构建完全隔离的安装命令
const command = `"${absoluteMysqldPath}" --install ${this.serviceName} --defaults-file="${absoluteConfigPath}"`;
this.log('system', `准备安装服务: ${this.serviceName}`);
this.log('system', `MySQL可执行文件: ${absoluteMysqldPath}`);
this.log('system', `配置文件: ${absoluteConfigPath}`);
// 检查文件是否存在
if (!fs.existsSync(mysqld)) {
throw new Error(`mysqld.exe not found at: ${mysqld}`);
}
if (!fs.existsSync(this.configFile)) {
throw new Error(`Config file not found at: ${this.configFile}`);
}
// 在安装前清理环境变量(避免继承系统 MySQL 的环境变量)
const cleanEnv = { ...process.env };
delete cleanEnv.MYSQL_HOME;
delete cleanEnv.MYSQL_TCP_PORT;
// 使用 spawn 代替 exec更好地控制环境
const { spawn } = require('child_process');
await new Promise((resolve, reject) => {
const installProcess = spawn(absoluteMysqldPath, [
'--install',
this.serviceName,
`--defaults-file=${absoluteConfigPath}`
], {
env: cleanEnv,
cwd: this.mysqlPath,
stdio: 'pipe'
});
let stdout = '';
let stderr = '';
if (installProcess.stdout) {
installProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
}
if (installProcess.stderr) {
installProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
}
installProcess.on('close', (code) => {
if (code === 0) {
this.log('system', '服务安装命令执行成功');
if (stdout) this.log('system', `输出: ${stdout}`);
resolve();
} else {
const errorMsg = stderr || stdout || `Exit code: ${code}`;
this.log('error', `安装命令失败: ${errorMsg}`);
reject(new Error(errorMsg));
}
});
installProcess.on('error', (err) => {
this.log('error', `进程启动失败: ${err.message}`);
reject(err);
});
});
return true;
} catch (error) {
console.error('[MySQL Service] Failed to install service:', error);
this.log('error', `服务安装失败: ${error.message}`);
throw new Error(`Failed to install MySQL service: ${error.message}. Please run as administrator.`);
}
}
/**
* 检查是否有其他MySQL服务
*/
async checkOtherMySQLServices() {
try {
const { stdout } = await this.execCommand('sc query type= service state= all | findstr /i "mysql"');
const services = [];
const lines = stdout.split('\n');
for (const line of lines) {
const match = line.match(/SERVICE_NAME:\s*(\S+)/);
if (match && match[1] !== this.serviceName) {
services.push(match[1]);
}
}
return services;
} catch (error) {
// 如果没找到任何MySQL服务命令会失败这是正常的
return [];
}
}
/**
* 启动服务
*/
async startService() {
try {
console.log('[MySQL Service] Starting service...');
await this.execCommand(`net start ${this.serviceName}`);
console.log('[MySQL Service] Service started successfully');
// 等待MySQL完全启动
await new Promise(resolve => setTimeout(resolve, 2000));
return true;
} catch (error) {
console.error('[MySQL Service] Failed to start service:', error);
console.error('[MySQL Service] This may be due to invalid service configuration');
throw error;
}
}
/**
* 停止服务
*/
async stopService() {
try {
console.log('[MySQL Service] Stopping service...');
await this.execCommand(`net stop ${this.serviceName}`);
console.log('[MySQL Service] Service stopped successfully');
return true;
} catch (error) {
console.error('[MySQL Service] Failed to stop service:', error);
// 停止失败不抛异常,因为可能服务本来就是停止状态
return false;
}
}
/**
* 删除服务
*/
async removeService() {
try {
// 先尝试停止服务(如果正在运行)
try {
await this.stopService();
} catch (e) {
// 服务可能已经停止,忽略错误
console.log('[MySQL Service] Service already stopped or not running');
}
// 删除服务
await this.execCommand(`sc delete ${this.serviceName}`);
console.log('[MySQL Service] Service removed successfully');
// 等待服务完全删除
await new Promise(resolve => setTimeout(resolve, 1000));
return true;
} catch (error) {
console.error('[MySQL Service] Failed to remove service:', error);
// 即使删除失败也返回 true因为可能服务本来就不存在
return true;
}
}
/**
* 更新服务端口
* 需要停止服务、修改配置、重启服务
*/
async updateServicePort(newPort) {
console.log('[MySQL Service] Updating service port to:', newPort);
// 1. 停止服务
await this.stopService();
// 2. 更新配置文件
this.generateMyIni(newPort);
// 3. 启动服务
await this.startService();
console.log('[MySQL Service] Port updated successfully');
}
/**
* 检查数据库是否已初始化
*/
isDatabaseInitialized() {
return fs.existsSync(this.dataPath) && fs.readdirSync(this.dataPath).length > 0;
}
/**
* 清空数据库目录(重新初始化时使用)
*/
clearDatabase() {
console.log('[MySQL Service] Clearing database directory...');
if (fs.existsSync(this.dataPath)) {
// 递归删除目录
const rimraf = (dir) => {
if (fs.existsSync(dir)) {
fs.readdirSync(dir).forEach((file) => {
const curPath = path.join(dir, file);
if (fs.lstatSync(curPath).isDirectory()) {
rimraf(curPath);
} else {
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(dir);
}
};
rimraf(this.dataPath);
}
// 重新创建空目录
fs.mkdirSync(this.dataPath, { recursive: true });
console.log('[MySQL Service] Database directory cleared');
}
/**
* 主流程确保MySQL服务运行
* @param {function} findAvailablePort - 查找可用端口的函数
* @param {function} waitForPort - 等待端口就绪的函数
* @returns {Promise<number>} 返回MySQL运行的端口
*/
async ensureServiceRunning(findAvailablePort, waitForPort) {
this.log('system', '开始MySQL服务检查流程');
this.log('system', `MySQL路径: ${this.mysqlPath}`);
this.log('system', `配置文件: ${this.configFile}`);
const serviceStatus = await this.getServiceStatus();
this.log('system', `服务状态: ${serviceStatus}`);
// 如果服务存在,检查路径是否匹配
if (serviceStatus !== 'NOT_EXISTS') {
const pathMatches = await this.checkServicePath();
if (!pathMatches) {
this.log('warn', '检测到服务路径与当前环境不匹配');
this.log('warn', '这可能是从其他环境(开发/打包)安装的旧服务');
this.log('system', '正在删除旧服务并重新安装...');
// 删除旧服务
await this.removeService();
// 重置状态,后续将按新安装流程处理
this.log('system', '旧服务已删除,准备重新安装');
// 继续执行下面的 NOT_EXISTS 流程
}
}
// 重新获取服务状态(如果删除了旧服务,状态会变为 NOT_EXISTS
const currentStatus = await this.getServiceStatus();
if (currentStatus === 'NOT_EXISTS') {
// ========== 服务不存在,全新安装 ==========
this.log('system', '服务不存在,开始安装流程');
// 0. 检查是否有其他MySQL服务仅作提示不影响安装
const otherServices = await this.checkOtherMySQLServices();
if (otherServices.length > 0) {
this.log('system', `检测到系统中有其他MySQL服务: ${otherServices.join(', ')}`);
this.log('system', 'mysql9100 将使用独立的配置和端口,不会冲突');
}
// 1. 查找可用端口
const port = await findAvailablePort(3306, 100);
this.log('system', `找到可用端口: ${port}`);
// 2. 检查数据库是否已初始化
if (!this.isDatabaseInitialized()) {
// 数据库未初始化,正常初始化流程
this.log('system', '数据库未初始化,开始初始化...');
this.generateMyIni(port);
await this.initializeDatabase();
} else {
// 数据库已存在(可能是从其他机器拷贝过来的)
this.log('system', '检测到已有数据库目录');
// 无论端口是什么,都使用现有数据库,只修改配置文件
if (port !== 3306) {
this.log('system', `端口3306被占用将使用端口${port}`);
this.log('system', '保留现有数据库,仅更新端口配置');
} else {
this.log('system', '端口3306可用使用现有数据库');
}
// 只生成新配置,不删除数据
this.generateMyIni(port);
}
// 3. 安装服务
this.log('system', '开始安装MySQL服务...');
await this.installService();
// 3.1 验证服务是否真的安装成功
await this.sleep(2000); // 等待2秒让服务注册完成
const verifyStatus = await this.getServiceStatus();
if (verifyStatus === 'NOT_EXISTS') {
this.log('error', '服务安装失败:服务未创建');
this.log('error', '可能的原因:');
this.log('error', '1. 权限不足(请确保以管理员身份运行)');
this.log('error', '2. MySQL可执行文件损坏');
this.log('error', '3. 配置文件路径有误');
// 检查是否有其他MySQL服务
const otherServices = await this.checkOtherMySQLServices();
if (otherServices.length > 0) {
this.log('system', `提示系统中存在其他MySQL服务 ${otherServices.join(', ')}`);
this.log('system', '如果问题持续请查看文档doc/MySQL服务冲突处理方案.md');
}
throw new Error('MySQL服务安装失败请检查管理员权限和日志');
}
this.log('success', '服务安装成功,已验证');
// 3.2 设置服务为开机自启
this.log('system', '正在配置服务为开机自启...');
try {
await this.execCommand(`sc config ${this.serviceName} start= auto`);
this.log('success', '服务已配置为开机自启');
// 验证配置是否生效
const { stdout: qcOutput } = await this.execCommand(`sc qc ${this.serviceName}`);
if (qcOutput.includes('AUTO_START')) {
this.log('success', '开机自启配置验证成功');
} else {
this.log('warn', '开机自启配置可能未生效,请手动检查');
}
} catch (error) {
this.log('warn', `设置开机自启失败: ${error.message}`);
this.log('warn', '服务已安装,但需要手动设置启动类型');
}
// 4. 启动服务
this.log('system', '开始启动MySQL服务...');
await this.startService();
const finalPort = this.readPortFromConfig();
this.log('success', `MySQL服务安装并启动成功端口: ${finalPort}`);
// 4. 等待端口就绪
await waitForPort(finalPort, 30000);
return finalPort;
}
else if (currentStatus === 'STOPPED') {
// ========== 服务存在但停止,尝试启动 ==========
this.log('system', '服务已停止,尝试启动...');
try {
// 先读取配置端口
const configPort = this.readPortFromConfig();
// 重新生成配置文件,确保使用绝对路径(修复相对路径问题)
this.log('system', '更新配置文件,确保路径正确...');
this.generateMyIni(configPort);
// 尝试启动服务
await this.startService();
this.log('success', `服务启动成功,端口: ${configPort}`);
// 等待端口就绪
await waitForPort(configPort, 30000);
return configPort;
} catch (startError) {
// 启动失败,先诊断问题
this.log('error', '服务启动失败');
this.log('system', `失败原因: ${startError.message}`);
// 检查是否是端口被占用导致的启动失败
const configPort = this.readPortFromConfig();
this.log('system', `配置的端口: ${configPort}`);
// 检查端口是否被占用
const PortChecker = require('./port-checker');
const portAvailable = await PortChecker.findAvailablePort(configPort, 1) === configPort;
if (!portAvailable) {
// 端口被占用,需要更换端口并重新安装
this.log('warn', `端口${configPort}已被占用,尝试重新配置服务`);
// 1. 删除旧服务
this.log('system', '正在删除旧服务...');
await this.removeService();
// 2. 查找可用端口
const port = await findAvailablePort(3306, 100);
this.log('system', `找到可用端口: ${port}`);
// 3. 保留现有数据库,只更新配置
if (this.isDatabaseInitialized()) {
this.log('system', '保留现有数据库,更新端口配置');
this.generateMyIni(port);
} else {
this.log('system', '数据库未初始化,开始初始化...');
this.generateMyIni(port);
await this.initializeDatabase();
}
// 4. 重新安装服务
this.log('system', '开始重新安装MySQL服务...');
await this.installService();
// 4.1 设置开机自启
this.log('system', '正在配置服务为开机自启...');
try {
await this.execCommand(`sc config ${this.serviceName} start= auto`);
this.log('success', '服务已配置为开机自启');
} catch (error) {
this.log('warn', `设置开机自启失败: ${error.message}`);
}
// 5. 启动服务
this.log('system', '开始启动MySQL服务...');
await this.startService();
const finalPort = this.readPortFromConfig();
this.log('success', `MySQL服务重新安装并启动成功端口: ${finalPort}`);
// 6. 等待端口就绪
await waitForPort(finalPort, 30000);
return finalPort;
} else {
// 端口没被占用,但服务启动失败,可能是配置问题或权限问题
this.log('error', '服务启动失败,但端口未被占用');
this.log('error', '可能的原因:');
this.log('error', '1. 配置文件路径错误或损坏');
this.log('error', '2. 数据库文件损坏');
this.log('error', '3. 权限不足');
this.log('error', '4. MySQL可执行文件问题');
this.log('system', '建议:检查日志文件 mysql/mysql_error.log');
throw new Error(`MySQL服务启动失败: ${startError.message}。请检查配置和日志。`);
}
}
}
else if (currentStatus === 'RUNNING') {
// ========== 服务正在运行 ==========
const configPort = this.readPortFromConfig();
this.log('success', `MySQL服务已在运行端口: ${configPort}`);
// 验证端口是否真的在监听
const PortChecker = require('./port-checker');
const portReady = await PortChecker.checkPort(configPort);
if (!portReady) {
this.log('warn', '服务运行中但端口未就绪,重新启动...');
// 重新生成配置文件
this.generateMyIni(configPort);
await this.stopService();
await this.startService();
await waitForPort(configPort, 30000);
}
return configPort;
}
throw new Error('Unknown service status: ' + currentStatus);
}
}
module.exports = MySQLServiceManager;