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$/; const SNAP_FILENAME_RE = /^volunteer-restoresnap-(\d{8})-(\d{6})-([A-Za-z0-9]{8})\.db$/; export interface BackupConfig { enabled: boolean; mode: 'manual' | 'hourly' | 'every6h' | 'every12h' | 'daily'; daily_time: string; keep: number; } export interface BackupItem { filename: string; created_at: string; size_bytes: number; kind: 'backup' | 'restoresnap'; } export interface BackupRuntime { config: BackupConfig; last_run: string | null; next_run: string | null; } function utc8Now(): Date { const d = new Date(); const utcMs = d.getTime() + d.getTimezoneOffset() * 60000; return new Date(utcMs + TZ_OFFSET_HOURS * 3600000); } function pad2(n: number) { return String(Math.max(0, Math.floor(n))).padStart(2, '0'); } function fmtStamp(d: Date) { return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`; } function fmtIso(d: Date) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } export function getDataDir(): string { const p = dbGetDataDir() || path.join(__dirname, '..', '..', 'data'); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); return p; } export function getBackupsDir(): string { const p = path.join(getDataDir(), 'backups'); if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); return p; } export function getDbPath(): string { return dbGetDbPath() || path.join(getDataDir(), 'volunteer.db'); } function getConfigPath(): string { return path.join(getDataDir(), 'backup-config.json'); } const DEFAULT_CONFIG: BackupConfig = { enabled: true, mode: 'daily', daily_time: '02:00', keep: 30, }; export function loadConfig(): BackupConfig { const fp = getConfigPath(); try { if (!fs.existsSync(fp)) { fs.writeFileSync(fp, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8'); return { ...DEFAULT_CONFIG }; } const raw = JSON.parse(fs.readFileSync(fp, 'utf8')); const cfg: BackupConfig = { ...DEFAULT_CONFIG, ...(raw || {}) }; if (!['manual', 'hourly', 'every6h', 'every12h', 'daily'].includes(cfg.mode)) cfg.mode = 'daily'; if (!/^\d{2}:\d{2}$/.test(cfg.daily_time)) cfg.daily_time = '02:00'; if (!Number.isFinite(cfg.keep) || cfg.keep < 1) cfg.keep = 30; cfg.keep = Math.min(365, Math.max(1, Math.floor(cfg.keep))); cfg.enabled = !!cfg.enabled; return cfg; } catch (e) { console.error('[backup] loadConfig error, use defaults', e); return { ...DEFAULT_CONFIG }; } } export function saveConfig(cfg: Partial): BackupConfig { const current = loadConfig(); const next: BackupConfig = { ...current, ...cfg }; if (!['manual', 'hourly', 'every6h', 'every12h', 'daily'].includes(next.mode)) next.mode = 'daily'; if (!/^\d{2}:\d{2}$/.test(next.daily_time)) next.daily_time = '02:00'; if (!Number.isFinite(next.keep) || next.keep < 1) next.keep = 30; next.keep = Math.min(365, Math.max(1, Math.floor(next.keep))); next.enabled = !!next.enabled; fs.writeFileSync(getConfigPath(), JSON.stringify(next, null, 2), 'utf8'); return next; } function parseBackupDate(name: string): Date | null { const m = name.match(BACKUP_FILENAME_RE) || name.match(SNAP_FILENAME_RE); if (!m) return null; const [, ymd, hms] = m; const yy = Number(ymd.slice(0, 4)); const mo = Number(ymd.slice(4, 6)) - 1; const dd = Number(ymd.slice(6, 8)); const hh = Number(hms.slice(0, 2)); const mi = Number(hms.slice(2, 4)); const ss = Number(hms.slice(4, 6)); return new Date(yy, mo, dd, hh, mi, ss); } export function listBackups(): BackupItem[] { const dir = getBackupsDir(); const names = fs.readdirSync(dir).filter((n) => BACKUP_FILENAME_RE.test(n) || SNAP_FILENAME_RE.test(n)); const items: BackupItem[] = []; for (const name of names) { try { const stat = fs.statSync(path.join(dir, name)); const d = parseBackupDate(name); items.push({ filename: name, created_at: d ? fmtIso(d) : '', size_bytes: Number(stat.size || 0), kind: SNAP_FILENAME_RE.test(name) ? 'restoresnap' : 'backup', }); } catch { // ignore unreadable } } items.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)) || b.filename.localeCompare(a.filename)); return items; } function safeFilename(name: string): boolean { return (BACKUP_FILENAME_RE.test(name) || SNAP_FILENAME_RE.test(name)) && !name.includes('/') && !name.includes('\\') && !name.includes('..'); } function cleanupByKeep(cfg: BackupConfig): BackupItem[] { const list = listBackups(); const keep = Math.max(0, Math.floor(cfg.keep)); if (list.length <= keep) return list; // Keep newest, delete oldest (sorted desc already) const toDelete = list.slice(keep); for (const it of toDelete) { try { fs.unlinkSync(path.join(getBackupsDir(), it.filename)); } catch (e) { console.error('[backup] cleanup error', e); } } return listBackups(); } export function createBackup(opts: { trigger?: 'manual' | 'scheduled'; forceSnapshot?: boolean } = {}): BackupItem { const trigger = opts.trigger || 'manual'; 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); // 获取 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')) { try { fs.unlinkSync(dest); } catch { /* noop */ } throw new Error('备份文件校验失败,已自动删除损坏副本'); } const cfg = loadConfig(); if (trigger === 'scheduled') { const rPath = path.join(getDataDir(), 'backup-runtime.json'); const rt = fs.existsSync(rPath) ? JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}') : {}; rt.last_run = fmtIso(utc8Now()); fs.writeFileSync(rPath, JSON.stringify(rt, null, 2), 'utf8'); } cleanupByKeep(cfg); const stat = fs.statSync(dest); const d = parseBackupDate(filename)!; return { filename, created_at: fmtIso(d), size_bytes: Number(stat.size || 0), kind: opts.forceSnapshot ? 'restoresnap' : 'backup', }; } export function deleteBackup(filename: string): { ok: boolean; deleted: boolean } { if (!safeFilename(filename)) throw new Error('备份文件名非法'); const p = path.join(getBackupsDir(), filename); if (!fs.existsSync(p)) return { ok: true, deleted: false }; fs.unlinkSync(p); return { ok: true, deleted: true }; } 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('备份文件不存在'); // 1) 先对当前数据库拍一张 restoresnap,防止误操作 const snap = createBackup({ trigger: 'manual', forceSnapshot: true }); // 2) 覆盖磁盘 DB 文件,并同时 reload 运行中的内存 DB(避免必须重启进程才生效,也绕过磁盘路径不一致) const buf = fs.readFileSync(p); 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 { if (!cfg.enabled || cfg.mode === 'manual') return null; const now = utc8Now(); let next: Date; if (cfg.mode === 'hourly') { next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 1); next = new Date(next.getTime() + 3600 * 1000); } else if (cfg.mode === 'every6h') { const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 1); const offsetHours = [0, 6, 12, 18]; next = new Date(base); let found = false; for (const h of offsetHours) { const cand = new Date(base.getTime() + h * 3600 * 1000); if (cand.getTime() > now.getTime()) { next = cand; found = true; break; } } if (!found) next = new Date(base.getTime() + 24 * 3600 * 1000); } else if (cfg.mode === 'every12h') { const base = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 1); const offsetHours = [0, 12]; let found = false; next = new Date(base); for (const h of offsetHours) { const cand = new Date(base.getTime() + h * 3600 * 1000); if (cand.getTime() > now.getTime()) { next = cand; found = true; break; } } if (!found) next = new Date(base.getTime() + 24 * 3600 * 1000); } else { // daily const [hhStr, mmStr] = (cfg.daily_time || '02:00').split(':'); const hh = Math.min(23, Math.max(0, Number(hhStr) || 0)); const mm = Math.min(59, Math.max(0, Number(mmStr) || 0)); next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 1); if (next.getTime() <= now.getTime()) next = new Date(next.getTime() + 24 * 3600 * 1000); } return next.getTime() - now.getTime(); } export function getRuntime(): BackupRuntime { const cfg = loadConfig(); const rPath = path.join(getDataDir(), 'backup-runtime.json'); let lastRun: string | null = null; try { if (fs.existsSync(rPath)) { const rt = JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}'); lastRun = rt.last_run || null; } } catch { /* ignore */ } let nextRun: string | null = null; const ms = getNextRunMs(cfg); if (ms !== null) { const d = new Date(utc8Now().getTime() + ms); nextRun = fmtIso(d); } return { config: cfg, last_run: lastRun, next_run: nextRun }; } let tickHandle: any = null; export function startScheduler(onFire?: (item: BackupItem) => void) { if (tickHandle) return; const loop = async () => { try { const cfg = loadConfig(); const wait = getNextRunMs(cfg); const rPath = path.join(getDataDir(), 'backup-runtime.json'); const rt = fs.existsSync(rPath) ? JSON.parse(fs.readFileSync(rPath, 'utf8') || '{}') : {}; const ms = getNextRunMs(cfg); rt.next_run = ms === null ? null : fmtIso(new Date(utc8Now().getTime() + ms)); fs.writeFileSync(rPath, JSON.stringify(rt, null, 2), 'utf8'); if (wait === null || wait <= 0) return; tickHandle = setTimeout(() => { try { const c = loadConfig(); if (c.enabled && c.mode !== 'manual') { const item = createBackup({ trigger: 'scheduled' }); if (onFire) onFire(item); console.log('[backup] scheduled backup created', item.filename, item.size_bytes, 'bytes'); } } catch (e) { console.error('[backup] scheduled backup failed', e); } // schedule next iteration tickHandle = null; loop(); }, Math.min(wait, 2 ** 31 - 1)); } catch (e) { console.error('[backup] scheduler loop error', e); tickHandle = null; setTimeout(loop, 30 * 1000); } }; loop(); }