Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming. Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
150 lines
7.9 KiB
JavaScript
150 lines
7.9 KiB
JavaScript
// 被复制到数据目录后独立运行;仅用内置模块,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; });
|
|
}
|