309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
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>): 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();
|
|
}
|