fix(备份): 修复'源数据库文件不存在'报错; 改为内存DB直出备份并热重载还原(免重启)
Deploy to Server / deploy (push) Successful in 1m52s
Deploy to Server / deploy (push) Successful in 1m52s
This commit is contained in:
@@ -140,13 +140,13 @@ export default function Backups() {
|
|||||||
` · 备份文件:${it.filename}\n` +
|
` · 备份文件:${it.filename}\n` +
|
||||||
` · 备份时间:${it.created_at}\n` +
|
` · 备份时间:${it.created_at}\n` +
|
||||||
` · 文件大小:${fmtSize(it.size_bytes)}\n\n` +
|
` · 文件大小:${fmtSize(it.size_bytes)}\n\n` +
|
||||||
`还原后将覆盖当前所有数据(部员/活动/时长记录/四小时核销记录等),\n` +
|
`还原后将用该备份内容覆盖当前数据库(部员/活动/时长记录/四小时核销记录等全部会替换)。\n` +
|
||||||
`系统会先对当前数据库自动拍一张「还原前快照(restoresnap)」作为应急回滚点,\n` +
|
`系统会先对当前数据库自动拍一张「还原前快照(restoresnap)」作为应急回滚点,\n` +
|
||||||
`然后覆盖数据库并自动重启后端进程(Docker/守护进程会拉起来)。\n\n` +
|
`完成后直接热更新运行中的数据库(无需重启服务端进程),刷新前端页面即可看到还原后的内容。\n\n` +
|
||||||
`请在下方输入框输入:"我确认从备份 ${it.filename} 还原并重启服务"`;
|
`请在下方输入框输入:"我确认从备份 ${it.filename} 还原并覆盖当前数据库"`;
|
||||||
const i1 = window.prompt(m1, '');
|
const i1 = window.prompt(m1, '');
|
||||||
if (i1 === null) return;
|
if (i1 === null) return;
|
||||||
const expected = `我确认从备份 ${it.filename} 还原并重启服务`;
|
const expected = `我确认从备份 ${it.filename} 还原并覆盖当前数据库`;
|
||||||
if (i1.trim() !== expected) { alert('确认文本不匹配,已取消'); return; }
|
if (i1.trim() !== expected) { alert('确认文本不匹配,已取消'); return; }
|
||||||
const i2 = window.prompt(
|
const i2 = window.prompt(
|
||||||
`最后一道确认:请再次输入 "YES, RESTORE NOW"(全大写,不含引号)以继续。`,
|
`最后一道确认:请再次输入 "YES, RESTORE NOW"(全大写,不含引号)以继续。`,
|
||||||
@@ -160,14 +160,14 @@ export default function Backups() {
|
|||||||
const r = await exportApi.backupRestore(it.filename);
|
const r = await exportApi.backupRestore(it.filename);
|
||||||
if (r?.ok) {
|
if (r?.ok) {
|
||||||
alert(
|
alert(
|
||||||
`还原已受理,后端将在 1 秒内重启。\n\n` +
|
`还原已完成!\n\n` +
|
||||||
`还原前自动快照:${r.snapshot.filename}\n` +
|
`还原前自动快照:${r.snapshot.filename}\n` +
|
||||||
`快照大小:${fmtSize(r.snapshot.size_bytes)}\n` +
|
`快照大小:${fmtSize(r.snapshot.size_bytes)}\n` +
|
||||||
`快照时间:${r.snapshot.created_at}\n\n` +
|
`快照时间:${r.snapshot.created_at}\n\n` +
|
||||||
`若重启后出现异常,请使用该快照文件再次还原或联系管理员。\n\n` +
|
`后端已热更新运行中的数据库,将在 2 秒后自动刷新当前页面以查看最新数据。\n` +
|
||||||
`将在 5 秒后自动刷新当前页面。`
|
`若刷新后仍看到旧内容,请手动再刷一次。`
|
||||||
);
|
);
|
||||||
setTimeout(() => window.location.reload(), 5000);
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRestarting(false);
|
setRestarting(false);
|
||||||
@@ -216,8 +216,8 @@ export default function Backups() {
|
|||||||
|
|
||||||
{restarting ? (
|
{restarting ? (
|
||||||
<div className="card p-5 md:p-8 text-center bg-amber-50 border border-amber-200 animate-pulse">
|
<div className="card p-5 md:p-8 text-center bg-amber-50 border border-amber-200 animate-pulse">
|
||||||
<div className="text-lg md:text-xl font-semibold text-amber-800">后端正在重启以完成还原…请稍候,页面将自动刷新</div>
|
<div className="text-lg md:text-xl font-semibold text-amber-800">正在刷新以加载还原后的数据库…请稍候</div>
|
||||||
<div className="muted mt-2">若 30 秒后仍无法访问,请手动刷新页面或检查容器/守护进程。</div>
|
<div className="muted mt-2">若 30 秒后仍停留在该状态,请手动刷新页面。</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
+22
-13
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { getDataDir as dbGetDataDir, getDbPath as dbGetDbPath, getDbBuffer, reloadDbFromBuffer } from './db';
|
||||||
|
|
||||||
const TZ_OFFSET_HOURS = 8;
|
const TZ_OFFSET_HOURS = 8;
|
||||||
const BACKUP_FILENAME_RE = /^volunteer-backup-(\d{8})-(\d{6})-([A-Za-z0-9]{8})\.db$/;
|
const BACKUP_FILENAME_RE = /^volunteer-backup-(\d{8})-(\d{6})-([A-Za-z0-9]{8})\.db$/;
|
||||||
@@ -40,7 +41,7 @@ function fmtIso(d: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDataDir(): string {
|
export function getDataDir(): string {
|
||||||
const p = path.join(__dirname, '..', '..', 'data');
|
const p = dbGetDataDir() || path.join(__dirname, '..', '..', 'data');
|
||||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ export function getBackupsDir(): string {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
export function getDbPath(): string {
|
export function getDbPath(): string {
|
||||||
return path.join(getDataDir(), 'volunteer.db');
|
return dbGetDbPath() || path.join(getDataDir(), 'volunteer.db');
|
||||||
}
|
}
|
||||||
function getConfigPath(): string {
|
function getConfigPath(): string {
|
||||||
return path.join(getDataDir(), 'backup-config.json');
|
return path.join(getDataDir(), 'backup-config.json');
|
||||||
@@ -148,18 +149,25 @@ function cleanupByKeep(cfg: BackupConfig): BackupItem[] {
|
|||||||
|
|
||||||
export function createBackup(opts: { trigger?: 'manual' | 'scheduled'; forceSnapshot?: boolean } = {}): BackupItem {
|
export function createBackup(opts: { trigger?: 'manual' | 'scheduled'; forceSnapshot?: boolean } = {}): BackupItem {
|
||||||
const trigger = opts.trigger || 'manual';
|
const trigger = opts.trigger || 'manual';
|
||||||
const src = getDbPath();
|
|
||||||
if (!fs.existsSync(src)) {
|
|
||||||
throw new Error('源数据库文件不存在,无法备份');
|
|
||||||
}
|
|
||||||
const dir = getBackupsDir();
|
const dir = getBackupsDir();
|
||||||
const stamp = fmtStamp(utc8Now());
|
const stamp = fmtStamp(utc8Now());
|
||||||
const rand = crypto.randomBytes(4).toString('hex');
|
const rand = crypto.randomBytes(4).toString('hex');
|
||||||
const prefix = opts.forceSnapshot ? 'volunteer-restoresnap' : 'volunteer-backup';
|
const prefix = opts.forceSnapshot ? 'volunteer-restoresnap' : 'volunteer-backup';
|
||||||
const filename = `${prefix}-${stamp}-${rand}.db`;
|
const filename = `${prefix}-${stamp}-${rand}.db`;
|
||||||
const dest = path.join(dir, filename);
|
const dest = path.join(dir, filename);
|
||||||
// copy with integrity: read -> verify sha -> write
|
// 获取 DB 原始二进制:优先从内存 DB 直接 export,磁盘文件仅作为兜底回退路径,避免路径不一致
|
||||||
const buf = fs.readFileSync(src);
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
const mem = getDbBuffer();
|
||||||
|
buf = Buffer.from(mem);
|
||||||
|
} catch (_e) {
|
||||||
|
const src = getDbPath();
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
throw new Error('源数据库文件不存在,无法备份');
|
||||||
|
}
|
||||||
|
buf = fs.readFileSync(src);
|
||||||
|
}
|
||||||
|
if (!buf || buf.length < 100) throw new Error('数据库为空,已中止备份');
|
||||||
fs.writeFileSync(dest, buf);
|
fs.writeFileSync(dest, buf);
|
||||||
const verify = fs.readFileSync(dest);
|
const verify = fs.readFileSync(dest);
|
||||||
if (verify.length !== buf.length || crypto.createHash('sha256').update(buf).digest('hex') !== crypto.createHash('sha256').update(verify).digest('hex')) {
|
if (verify.length !== buf.length || crypto.createHash('sha256').update(buf).digest('hex') !== crypto.createHash('sha256').update(verify).digest('hex')) {
|
||||||
@@ -192,17 +200,18 @@ export function deleteBackup(filename: string): { ok: boolean; deleted: boolean
|
|||||||
return { ok: true, deleted: true };
|
return { ok: true, deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreBackup(filename: string): { ok: true; snapshot: BackupItem } {
|
export function restoreBackup(filename: string): { ok: true; snapshot: BackupItem; db_reinitialized: boolean } {
|
||||||
if (!safeFilename(filename)) throw new Error('备份文件名非法');
|
if (!safeFilename(filename)) throw new Error('备份文件名非法');
|
||||||
const p = path.join(getBackupsDir(), filename);
|
const p = path.join(getBackupsDir(), filename);
|
||||||
if (!fs.existsSync(p)) throw new Error('备份文件不存在');
|
if (!fs.existsSync(p)) throw new Error('备份文件不存在');
|
||||||
const src = getDbPath();
|
|
||||||
// 1) 先对当前数据库拍一张 restoresnap,防止误操作
|
// 1) 先对当前数据库拍一张 restoresnap,防止误操作
|
||||||
const snap = createBackup({ trigger: 'manual', forceSnapshot: true });
|
const snap = createBackup({ trigger: 'manual', forceSnapshot: true });
|
||||||
// 2) 覆盖 database file
|
// 2) 覆盖磁盘 DB 文件,并同时 reload 运行中的内存 DB(避免必须重启进程才生效,也绕过磁盘路径不一致)
|
||||||
const buf = fs.readFileSync(p);
|
const buf = fs.readFileSync(p);
|
||||||
fs.writeFileSync(src, buf);
|
if (!buf || buf.length < 100) throw new Error('备份文件过短,已中止还原');
|
||||||
return { ok: true, snapshot: snap };
|
reloadDbFromBuffer(new Uint8Array(buf));
|
||||||
|
fs.writeFileSync(getDbPath(), buf);
|
||||||
|
return { ok: true, snapshot: snap, db_reinitialized: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextRunMs(cfg: BackupConfig): number | null {
|
export function getNextRunMs(cfg: BackupConfig): number | null {
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ let db: (Database & {
|
|||||||
execCompat(sql: string): void;
|
execCompat(sql: string): void;
|
||||||
pragmaCompat(expr: string): void;
|
pragmaCompat(expr: string): void;
|
||||||
save(): void;
|
save(): void;
|
||||||
|
exportBuffer(): Uint8Array;
|
||||||
}) | null = null;
|
}) | null = null;
|
||||||
let dbFilePath: string = '';
|
let dbFilePath: string = '';
|
||||||
|
let dataDirPath: string = '';
|
||||||
|
|
||||||
|
export function getDataDir(): string { return dataDirPath; }
|
||||||
|
export function getDbPath(): string { return dbFilePath; }
|
||||||
|
export function getDbBuffer(): Uint8Array {
|
||||||
|
if (!db) throw new Error('getDbBuffer: database not initialized');
|
||||||
|
return db.exportBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
interface StmtCompat {
|
interface StmtCompat {
|
||||||
run(...params: any[]): { changes: number; lastInsertRowid: number };
|
run(...params: any[]): { changes: number; lastInsertRowid: number };
|
||||||
@@ -86,6 +95,7 @@ export async function initDb() {
|
|||||||
|
|
||||||
const dataDir = path.join(__dirname, '..', '..', 'data');
|
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
dataDirPath = dataDir;
|
||||||
dbFilePath = path.join(dataDir, 'volunteer.db');
|
dbFilePath = path.join(dataDir, 'volunteer.db');
|
||||||
|
|
||||||
let rawDb: Database;
|
let rawDb: Database;
|
||||||
@@ -106,6 +116,9 @@ export async function initDb() {
|
|||||||
const data = extended.export();
|
const data = extended.export();
|
||||||
fs.writeFileSync(dbFilePath, Buffer.from(data));
|
fs.writeFileSync(dbFilePath, Buffer.from(data));
|
||||||
};
|
};
|
||||||
|
extended.exportBuffer = function () {
|
||||||
|
return new Uint8Array(extended.export());
|
||||||
|
};
|
||||||
|
|
||||||
db = extended;
|
db = extended;
|
||||||
const d = extended;
|
const d = extended;
|
||||||
@@ -234,4 +247,27 @@ export function getDb() {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reloadDbFromBuffer(buf: Uint8Array | Buffer): void {
|
||||||
|
if (!SQL) throw new Error('SQL module not loaded, call initDb() first');
|
||||||
|
if (!Buffer.isBuffer(buf) && !(buf instanceof Uint8Array)) throw new Error('reloadDbFromBuffer: illegal buffer type');
|
||||||
|
const rawDb = new SQL.Database(new Uint8Array(buf));
|
||||||
|
const extended = rawDb as any;
|
||||||
|
extended.prepareCompat = function (sql: string) { return wrapStmt(extended, sql); };
|
||||||
|
extended.execCompat = function (sql: string) { extended.exec(sql); };
|
||||||
|
extended.pragmaCompat = function (expr: string) {
|
||||||
|
try { extended.exec(`PRAGMA ${expr};`); } catch {}
|
||||||
|
};
|
||||||
|
extended.save = function () {
|
||||||
|
const data = extended.export();
|
||||||
|
fs.writeFileSync(dbFilePath, Buffer.from(data));
|
||||||
|
};
|
||||||
|
extended.exportBuffer = function () {
|
||||||
|
return new Uint8Array(extended.export());
|
||||||
|
};
|
||||||
|
try { extended.pragmaCompat('foreign_keys = ON'); } catch {}
|
||||||
|
// 切换全局引用:需要注意 keep using same variable
|
||||||
|
if (db) { try { (db as any).close?.(); } catch { /* noop for sql.js */ } }
|
||||||
|
db = extended;
|
||||||
|
}
|
||||||
|
|
||||||
export type DbInstance = NonNullable<typeof db>;
|
export type DbInstance = NonNullable<typeof db>;
|
||||||
|
|||||||
@@ -600,13 +600,20 @@ router.post('/backups/restore', authMiddleware, (req: AuthRequest, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = restoreBackup(filename);
|
const r = restoreBackup(filename);
|
||||||
res.json({ ok: true, snapshot: r.snapshot, will_restart: true });
|
// 写入还原操作标记(当前已经即时生效,不再进程退出,仅留痕)
|
||||||
// 写入重启标记,避免响应未发出即退出
|
|
||||||
const marker = path.join(getBackupsDir(), '.restart_after_restore');
|
const marker = path.join(getBackupsDir(), '.restart_after_restore');
|
||||||
try { fs.writeFileSync(marker, JSON.stringify({ at: new Date().toISOString(), by: req.adminUsername || null, from: filename }), 'utf8'); } catch { /* noop */ }
|
try {
|
||||||
setTimeout(() => {
|
fs.writeFileSync(
|
||||||
try { process.exit(0); } catch { /* noop */ }
|
marker,
|
||||||
}, 800);
|
JSON.stringify(
|
||||||
|
{ at: new Date().toISOString(), by: req.adminUsername || null, from: filename, db_reinitialized: !!r.db_reinitialized },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
res.json({ ok: true, snapshot: r.snapshot, will_restart: false, db_reinitialized: !!r.db_reinitialized });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
res.status(500).json({ error: e?.message || '还原失败,未覆盖当前数据库' });
|
res.status(500).json({ error: e?.message || '还原失败,未覆盖当前数据库' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user