feat: add admin web updates and verified username changes
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.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/** 管理员网页更新入口。安装由独立 systemd 单元执行,重启 ERP 不会杀掉更新任务。 */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const exec = promisify(execFile);
|
||||
const updater = require('./updater');
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'backend/data'));
|
||||
const DIR = path.join(DATA, 'system-update');
|
||||
const STATUS = path.join(DIR, 'status.json');
|
||||
const LOCK = path.join(DIR, 'lock');
|
||||
const SERVICE = process.env.HAPPY_UPDATE_SERVICE || 'erp';
|
||||
|
||||
let lastInspection = 0;
|
||||
async function readStatus() {
|
||||
let status;
|
||||
try { status = JSON.parse(fs.readFileSync(STATUS, 'utf8')); }
|
||||
catch { return { state: 'idle', message: '尚未执行更新' }; }
|
||||
if (process.platform === 'linux' && /^[a-f0-9]{16}$/.test(status.id || '')
|
||||
&& !['idle', 'completed', 'failed'].includes(status.state)
|
||||
&& Date.now() - Date.parse(status.startedAt) > 60000 && Date.now() - lastInspection > 30000) {
|
||||
lastInspection = Date.now();
|
||||
try {
|
||||
const { stdout } = await exec('systemctl', ['show', `happy-update-${status.id}.service`, '--property=ActiveState', '--value'], { timeout: 5000 });
|
||||
if (['inactive', 'failed'].includes(stdout.trim())) {
|
||||
// 再读一次,避免 worker 刚完成时被旧状态覆盖。
|
||||
const latest = JSON.parse(fs.readFileSync(STATUS, 'utf8'));
|
||||
if (latest.id === status.id && !['completed', 'failed'].includes(latest.state)) {
|
||||
status = { ...latest, state: 'failed', message: '更新任务意外中断,请查看日志;数据库备份与恢复目录已保留', finishedAt: new Date().toISOString() };
|
||||
fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 });
|
||||
fs.rmSync(LOCK, { force: true });
|
||||
} else status = latest;
|
||||
}
|
||||
} catch { /* systemd 暂不可达时保留状态,不能认定任务已终止 */ }
|
||||
}
|
||||
return status;
|
||||
}
|
||||
function capability() {
|
||||
return { supported: process.platform === 'linux', service: SERVICE,
|
||||
reason: process.platform === 'linux' ? '' : '网页安装支持 Linux systemd 部署;当前环境可检查版本。' };
|
||||
}
|
||||
async function start(req) {
|
||||
if (!capability().supported) throw Object.assign(new Error(capability().reason), { statusCode: 400 });
|
||||
if (!/^[a-zA-Z0-9_.@-]+$/.test(SERVICE)) throw new Error('更新服务名配置无效');
|
||||
await exec('systemctl', ['is-active', `${SERVICE}.service`], { timeout: 10000 });
|
||||
const dirty = await exec('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: ROOT, timeout: 10000 });
|
||||
if (dirty.stdout.trim()) throw Object.assign(new Error('部署目录有未提交或未跟踪的文件,请先处理后再更新'), { statusCode: 409 });
|
||||
const repo = updater.normalizeRepo(process.env.HAPPY_UPDATE_REPO || updater.DEFAULT_REPO);
|
||||
if (!repo || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) throw new Error('服务器更新源配置无效');
|
||||
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
||||
try { fs.writeFileSync(LOCK, String(Date.now()), { flag: 'wx', mode: 0o600 }); }
|
||||
catch (error) {
|
||||
if (error.code === 'EEXIST') throw Object.assign(new Error('已有更新任务正在执行'), { statusCode: 409 });
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = await updater.checkUpdate(repo);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
if (!result.hasUpdate) throw Object.assign(new Error('当前已是最新版本'), { statusCode: 409 });
|
||||
if (!/^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(result.tag)) throw new Error('版本标签无效');
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const jobDir = path.join(DIR, id);
|
||||
fs.mkdirSync(jobDir, { mode: 0o700 });
|
||||
const worker = path.join(jobDir, 'worker.js');
|
||||
fs.copyFileSync(path.join(__dirname, 'updateWorker.js'), worker);
|
||||
const config = path.join(jobDir, 'config.json');
|
||||
fs.writeFileSync(config, JSON.stringify({ root: ROOT, data: DATA, dir: DIR, jobDir, service: SERVICE,
|
||||
repo, tag: result.tag, version: result.latest, node: process.execPath, port: process.env.PORT || '4300' }), { mode: 0o600 });
|
||||
const status = { id, state: 'queued', message: '更新任务已创建', version: result.latest,
|
||||
startedAt: new Date().toISOString(), username: req.user.username };
|
||||
fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 });
|
||||
await exec('systemd-run', ['--unit', `happy-update-${id}`, '--collect', '--property=Type=exec',
|
||||
process.execPath, worker, config], { timeout: 15000 });
|
||||
return status;
|
||||
} catch (error) {
|
||||
fs.rmSync(LOCK, { force: true });
|
||||
fs.writeFileSync(STATUS, JSON.stringify({ state: 'failed', message: error.message, finishedAt: new Date().toISOString() }), { mode: 0o600 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
module.exports = { start, readStatus, capability };
|
||||
Reference in New Issue
Block a user