/** 管理员网页更新入口。安装由独立 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 };