diff --git a/client/src/pages/admin/Backups.tsx b/client/src/pages/admin/Backups.tsx index 92f8607..4a9e94d 100644 --- a/client/src/pages/admin/Backups.tsx +++ b/client/src/pages/admin/Backups.tsx @@ -140,13 +140,13 @@ export default function Backups() { ` · 备份文件:${it.filename}\n` + ` · 备份时间:${it.created_at}\n` + ` · 文件大小:${fmtSize(it.size_bytes)}\n\n` + - `还原后将覆盖当前所有数据(部员/活动/时长记录/四小时核销记录等),\n` + + `还原后将用该备份内容覆盖当前数据库(部员/活动/时长记录/四小时核销记录等全部会替换)。\n` + `系统会先对当前数据库自动拍一张「还原前快照(restoresnap)」作为应急回滚点,\n` + - `然后覆盖数据库并自动重启后端进程(Docker/守护进程会拉起来)。\n\n` + - `请在下方输入框输入:"我确认从备份 ${it.filename} 还原并重启服务"`; + `完成后直接热更新运行中的数据库(无需重启服务端进程),刷新前端页面即可看到还原后的内容。\n\n` + + `请在下方输入框输入:"我确认从备份 ${it.filename} 还原并覆盖当前数据库"`; const i1 = window.prompt(m1, ''); if (i1 === null) return; - const expected = `我确认从备份 ${it.filename} 还原并重启服务`; + const expected = `我确认从备份 ${it.filename} 还原并覆盖当前数据库`; if (i1.trim() !== expected) { alert('确认文本不匹配,已取消'); return; } const i2 = window.prompt( `最后一道确认:请再次输入 "YES, RESTORE NOW"(全大写,不含引号)以继续。`, @@ -160,14 +160,14 @@ export default function Backups() { const r = await exportApi.backupRestore(it.filename); if (r?.ok) { alert( - `还原已受理,后端将在 1 秒内重启。\n\n` + + `还原已完成!\n\n` + `还原前自动快照:${r.snapshot.filename}\n` + `快照大小:${fmtSize(r.snapshot.size_bytes)}\n` + `快照时间:${r.snapshot.created_at}\n\n` + - `若重启后出现异常,请使用该快照文件再次还原或联系管理员。\n\n` + - `将在 5 秒后自动刷新当前页面。` + `后端已热更新运行中的数据库,将在 2 秒后自动刷新当前页面以查看最新数据。\n` + + `若刷新后仍看到旧内容,请手动再刷一次。` ); - setTimeout(() => window.location.reload(), 5000); + setTimeout(() => window.location.reload(), 2000); } } catch (e) { setRestarting(false); @@ -216,8 +216,8 @@ export default function Backups() { {restarting ? (
-
后端正在重启以完成还原…请稍候,页面将自动刷新
-
若 30 秒后仍无法访问,请手动刷新页面或检查容器/守护进程。
+
正在刷新以加载还原后的数据库…请稍候
+
若 30 秒后仍停留在该状态,请手动刷新页面。
) : null} diff --git a/server/src/backup.ts b/server/src/backup.ts index 41d401d..ce11e07 100644 --- a/server/src/backup.ts +++ b/server/src/backup.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; +import { getDataDir as dbGetDataDir, getDbPath as dbGetDbPath, getDbBuffer, reloadDbFromBuffer } from './db'; const TZ_OFFSET_HOURS = 8; 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 { - const p = path.join(__dirname, '..', '..', 'data'); + const p = dbGetDataDir() || path.join(__dirname, '..', '..', 'data'); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); return p; } @@ -50,7 +51,7 @@ export function getBackupsDir(): string { return p; } export function getDbPath(): string { - return path.join(getDataDir(), 'volunteer.db'); + return dbGetDbPath() || path.join(getDataDir(), 'volunteer.db'); } function getConfigPath(): string { 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 { const trigger = opts.trigger || 'manual'; - const src = getDbPath(); - if (!fs.existsSync(src)) { - throw new Error('源数据库文件不存在,无法备份'); - } const dir = getBackupsDir(); const stamp = fmtStamp(utc8Now()); const rand = crypto.randomBytes(4).toString('hex'); const prefix = opts.forceSnapshot ? 'volunteer-restoresnap' : 'volunteer-backup'; const filename = `${prefix}-${stamp}-${rand}.db`; const dest = path.join(dir, filename); - // copy with integrity: read -> verify sha -> write - const buf = fs.readFileSync(src); + // 获取 DB 原始二进制:优先从内存 DB 直接 export,磁盘文件仅作为兜底回退路径,避免路径不一致 + 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); const verify = fs.readFileSync(dest); 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 }; } -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('备份文件名非法'); const p = path.join(getBackupsDir(), filename); if (!fs.existsSync(p)) throw new Error('备份文件不存在'); - const src = getDbPath(); // 1) 先对当前数据库拍一张 restoresnap,防止误操作 const snap = createBackup({ trigger: 'manual', forceSnapshot: true }); - // 2) 覆盖 database file + // 2) 覆盖磁盘 DB 文件,并同时 reload 运行中的内存 DB(避免必须重启进程才生效,也绕过磁盘路径不一致) const buf = fs.readFileSync(p); - fs.writeFileSync(src, buf); - return { ok: true, snapshot: snap }; + if (!buf || buf.length < 100) throw new Error('备份文件过短,已中止还原'); + reloadDbFromBuffer(new Uint8Array(buf)); + fs.writeFileSync(getDbPath(), buf); + return { ok: true, snapshot: snap, db_reinitialized: true }; } export function getNextRunMs(cfg: BackupConfig): number | null { diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 539376d..a255752 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -9,8 +9,17 @@ let db: (Database & { execCompat(sql: string): void; pragmaCompat(expr: string): void; save(): void; + exportBuffer(): Uint8Array; }) | null = null; 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 { run(...params: any[]): { changes: number; lastInsertRowid: number }; @@ -86,6 +95,7 @@ export async function initDb() { const dataDir = path.join(__dirname, '..', '..', 'data'); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + dataDirPath = dataDir; dbFilePath = path.join(dataDir, 'volunteer.db'); let rawDb: Database; @@ -106,6 +116,9 @@ export async function initDb() { const data = extended.export(); fs.writeFileSync(dbFilePath, Buffer.from(data)); }; + extended.exportBuffer = function () { + return new Uint8Array(extended.export()); + }; db = extended; const d = extended; @@ -234,4 +247,27 @@ export function getDb() { 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; diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts index 10f5506..faf1619 100644 --- a/server/src/routes/export.ts +++ b/server/src/routes/export.ts @@ -600,13 +600,20 @@ router.post('/backups/restore', authMiddleware, (req: AuthRequest, res) => { } try { const r = restoreBackup(filename); - res.json({ ok: true, snapshot: r.snapshot, will_restart: true }); - // 写入重启标记,避免响应未发出即退出 + // 写入还原操作标记(当前已经即时生效,不再进程退出,仅留痕) 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 */ } - setTimeout(() => { - try { process.exit(0); } catch { /* noop */ } - }, 800); + try { + fs.writeFileSync( + marker, + 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) { res.status(500).json({ error: e?.message || '还原失败,未覆盖当前数据库' }); }