diff --git a/client/src/pages/admin/Layout.tsx b/client/src/pages/admin/Layout.tsx
index 0fdd15c..fc837fc 100644
--- a/client/src/pages/admin/Layout.tsx
+++ b/client/src/pages/admin/Layout.tsx
@@ -5,7 +5,7 @@ import Modal from '../../components/Modal';
import Input from '../../components/Input';
import Brand from '../../components/Brand';
import { authApi, errMsg } from '../../api';
-import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu, IconFourHour } from '../../components/Icons';
+import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu, IconFourHour, IconBackup } from '../../components/Icons';
type MenuItem = { path: string; label: string; icon: ReactNode };
@@ -15,6 +15,7 @@ const menu: MenuItem[] = [
{ path: '/admin/users', label: '用户管理', icon: },
{ path: '/admin/four-hour-export', label: '四小时导出', icon: },
{ path: '/admin/export', label: '数据导出', icon: },
+ { path: '/admin/backups', label: '备份管理', icon: },
];
export default function Layout() {
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index d87860a..f73968e 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -107,6 +107,42 @@ export interface FourHourPlanItem {
delta: number;
}
+export interface FourHourExportHistoryItem {
+ id: number;
+ status: 'active' | 'voided' | string;
+ admin_username: string | null;
+ eligible_count: number;
+ eligible_hours_total: number;
+ items_updated: number;
+ note: string | null;
+ created_at: string | null;
+ voided_at: string | null;
+ can_void: boolean;
+}
+
+export interface BackupItem {
+ filename: string;
+ created_at: string;
+ size_bytes: number;
+ kind: 'backup' | 'restoresnap';
+}
+
+export interface BackupConfig {
+ enabled: boolean;
+ mode: 'manual' | 'hourly' | 'every6h' | 'every12h' | 'daily';
+ daily_time: string;
+ keep: number;
+}
+
+export interface BackupRuntime {
+ ok: boolean;
+ config: BackupConfig;
+ last_run: string | null;
+ next_run: string | null;
+ db_path?: string;
+ backups_dir?: string;
+}
+
export interface FourHourPreviewResult {
ok: boolean;
generated_at: string;
diff --git a/server/src/backup.ts b/server/src/backup.ts
new file mode 100644
index 0000000..41d401d
--- /dev/null
+++ b/server/src/backup.ts
@@ -0,0 +1,299 @@
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+
+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 = 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 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 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);
+ 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 } {
+ 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
+ const buf = fs.readFileSync(p);
+ fs.writeFileSync(src, buf);
+ return { ok: true, snapshot: snap };
+}
+
+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();
+}
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
index 9a8e9ba..a06321b 100644
--- a/server/src/db/schema.sql
+++ b/server/src/db/schema.sql
@@ -56,3 +56,26 @@ CREATE TABLE IF NOT EXISTS admins (
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours'))
);
+
+CREATE TABLE IF NOT EXISTS four_hour_exports (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ status TEXT NOT NULL DEFAULT 'active',
+ admin_username TEXT,
+ eligible_count INTEGER NOT NULL DEFAULT 0,
+ eligible_hours_total REAL NOT NULL DEFAULT 0,
+ items_updated INTEGER NOT NULL DEFAULT 0,
+ plan_json TEXT,
+ note TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
+ voided_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS four_hour_export_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ export_id INTEGER NOT NULL,
+ hour_id INTEGER NOT NULL,
+ delta REAL NOT NULL,
+ FOREIGN KEY (export_id) REFERENCES four_hour_exports(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_fhe_items_export ON four_hour_export_items(export_id);
+CREATE INDEX IF NOT EXISTS idx_fhe_items_hour ON four_hour_export_items(hour_id);
diff --git a/server/src/index.ts b/server/src/index.ts
index 37c89ca..e875e9e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -9,6 +9,7 @@ import usersRoutes from './routes/users';
import activitiesRoutes from './routes/activities';
import hoursRoutes from './routes/hours';
import exportRoutes from './routes/export';
+import { startScheduler } from './backup';
process.env.TZ = 'Asia/Shanghai';
@@ -36,6 +37,13 @@ async function bootstrap() {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Volunteer system server running on http://0.0.0.0:${PORT}`);
+ try {
+ startScheduler((item) => {
+ console.log(`[backup] Scheduled backup written: ${item.filename} (${item.size_bytes} B)`);
+ });
+ } catch (e) {
+ console.error('[backup] scheduler start failed', e);
+ }
});
}
diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts
index 9a63098..c1c4a9d 100644
--- a/server/src/routes/export.ts
+++ b/server/src/routes/export.ts
@@ -1,6 +1,19 @@
import { Router } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
+import fs from 'fs';
+import path from 'path';
+import {
+ createBackup,
+ deleteBackup,
+ getBackupsDir,
+ getDbPath,
+ getRuntime,
+ listBackups,
+ loadConfig,
+ restoreBackup,
+ saveConfig,
+} from '../backup';
const router = Router();
@@ -352,6 +365,9 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
res.status(400).json({ error: '导出计划为空,请先生成预览' });
return;
}
+ const eligibleCount = Number((req.body as any)?.eligible_count || 0) || 0;
+ const eligibleHours = Number((req.body as any)?.eligible_hours_total || 0) || 0;
+ const adminUsername = String(req.adminUsername || '').trim() || null;
const db = getDb();
try {
db.execCompat('BEGIN');
@@ -361,17 +377,40 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
`);
let updated = 0;
let skipped = 0;
+ let totalApplied = 0;
+ const appliedItems: Array<{ hour_id: number; delta: number }> = [];
for (const p of plan) {
const hid = Number(p.hour_id);
const d = Number(p.delta || 0);
if (!hid || d <= 0.0001) { skipped++; continue; }
const r = updStmt.run(d, hid, d);
- if (r && r.changes > 0) updated++;
- else skipped++;
+ if (r && r.changes > 0) {
+ updated++;
+ totalApplied += d;
+ appliedItems.push({ hour_id: hid, delta: Number(d.toFixed(1)) });
+ } else {
+ skipped++;
+ }
+ }
+ const planJson = JSON.stringify(plan);
+ const noteRaw = (req.body as any)?.note;
+ const note = noteRaw ? String(noteRaw).slice(0, 500) : null;
+ const insEx = db.prepareCompat(`
+ INSERT INTO four_hour_exports (status, admin_username, eligible_count, eligible_hours_total, items_updated, plan_json, note)
+ VALUES ('active', ?, ?, ?, ?, ?, ?)
+ `).run(adminUsername, eligibleCount, eligibleHours, updated, planJson, note);
+ const exportId = Number(insEx?.lastInsertRowid || 0);
+ if (exportId > 0 && appliedItems.length > 0) {
+ const insItem = db.prepareCompat(`
+ INSERT INTO four_hour_export_items (export_id, hour_id, delta) VALUES (?, ?, ?)
+ `);
+ for (const it of appliedItems) {
+ insItem.run(exportId, it.hour_id, it.delta);
+ }
}
db.save();
db.execCompat('COMMIT');
- res.json({ ok: true, updated, skipped, total_processed: plan.length });
+ res.json({ ok: true, updated, skipped, total_processed: plan.length, export_id: exportId, total_applied: Number(totalApplied.toFixed(1)) });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
console.error('[4h-export] confirm error', e);
@@ -379,4 +418,194 @@ router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
}
});
+router.get('/four-hour/history', authMiddleware, (_req: AuthRequest, res) => {
+ const db = getDb();
+ const rows = db.prepareCompat(`
+ SELECT id, status, admin_username, eligible_count, eligible_hours_total,
+ items_updated, note, created_at, voided_at
+ FROM four_hour_exports
+ ORDER BY id DESC
+ LIMIT 200
+ `).all() || [];
+ const list = (rows as any[]).map(r => ({
+ id: Number(r.id),
+ status: String(r.status || 'active'),
+ admin_username: r.admin_username || null,
+ eligible_count: Number(r.eligible_count || 0),
+ eligible_hours_total: Number(Number(r.eligible_hours_total || 0).toFixed(1)),
+ items_updated: Number(r.items_updated || 0),
+ note: r.note || null,
+ created_at: r.created_at || null,
+ voided_at: r.voided_at || null,
+ can_void: String(r.status || 'active') === 'active',
+ }));
+ res.json({ ok: true, list });
+});
+
+router.post('/four-hour/void', authMiddleware, (req: AuthRequest, res) => {
+ const exportId = Number((req.body as any)?.export_id || 0);
+ if (!exportId) {
+ res.status(400).json({ error: '缺少 export_id' });
+ return;
+ }
+ const adminUsername = String(req.adminUsername || '').trim() || null;
+ const db = getDb();
+ try {
+ const head = db.prepareCompat(`SELECT * FROM four_hour_exports WHERE id = ?`).get(exportId);
+ if (!head) { res.status(404).json({ error: '核销批次不存在' }); return; }
+ if (String(head.status || '') !== 'active') {
+ res.status(400).json({ error: `该批次状态为 ${String(head.status || '')},不可再次撤回` });
+ return;
+ }
+ const items = db.prepareCompat(`
+ SELECT id, hour_id, delta FROM four_hour_export_items WHERE export_id = ?
+ `).all(exportId) || [];
+ db.execCompat('BEGIN');
+ const subStmt = db.prepareCompat(`
+ UPDATE hours SET exported_hours = MAX(0, COALESCE(exported_hours, 0) - ?)
+ WHERE id = ? AND COALESCE(exported_hours, 0) >= ? - 0.0001
+ `);
+ let rolled = 0;
+ let skipped = 0;
+ let rolledHours = 0;
+ for (const it of items as any[]) {
+ const hid = Number(it.hour_id);
+ const d = Number(it.delta || 0);
+ if (!hid || d <= 0.0001) { skipped++; continue; }
+ const r = subStmt.run(d, hid, d);
+ if (r && r.changes > 0) {
+ rolled++;
+ rolledHours += d;
+ } else {
+ skipped++;
+ }
+ }
+ db.prepareCompat(`
+ UPDATE four_hour_exports SET status = 'voided', voided_at = datetime('now','+8 hours'), note = COALESCE(note,'') || ?
+ WHERE id = ?
+ `).run(
+ `\n[已撤回 ${new Date().toISOString().slice(0,19).replace('T',' ')}${adminUsername ? ' by '+adminUsername : ''}]`,
+ exportId
+ );
+ db.save();
+ db.execCompat('COMMIT');
+ res.json({ ok: true, export_id: exportId, rolled, skipped, rolled_hours: Number(rolledHours.toFixed(1)) });
+ } catch (e: any) {
+ try { db.execCompat('ROLLBACK'); } catch {}
+ console.error('[4h-export] void error', e);
+ res.status(500).json({ error: e?.message || '撤回失败,已回滚' });
+ }
+});
+
+// ===== Backup endpoints =====
+router.get('/backups/runtime', authMiddleware, (_req, res) => {
+ try {
+ res.json({ ok: true, ...getRuntime(), db_path: getDbPath(), backups_dir: getBackupsDir() });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '获取备份运行时失败' });
+ }
+});
+
+router.get('/backups/list', authMiddleware, (_req, res) => {
+ try {
+ res.json({ ok: true, list: listBackups() });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '列出备份失败' });
+ }
+});
+
+router.post('/backups/create', authMiddleware, (_req: AuthRequest, res) => {
+ try {
+ const item = createBackup({ trigger: 'manual' });
+ res.json({ ok: true, item });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '手动备份失败' });
+ }
+});
+
+router.get('/backups/config', authMiddleware, (_req, res) => {
+ try {
+ res.json({ ok: true, config: loadConfig() });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '读取配置失败' });
+ }
+});
+
+router.put('/backups/config', authMiddleware, (req, res) => {
+ try {
+ const body: any = req.body || {};
+ const next = saveConfig({
+ enabled: typeof body.enabled === 'boolean' ? body.enabled : undefined,
+ mode: typeof body.mode === 'string' ? body.mode as any : undefined,
+ daily_time: typeof body.daily_time === 'string' ? body.daily_time : undefined,
+ keep: typeof body.keep === 'number' ? body.keep : undefined,
+ });
+ res.json({ ok: true, config: next, runtime: getRuntime() });
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '保存配置失败' });
+ }
+});
+
+router.post('/backups/delete', authMiddleware, (req, res) => {
+ try {
+ const filename = String((req.body as any)?.filename || '');
+ if (!filename) { res.status(400).json({ error: '缺少 filename' }); return; }
+ const r = deleteBackup(filename);
+ res.json({ ...r, list: listBackups() });
+ } catch (e: any) {
+ res.status(400).json({ error: e?.message || '删除失败' });
+ }
+});
+
+router.get('/backups/download/:filename', (req, res, next) => {
+ const authHeader = req.headers.authorization || '';
+ const authParam = String(req.query.auth || '');
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authParam;
+ if (!token) { res.status(401).json({ error: '未登录' }); return; }
+ // manual jwt check inline (avoid double middleware)
+ try {
+ const jwt = require('jsonwebtoken');
+ const { JWT_SECRET } = require('../config');
+ jwt.verify(token, JWT_SECRET);
+ } catch {
+ res.status(401).json({ error: 'Token 无效' });
+ return;
+ }
+ const filename = String(req.params.filename || '');
+ if (!/^volunteer-(backup|restoresnap)-\d{8}-\d{6}-[A-Za-z0-9]{8}\.db$/.test(filename)) {
+ res.status(400).json({ error: '文件名非法' });
+ return;
+ }
+ const p = path.join(getBackupsDir(), filename);
+ if (!fs.existsSync(p)) { res.status(404).json({ error: '文件不存在' }); return; }
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
+ res.setHeader('Content-Type', 'application/octet-stream');
+ const s = fs.createReadStream(p);
+ s.on('error', (e) => { res.status(500).json({ error: String(e?.message || '读取失败') }); });
+ s.pipe(res);
+ void next;
+});
+
+router.post('/backups/restore', authMiddleware, (req: AuthRequest, res) => {
+ const filename = String((req.body as any)?.filename || '');
+ if (!filename) { res.status(400).json({ error: '缺少 filename' }); return; }
+ const confirm = String((req.body as any)?.confirm || '');
+ if (confirm !== 'I_AGREE_RESTORE_FROM_BACKUP_WILL_RESTART') {
+ res.status(400).json({ error: '缺少二次确认标识' });
+ return;
+ }
+ 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);
+ } catch (e: any) {
+ res.status(500).json({ error: e?.message || '还原失败,未覆盖当前数据库' });
+ }
+});
+
export default router;