From 37fdcdff42584124377b6be8472cda0d45c352ef Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Tue, 17 Mar 2026 13:11:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E5=A2=9E=E5=BC=BA=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=9B=B4=E6=96=B0=E8=84=9A=E6=9C=AC=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.js | 650 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 572 insertions(+), 78 deletions(-) diff --git a/update.js b/update.js index 8f064a0..0c6bdda 100644 --- a/update.js +++ b/update.js @@ -2,14 +2,21 @@ /** * IDC设备管理系统 - 一键更新脚本 - * 功能:拉取最新代码 → 安装依赖 → 重建前端 → 重启服务 + * 功能:拉取最新代码 → 安装依赖 → 数据库迁移 → 重建前端 → 重启服务 → 健康检查 + * 版本:2.0.0 */ -const { execSync } = require('child_process'); +const { execSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); -// 颜色输出 +const SCRIPT_VERSION = '2.0.0'; +const MIN_NODE_VERSION = 16; +const LOCK_FILE = path.join(__dirname, '.update.lock'); +const LOG_DIR = path.join(__dirname, 'logs'); +const BACKUP_DIR = path.join(__dirname, 'backup'); +const MAX_BACKUP_FILES = 10; + const colors = { reset: '\x1b[0m', bright: '\x1b[1m', @@ -17,7 +24,8 @@ const colors = { yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', - gray: '\x1b[90m' + gray: '\x1b[90m', + magenta: '\x1b[35m' }; const log = { @@ -26,117 +34,603 @@ const log = { warning: (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`), error: (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`), step: (msg) => console.log(`\n${colors.bright}${colors.cyan}▶ ${msg}${colors.reset}`), - divider: () => console.log(`${colors.gray}${'─'.repeat(60)}${colors.reset}`) + divider: () => console.log(`${colors.gray}${'─'.repeat(60)}${colors.reset}`), + subStep: (msg) => console.log(` ${colors.gray}└${colors.reset} ${msg}`) }; +let logFileStream = null; +let updateStartTime = null; +let rollbackInfo = { + backupDbPath: null, + previousVersion: null +}; + +function parseArgs() { + const args = process.argv.slice(2); + return { + skipGit: args.includes('--skip-git'), + skipBackup: args.includes('--skip-backup'), + skipMigrate: args.includes('--skip-migrate'), + skipBuild: args.includes('--skip-build'), + skipRestart: args.includes('--skip-restart'), + dryRun: args.includes('--dry-run'), + force: args.includes('--force'), + help: args.includes('--help') || args.includes('-h') + }; +} + +function showHelp() { + console.log(` +${colors.bright}IDC设备管理系统 - 一键更新脚本 v${SCRIPT_VERSION}${colors.reset} + +用法: node update.js [选项] + +选项: + --skip-git 跳过 Git 拉取 + --skip-backup 跳过数据库备份 + --skip-migrate 跳过数据库迁移 + --skip-build 跳过前端构建 + --skip-restart 跳过服务重启 + --dry-run 模拟运行,不执行实际操作 + --force 强制执行,忽略锁文件 + -h, --help 显示帮助信息 + +示例: + node update.js # 完整更新流程 + node update.js --skip-backup # 跳过备份 + node update.js --dry-run # 模拟运行 +`); + process.exit(0); +} + +function initLogFile() { + if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); + } + const logFileName = `update_${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const logFilePath = path.join(LOG_DIR, logFileName); + logFileStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + + const originalConsoleLog = console.log; + console.log = (...args) => { + const timestamp = new Date().toISOString(); + const message = args.map(arg => + typeof arg === 'string' ? arg.replace(/\x1b\[[0-9;]*m/g, '') : String(arg) + ).join(' '); + logFileStream.write(`[${timestamp}] ${message}\n`); + originalConsoleLog.apply(console, args); + }; +} + +function closeLogFile() { + if (logFileStream) { + logFileStream.end(); + } +} + +function checkLock(options) { + if (options.force) { + if (fs.existsSync(LOCK_FILE)) { + log.warning('检测到锁文件,已通过 --force 强制执行'); + fs.unlinkSync(LOCK_FILE); + } + return true; + } + + if (fs.existsSync(LOCK_FILE)) { + const lockContent = fs.readFileSync(LOCK_FILE, 'utf8'); + log.error('另一个更新进程正在运行'); + log.info(`锁文件信息: ${lockContent}`); + log.info('如需强制执行,请使用 --force 参数'); + return false; + } + + const lockContent = JSON.stringify({ + pid: process.pid, + startTime: new Date().toISOString() + }); + fs.writeFileSync(LOCK_FILE, lockContent); + return true; +} + +function releaseLock() { + if (fs.existsSync(LOCK_FILE)) { + fs.unlinkSync(LOCK_FILE); + } +} + +function checkNodeVersion() { + const nodeVersion = process.versions.node; + const majorVersion = parseInt(nodeVersion.split('.')[0], 10); + + log.info(`Node.js 版本: ${nodeVersion}`); + + if (majorVersion < MIN_NODE_VERSION) { + log.error(`Node.js 版本过低,需要 v${MIN_NODE_VERSION} 或更高版本`); + return false; + } + return true; +} + function runCommand(command, options = {}) { + if (options.dryRun) { + log.subStep(`[模拟] 执行: ${command}`); + return { success: true, output: '', dryRun: true }; + } + try { const result = execSync(command, { encoding: 'utf8', stdio: options.silent ? 'pipe' : 'inherit', cwd: options.cwd || process.cwd(), - shell: true + shell: true, + timeout: options.timeout || 300000 }); return { success: true, output: result }; } catch (error) { - return { success: false, error: error.message }; + return { success: false, error: error.message, output: error.stdout || '' }; } } +function getGitInfo() { + try { + const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + const currentCommit = execSync('git rev-parse HEAD', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim().substring(0, 7); + + return { branch: currentBranch, commit: currentCommit }; + } catch { + return { branch: 'unknown', commit: 'unknown' }; + } +} + +function backupDatabase(options) { + if (options.skipBackup || options.dryRun) { + if (options.skipBackup) log.info('已跳过数据库备份'); + return { success: true, skipped: true }; + } + + const envPath = path.join(__dirname, 'backend', '.env'); + if (!fs.existsSync(envPath)) { + log.warning('未找到 .env 文件,跳过数据库备份'); + return { success: true, skipped: true }; + } + + const envContent = fs.readFileSync(envPath, 'utf8'); + const dbTypeMatch = envContent.match(/DB_TYPE=(\w+)/); + const dbType = dbTypeMatch ? dbTypeMatch[1].toLowerCase() : 'sqlite'; + + if (dbType === 'sqlite') { + const dbPath = path.join(__dirname, 'backend', 'idc_management.db'); + if (!fs.existsSync(dbPath)) { + log.warning('SQLite 数据库文件不存在,跳过备份'); + return { success: true, skipped: true }; + } + + if (!fs.existsSync(BACKUP_DIR)) { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + } + + const backupPath = path.join(BACKUP_DIR, `database_${Date.now()}.db`); + fs.copyFileSync(dbPath, backupPath); + rollbackInfo.backupDbPath = backupPath; + log.success(`数据库已备份: ${path.basename(backupPath)}`); + + cleanOldBackups('database_*.db'); + return { success: true, backupPath }; + } else if (dbType === 'mysql') { + log.warning('MySQL 数据库请手动备份'); + log.subStep(`mysqldump -u [username] -p [database] > backup_${Date.now()}.sql`); + return { success: true, skipped: true }; + } + + return { success: true, skipped: true }; +} + +function cleanOldBackups(pattern) { + try { + const files = fs.readdirSync(BACKUP_DIR) + .filter(f => f.match(new RegExp(pattern.replace('*', '.*')))) + .map(f => ({ + name: f, + path: path.join(BACKUP_DIR, f), + time: fs.statSync(path.join(BACKUP_DIR, f)).mtime.getTime() + })) + .sort((a, b) => b.time - a.time); + + if (files.length > MAX_BACKUP_FILES) { + files.slice(MAX_BACKUP_FILES).forEach(f => { + fs.unlinkSync(f.path); + log.subStep(`清理旧备份: ${f.name}`); + }); + } + } catch (error) { + log.warning(`清理旧备份失败: ${error.message}`); + } +} + +function pullCode(options) { + if (options.skipGit) { + log.info('已跳过 Git 拉取'); + return { success: true, skipped: true }; + } + + const gitInfo = getGitInfo(); + rollbackInfo.previousVersion = gitInfo.commit; + log.info(`当前分支: ${gitInfo.branch}, 提交: ${gitInfo.commit}`); + + if (options.dryRun) { + log.subStep('[模拟] 执行: git pull'); + return { success: true, dryRun: true }; + } + + const gitStatus = runCommand('git status --porcelain', { silent: true }); + if (gitStatus.output && gitStatus.output.trim()) { + log.warning('工作区有未提交的更改'); + log.subStep(gitStatus.output.trim().split('\n').slice(0, 5).join('\n ')); + + const stashResult = runCommand('git stash'); + if (!stashResult.success) { + log.error('暂存更改失败,请手动处理'); + return { success: false }; + } + log.success('已暂存本地更改'); + } + + const pullResult = runCommand('git pull'); + if (pullResult.success) { + const newGitInfo = getGitInfo(); + if (gitInfo.commit !== newGitInfo.commit) { + log.success(`代码已更新: ${gitInfo.commit} → ${newGitInfo.commit}`); + } else { + log.info('代码已是最新版本'); + } + return { success: true }; + } + + log.error('代码拉取失败'); + return { success: false }; +} + +function installDependencies(options) { + const backendPath = path.join(__dirname, 'backend'); + const frontendPath = path.join(__dirname, 'frontend'); + + if (options.dryRun) { + log.subStep('[模拟] 安装后端依赖'); + log.subStep('[模拟] 安装前端依赖'); + return { success: true, dryRun: true }; + } + + log.subStep('安装后端依赖...'); + const backendResult = runCommand('npm install --production=false', { cwd: backendPath }); + if (!backendResult.success) { + log.error('后端依赖安装失败'); + return { success: false, step: 'backend' }; + } + log.success('后端依赖安装完成'); + + log.subStep('安装前端依赖...'); + const frontendResult = runCommand('npm install', { cwd: frontendPath }); + if (!frontendResult.success) { + log.error('前端依赖安装失败'); + return { success: false, step: 'frontend' }; + } + log.success('前端依赖安装完成'); + + return { success: true }; +} + +function runMigrations(options) { + if (options.skipMigrate) { + log.info('已跳过数据库迁移'); + return { success: true, skipped: true }; + } + + const migratePath = path.join(__dirname, 'backend', 'scripts', 'migrate-all.js'); + if (!fs.existsSync(migratePath)) { + log.info('未找到迁移脚本,跳过'); + return { success: true, skipped: true }; + } + + if (options.dryRun) { + log.subStep('[模拟] 执行数据库迁移'); + return { success: true, dryRun: true }; + } + + log.subStep('执行数据库迁移...'); + const migrateResult = runCommand('node scripts/migrate-all.js', { + cwd: path.join(__dirname, 'backend') + }); + + if (migrateResult.success) { + log.success('数据库迁移完成'); + return { success: true }; + } + + log.warning('数据库迁移可能存在问题,请检查日志'); + return { success: true, warning: true }; +} + +function buildFrontend(options) { + if (options.skipBuild) { + log.info('已跳过前端构建'); + return { success: true, skipped: true }; + } + + const frontendPath = path.join(__dirname, 'frontend'); + + if (options.dryRun) { + log.subStep('[模拟] 构建前端'); + return { success: true, dryRun: true }; + } + + log.subStep('构建前端...'); + const buildResult = runCommand('npm run build', { cwd: frontendPath }); + + if (buildResult.success) { + log.success('前端构建完成'); + return { success: true }; + } + + log.error('前端构建失败'); + return { success: false }; +} + +function restartServices(options) { + if (options.skipRestart) { + log.info('已跳过服务重启'); + return { success: true, skipped: true }; + } + + if (options.dryRun) { + log.subStep('[模拟] 重启后端服务'); + log.subStep('[模拟] 重启前端服务'); + return { success: true, dryRun: true }; + } + + log.subStep('检查 PM2 服务状态...'); + const listResult = runCommand('pm2 list', { silent: true }); + + if (!listResult.success) { + log.warning('PM2 未安装或未运行,尝试直接启动'); + return startDirectly(); + } + + log.subStep('重启后端服务...'); + const backendRestart = runCommand('pm2 restart idc-backend 2>nul || pm2 start backend/server.js --name idc-backend'); + + if (backendRestart.success) { + log.success('后端服务已重启'); + } else { + log.error('后端服务重启失败'); + return { success: false }; + } + + const frontendCheck = runCommand('pm2 describe idc-frontend', { silent: true }); + if (frontendCheck.success) { + log.subStep('重启前端服务...'); + const frontendRestart = runCommand('pm2 restart idc-frontend'); + if (frontendRestart.success) { + log.success('前端服务已重启'); + } + } + + runCommand('pm2 save'); + return { success: true }; +} + +function startDirectly() { + log.subStep('尝试直接启动后端服务...'); + + const backendPath = path.join(__dirname, 'backend'); + const serverPath = path.join(backendPath, 'server.js'); + + if (!fs.existsSync(serverPath)) { + log.error('未找到 server.js'); + return { success: false }; + } + + try { + const child = spawn('node', ['server.js'], { + cwd: backendPath, + detached: true, + stdio: 'ignore', + shell: true + }); + child.unref(); + log.success('后端服务已启动 (PID: ' + child.pid + ')'); + return { success: true }; + } catch (error) { + log.error(`启动失败: ${error.message}`); + return { success: false }; + } +} + +async function healthCheck() { + log.subStep('检查服务健康状态...'); + + const maxRetries = 5; + const retryDelay = 3000; + + for (let i = 1; i <= maxRetries; i++) { + try { + const result = execSync('curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/health 2>nul || echo "000"', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000 + }).trim(); + + if (result === '200') { + log.success('服务健康检查通过'); + return { success: true }; + } + + if (i < maxRetries) { + log.subStep(`第 ${i} 次检查失败 (HTTP ${result}),${retryDelay/1000}秒后重试...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } catch (error) { + if (i < maxRetries) { + log.subStep(`第 ${i} 次检查失败,${retryDelay/1000}秒后重试...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + + log.warning('健康检查未通过,请手动验证服务状态'); + return { success: false, warning: true }; +} + +function rollback(reason) { + log.divider(); + log.error(`更新失败: ${reason}`); + log.step('执行回滚...'); + + if (rollbackInfo.backupDbPath && fs.existsSync(rollbackInfo.backupDbPath)) { + const dbPath = path.join(__dirname, 'backend', 'idc_management.db'); + fs.copyFileSync(rollbackInfo.backupDbPath, dbPath); + log.success('数据库已回滚'); + } + + if (rollbackInfo.previousVersion) { + log.subStep(`如需回滚代码,执行: git reset --hard ${rollbackInfo.previousVersion}`); + } + + log.divider(); +} + +function printSummary(options, results) { + const duration = ((Date.now() - updateStartTime) / 1000).toFixed(1); + + console.log(`\n${colors.bright}${colors.magenta} +╔══════════════════════════════════════════════════════════╗ +║ 更新摘要 ║ +╚══════════════════════════════════════════════════════════╝${colors.reset}`); + + console.log(`\n ${colors.cyan}执行模式:${colors.reset} ${options.dryRun ? '模拟运行' : '实际执行'}`); + console.log(` ${colors.cyan}耗时:${colors.reset} ${duration} 秒`); + + console.log(`\n ${colors.cyan}步骤状态:${colors.reset}`); + console.log(` 数据库备份: ${results.backup?.skipped ? '已跳过' : results.backup?.success ? '成功' : '失败'}`); + console.log(` 代码更新: ${results.git?.skipped ? '已跳过' : results.git?.success ? '成功' : '失败'}`); + console.log(` 依赖安装: ${results.deps?.success ? '成功' : '失败'}`); + console.log(` 数据库迁移: ${results.migrate?.skipped ? '已跳过' : results.migrate?.success ? '成功' : '失败'}`); + console.log(` 前端构建: ${results.build?.skipped ? '已跳过' : results.build?.success ? '成功' : '失败'}`); + console.log(` 服务重启: ${results.restart?.skipped ? '已跳过' : results.restart?.success ? '成功' : '失败'}`); + console.log(` 健康检查: ${results.health?.success ? '通过' : '未通过'}`); + + console.log(`\n ${colors.cyan}访问地址:${colors.reset}`); + console.log(` 后端API: http://localhost:8000/api`); + console.log(` 前端页面: http://localhost`); + + console.log(`\n ${colors.cyan}日志文件:${colors.reset}`); + console.log(` ${path.join(LOG_DIR, `update_*.log`)}`); + + console.log(''); +} + async function main() { + updateStartTime = Date.now(); + const options = parseArgs(); + + if (options.help) { + showHelp(); + return; + } + + initLogFile(); + console.log(` ${colors.bright}${colors.cyan} ╔══════════════════════════════════════════════════════════╗ -║ IDC设备管理系统 - 一键更新脚本 ║ +║ IDC设备管理系统 - 一键更新脚本 v${SCRIPT_VERSION} ║ ║ One-Click Update Script ║ ╚══════════════════════════════════════════════════════════╝ ${colors.reset}`); + if (options.dryRun) { + log.info('运行模式: 模拟运行 (不会执行实际操作)'); + } + + const results = {}; + try { - // 1. 备份数据库 + if (!checkNodeVersion()) { + process.exit(1); + } + + if (!checkLock(options)) { + process.exit(1); + } + + process.on('SIGINT', () => { + log.warning('收到中断信号,正在清理...'); + releaseLock(); + closeLogFile(); + process.exit(130); + }); + log.step('1. 备份数据'); - const envPath = path.join(__dirname, 'backend', '.env'); - if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, 'utf8'); - const dbTypeMatch = envContent.match(/DB_TYPE=(\w+)/); - const dbType = dbTypeMatch ? dbTypeMatch[1] : 'sqlite'; - - if (dbType === 'sqlite') { - const dbPath = path.join(__dirname, 'backend', 'idc_management.db'); - const backupDir = path.join(__dirname, 'backup'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); - } - const backupPath = path.join(backupDir, `database_${Date.now()}.db`); - if (fs.existsSync(dbPath)) { - fs.copyFileSync(dbPath, backupPath); - log.success(`数据库已备份: ${backupPath}`); - } - } else { - log.warning('MySQL数据库请手动备份'); - console.log(` ${colors.gray}mysqldump -u username -p idc_management > backup_${Date.now()}.sql${colors.reset}`); - } + results.backup = backupDatabase(options); + if (!results.backup.success && !options.dryRun) { + throw new Error('数据库备份失败'); } - // 2. 拉取最新代码 log.step('2. 拉取最新代码'); - const gitResult = runCommand('git pull'); - if (gitResult.success) { - log.success('代码更新完成'); - } else { - log.warning('代码拉取失败或不是git仓库,跳过'); + results.git = pullCode(options); + + log.step('3. 安装依赖'); + results.deps = installDependencies(options); + if (!results.deps.success && !options.dryRun) { + throw new Error('依赖安装失败'); } - // 3. 更新后端依赖 - log.step('3. 更新后端依赖'); - const backendResult = runCommand('npm install', { cwd: path.join(__dirname, 'backend') }); - if (backendResult.success) { - log.success('后端依赖更新完成'); - } else { - log.error('后端依赖更新失败'); + log.step('4. 数据库迁移'); + results.migrate = runMigrations(options); + + log.step('5. 构建前端'); + results.build = buildFrontend(options); + if (!results.build.success && !options.dryRun) { + throw new Error('前端构建失败'); } - // 4. 更新前端依赖并构建 - log.step('4. 更新前端依赖并构建'); - const frontendInstall = runCommand('npm install', { cwd: path.join(__dirname, 'frontend') }); - if (frontendInstall.success) { - log.success('前端依赖更新完成'); - - const frontendBuild = runCommand('npm run build', { cwd: path.join(__dirname, 'frontend') }); - if (frontendBuild.success) { - log.success('前端构建完成'); - } else { - log.error('前端构建失败'); - } - } else { - log.error('前端依赖更新失败'); + log.step('6. 重启服务'); + results.restart = restartServices(options); + if (!results.restart.success && !options.dryRun) { + throw new Error('服务重启失败'); } - // 5. 重启服务 - log.step('5. 重启服务'); - const restartResult = runCommand('pm2 restart idc-backend'); - if (restartResult.success) { - log.success('后端服务已重启'); - } else { - log.error('后端服务重启失败,请手动执行: pm2 restart idc-backend'); - } - - // 检查是否有前端PM2服务 - const frontendRestart = runCommand('pm2 restart idc-frontend 2>nul || echo "frontend not found"', { silent: true }); - if (frontendRestart.success && !frontendRestart.output.includes('frontend not found')) { - log.success('前端服务已重启'); - } + log.step('7. 健康检查'); + results.health = await healthCheck(); log.divider(); - log.success('更新完成!'); - console.log(`\n${colors.cyan}访问地址:${colors.reset}`); - console.log(` 后端API: http://localhost:8000/api`); - console.log(` 前端页面: http://localhost`); + + if (!options.dryRun && results.health.success) { + log.success('更新完成!系统已成功更新并正常运行'); + } else if (options.dryRun) { + log.success('模拟运行完成!使用不带 --dry-run 参数执行实际更新'); + } else { + log.warning('更新完成,但健康检查未通过,请手动验证服务状态'); + } } catch (error) { - log.error(`更新失败: ${error.message}`); - console.error(error); - process.exit(1); + if (!options.dryRun) { + rollback(error.message); + } else { + log.error(`[模拟] 更新失败: ${error.message}`); + } + results.error = error.message; + } finally { + releaseLock(); + printSummary(options, results); + closeLogFile(); } }