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:
Drsakura
2026-10-22 17:24:19 +08:00
parent dc607e4ea3
commit 1ffcc1ea07
20 changed files with 905 additions and 585 deletions
+21 -2
View File
@@ -41,6 +41,22 @@ function verifyPassword(plain, stored) {
return crypto.timingSafeEqual(a, b);
}
/* 密码复核与登录共享失败次数和锁定,避免已登录会话无限猜密码。 */
function verifyCurrentPassword(req, password) {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const reject = (message, statusCode) => { throw Object.assign(new Error(message), { statusCode }); };
if (user.locked_until && Date.parse(user.locked_until) > Date.now()) reject('账号已锁定,请稍后再试', 429);
if (!password) reject('请输入当前密码', 400);
if (!verifyPassword(String(password), user.password_hash)) {
const failed = Number(user.failed_count || 0) + 1;
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?')
.run(failed, failed >= MAX_FAILED ? new Date(Date.now() + LOCK_MS).toISOString() : null, user.id);
audit({ username: user.username, action: 'password_verify_failed', detail: '敏感操作密码验证失败', req, ok: 0 });
reject(failed >= MAX_FAILED ? '密码连续错误,账号已锁定 15 分钟' : '当前密码不正确', failed >= MAX_FAILED ? 429 : 400);
}
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL WHERE id = ?').run(user.id);
}
/* ---------------------------------------------------------------- cookie */
function parseCookies(header) {
@@ -62,12 +78,13 @@ function parseCookies(header) {
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
res.append('Set-Cookie', [
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`,
...(process.env.SECURE_COOKIE === '1' ? ['Secure'] : [])
].join('; '));
}
function clearSessionCookie(res) {
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
setSessionCookie(res, '', 0);
}
function clientIp(req) {
@@ -110,6 +127,7 @@ function sessionUser(req) {
return null;
}
if (!row.is_active) return null;
if (row.locked_until && Date.parse(row.locked_until) > Date.now()) return null;
return row;
}
@@ -210,6 +228,7 @@ module.exports = {
LOCK_MS,
hashPassword,
verifyPassword,
verifyCurrentPassword,
parseCookies,
setSessionCookie,
clearSessionCookie,
+149
View File
@@ -0,0 +1,149 @@
// 被复制到数据目录后独立运行;仅用内置模块,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; });
}
+2 -5
View File
@@ -1,11 +1,8 @@
/**
* happy 出口通 —— 系统版本检查
*
* 与 sku-manager 的 updater 不同,这里**只做检查,不做自动替换**:
* happy 是整体应用(前端 dist + 后端服务 + 数据库),更新应当走
* `git pull` + 重新构建 + 重启服务,而不是像 sku-manager 那样下载 zip
* 换版本目录。所以本模块只负责回答一个问题:
* 远端仓库的版本号 vs 本机版本号,谁新?
* 本模块检查 GitHub Release/tag;网页安装入口见 webUpdate.js,
* 独立更新任务见 updateWorker.js(构建、备份、切换与重启)。
*
* 版本来源,按优先级:
* 1. GitHub Releases 的 latest.tag_name —— 推荐用法(打 tag 并发 Release)
+83
View File
@@ -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 };