// 被复制到数据目录后独立运行;仅用内置模块,ERP 重启或替换依赖均不影响任务。 const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); async function executeUpdate(config, hooks = {}) { const { root, data, dir, jobDir, service, repo, tag, version, node, port } = config; const statusFile = path.join(dir, 'status.json'); const logFile = path.join(jobDir, 'update.log'); const stage = path.join(jobDir, 'source'); const backendModules = path.join(root, 'backend/node_modules'); const frontendDist = path.join(root, 'frontend/dist'); const newModules = `${backendModules}.update-new`; const oldModules = `${backendModules}.update-old`; const newDist = `${frontendDist}.update-new`; const oldDist = `${frontendDist}.update-old`; let switched = false; let oldCommit; let status = JSON.parse(fs.readFileSync(statusFile, 'utf8')); function progress(state, message, extra = {}) { status = { ...status, state, message, updatedAt: new Date().toISOString(), ...extra }; const temporary = `${statusFile}.tmp`; fs.writeFileSync(temporary, JSON.stringify(status), { mode: 0o600 }); fs.renameSync(temporary, statusFile); } function run(command, args, cwd = root, timeout = 10 * 60 * 1000) { if (hooks.run) return hooks.run(command, args, cwd); return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: { ...process.env, PATH: `${path.dirname(node)}:${process.env.PATH || ''}` }, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; const consume = chunk => { output = (output + chunk.toString()).slice(-12000); fs.appendFileSync(logFile, chunk); }; child.stdout.on('data', consume); child.stderr.on('data', consume); const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeout); child.on('error', error => { clearTimeout(timer); reject(error); }); child.on('close', code => { clearTimeout(timer); if (code === 0) resolve(output.trim()); else reject(new Error(`${command} 执行失败(${code}),请查看更新日志`)); }); }); } async function healthy(expected) { if (hooks.healthy) return hooks.healthy(expected); for (let attempt = 0; attempt < 45; attempt++) { try { const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) }); const info = await response.json(); if (response.ok && info.status === 'ok' && (!expected || info.version === expected)) return; } catch { /* 重启期间暂不可用 */ } await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error('重启后健康检查未通过'); } async function main() { try { progress('preparing', '正在下载新版本'); oldCommit = await run('git', ['rev-parse', 'HEAD']); await run('git', ['fetch', '--no-tags', `https://github.com/${repo}.git`, `refs/tags/${tag}`]); const target = await run('git', ['rev-parse', 'FETCH_HEAD^{commit}']); await run('git', ['worktree', 'add', '--detach', stage, target]); const manifest = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8')); if (manifest.version !== version) throw new Error('版本标签与应用版本不一致,已取消更新'); progress('installing', '正在安装新版本依赖'); await run('npm', ['ci', '--omit=dev', '--ignore-scripts'], path.join(stage, 'backend')); await run('npm', ['ci', '--include=dev'], path.join(stage, 'frontend')); progress('building', '正在构建新版本页面'); await run('npm', ['run', 'build'], path.join(stage, 'frontend')); await run(node, ['--check', path.join(stage, 'backend/server.js')]); // 在目标文件系统中准备目录,再通过 rename 切换,避免跨盘移动失败。 for (const entry of [newModules, newDist, oldModules, oldDist]) { if (fs.existsSync(entry)) throw new Error('发现上次更新残留目录,请管理员检查后清理'); } fs.cpSync(path.join(stage, 'backend/node_modules'), newModules, { recursive: true, verbatimSymlinks: true }); fs.cpSync(path.join(stage, 'frontend/dist'), newDist, { recursive: true }); progress('backing_up', '正在备份数据库'); const dbPath = path.join(data, 'trade.db'); const backup = path.join(jobDir, 'trade-before-update.db'); if (!fs.existsSync(dbPath)) throw new Error('未找到业务数据库,已取消更新'); if (hooks.backup) await hooks.backup(backup); else { const Database = require(path.join(backendModules, 'better-sqlite3-multiple-ciphers')); const db = new Database(dbPath, { readonly: true }); try { await db.backup(backup); } finally { db.close(); } } fs.chmodSync(backup, 0o600); // 下载安装期间如果部署代码被人工改动,则保留这些修改并取消更新。 if ((await run('git', ['status', '--porcelain', '--untracked-files=normal'])).trim() || await run('git', ['rev-parse', 'HEAD']) !== oldCommit) throw new Error('部署代码在更新过程中发生变化,已取消更新'); progress('deploying', '正在切换应用版本', { previousCommit: oldCommit, targetCommit: target }); switched = true; fs.renameSync(backendModules, oldModules); fs.renameSync(newModules, backendModules); if (fs.existsSync(frontendDist)) fs.renameSync(frontendDist, oldDist); fs.renameSync(newDist, frontendDist); await run('git', ['reset', '--hard', target]); progress('restarting', '正在重启服务,请稍候'); await run('systemctl', ['restart', `${service}.service`], root, 60000); await healthy(version); switched = false; try { fs.rmSync(oldModules, { recursive: true, force: true }); fs.rmSync(oldDist, { recursive: true, force: true }); } catch (cleanupError) { // 服务已验证成功,清理旧目录失败不能触发半套依赖的回退。 fs.appendFileSync(logFile, `\n旧版本目录清理失败:${cleanupError.message}\n`); } progress('completed', '更新完成,可刷新页面使用新版本', { finishedAt: new Date().toISOString() }); } catch (error) { fs.appendFileSync(logFile, `\n${error.stack}\n`); if (switched) { try { progress('rolling_back', '更新失败,正在恢复上一版应用'); await run('systemctl', ['stop', `${service}.service`], root, 60000); await run('git', ['reset', '--hard', oldCommit]); if (fs.existsSync(oldModules)) { fs.rmSync(backendModules, { recursive: true, force: true }); fs.renameSync(oldModules, backendModules); } if (fs.existsSync(oldDist)) { fs.rmSync(frontendDist, { recursive: true, force: true }); fs.renameSync(oldDist, frontendDist); } await run('systemctl', ['start', `${service}.service`], root, 60000); await healthy(); progress('failed', `${error.message};已恢复上一版应用,数据库备份已保留`, { finishedAt: new Date().toISOString() }); } catch (rollbackError) { fs.appendFileSync(logFile, `\n${rollbackError.stack}\n`); progress('failed', `${error.message};自动恢复失败,请管理员查看日志和数据库备份`, { finishedAt: new Date().toISOString() }); } } else progress('failed', error.message, { finishedAt: new Date().toISOString() }); } finally { // 数据库备份与日志长期保留;不自动覆盖线上库,防止回滚丢失期间的写入。 try { await run('git', ['worktree', 'remove', '--force', stage]); } catch { /* 保留现场 */ } for (const entry of [newModules, newDist]) { try { fs.rmSync(entry, { recursive: true, force: true }); } catch { /* 保留现场 */ } } fs.rmSync(path.join(dir, 'lock'), { force: true }); } } await main(); } module.exports = { executeUpdate }; if (require.main === module) { const config = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); executeUpdate(config).catch(error => { console.error(error); process.exitCode = 1; }); }