diff --git a/client/src/App.tsx b/client/src/App.tsx index da35073..ee46e71 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,6 +8,7 @@ import Hours from './pages/admin/Hours'; import Users from './pages/admin/Users'; import Export from './pages/admin/Export'; import FourHourExport from './pages/admin/FourHourExport'; +import Backups from './pages/admin/Backups'; export default function App() { return ( @@ -23,6 +24,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 925bbba..fbbe181 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import type { WorkGroup, UserItem, UserDetail, ActivityItem, Participant, HoursActivityResult, ExportResult, FourHourPreviewResult, FourHourPlanItem, + FourHourExportHistoryItem, BackupConfig, BackupRuntime, BackupItem, } from '../types'; const api = axios.create({ @@ -102,9 +103,37 @@ export const exportApi = { }) => api.post('/export', opts).then(r => r.data), fourHourPreview: () => api.post('/export/four-hour/preview').then(r => r.data), - fourHourConfirm: (plan: FourHourPlanItem[]) => - api.post<{ ok: boolean; updated: number; skipped: number; total_processed: number }>( + fourHourConfirm: (plan: FourHourPlanItem[], extra?: { eligible_count?: number; eligible_hours_total?: number; note?: string }) => + api.post<{ ok: boolean; updated: number; skipped: number; total_processed: number; export_id?: number; total_applied?: number }>( '/export/four-hour/confirm', - { plan } + { plan, ...(extra || {}) } + ).then(r => r.data), + fourHourHistory: () => + api.get<{ ok: boolean; list: FourHourExportHistoryItem[] }>('/export/four-hour/history').then(r => r.data), + fourHourVoid: (export_id: number) => + api.post<{ ok: boolean; export_id: number; rolled: number; skipped: number; rolled_hours: number }>( + '/export/four-hour/void', + { export_id } + ).then(r => r.data), + backupRuntime: () => + api.get('/export/backups/runtime').then(r => r.data), + backupList: () => + api.get<{ ok: boolean; list: BackupItem[] }>('/export/backups/list').then(r => r.data), + backupCreate: () => + api.post<{ ok: boolean; item: BackupItem }>('/export/backups/create').then(r => r.data), + backupConfig: () => + api.get<{ ok: boolean; config: BackupConfig }>('/export/backups/config').then(r => r.data), + backupSaveConfig: (cfg: Partial) => + api.put<{ ok: boolean; config: BackupConfig; runtime?: BackupRuntime }>('/export/backups/config', cfg).then(r => r.data), + backupDelete: (filename: string) => + api.post<{ ok: boolean; deleted: boolean; list: BackupItem[] }>('/export/backups/delete', { filename }).then(r => r.data), + backupDownloadUrl: (filename: string) => { + const token = localStorage.getItem('volunteer_admin_token') || ''; + return `/api/export/backups/download/${encodeURIComponent(filename)}?auth=${encodeURIComponent(token)}`; + }, + backupRestore: (filename: string) => + api.post<{ ok: boolean; snapshot: BackupItem; will_restart: boolean }>( + '/export/backups/restore', + { filename, confirm: 'I_AGREE_RESTORE_FROM_BACKUP_WILL_RESTART' } ).then(r => r.data), }; diff --git a/client/src/components/Icons.tsx b/client/src/components/Icons.tsx index af84a0d..0ba7f1a 100644 --- a/client/src/components/Icons.tsx +++ b/client/src/components/Icons.tsx @@ -143,3 +143,57 @@ export const IconCheck: React.FC = ({ size, ...rest }) => ( ); + +export const IconUndo: React.FC = ({ size, ...rest }) => ( + + + + +); + +export const IconHistory: React.FC = ({ size, ...rest }) => ( + + + + + +); + +export const IconBackup: React.FC = ({ size, ...rest }) => ( + + + + + + + +); + +export const IconDownload: React.FC = ({ size, ...rest }) => ( + + + + +); + +export const IconRestore: React.FC = ({ size, ...rest }) => ( + + + + + +); + +export const IconPlay: React.FC = ({ size, ...rest }) => ( + + + +); + +export const IconSave: React.FC = ({ size, ...rest }) => ( + + + + + +); diff --git a/client/src/pages/admin/Backups.tsx b/client/src/pages/admin/Backups.tsx new file mode 100644 index 0000000..92f8607 --- /dev/null +++ b/client/src/pages/admin/Backups.tsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import Button from '../../components/Button'; +import Input from '../../components/Input'; +import { exportApi, errMsg } from '../../api'; +import type { BackupConfig, BackupItem, BackupRuntime } from '../../types'; +import { + IconBackup, + IconCheck, + IconDownload, + IconRestore, + IconTrash, + IconPlay, + IconSave, +} from '../../components/Icons'; + +function fmtSize(bytes: number): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${Number(v).toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +const MODE_OPTIONS: Array<{ value: BackupConfig['mode']; label: string; hint: string }> = [ + { value: 'manual', label: '仅手动(不自动)', hint: '关闭定时调度,仅通过页面上「立即备份」按钮触发。' }, + { value: 'hourly', label: '每小时整点', hint: '每小时 00 分 01 秒触发。' }, + { value: 'every6h', label: '每 6 小时', hint: '00:00 / 06:00 / 12:00 / 18:00 触发。' }, + { value: 'every12h', label: '每 12 小时', hint: '00:00 / 12:00 触发。' }, + { value: 'daily', label: '每日指定时间(推荐)', hint: '每天在下方指定的 UTC+8 时间触发。' }, +]; + +export default function Backups() { + const [rt, setRt] = useState(null); + const [list, setList] = useState([]); + const [busy, setBusy] = useState(false); + const [creating, setCreating] = useState(false); + const [cfgForm, setCfgForm] = useState(null); + const [saveCfgBusy, setSaveCfgBusy] = useState(false); + const [restarting, setRestarting] = useState(false); + const [notice, setNotice] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const refreshTimer = useRef(null); + + const loadAll = (showNotice = false) => { + setBusy(true); + (async () => { + try { + const [rtR, listR] = await Promise.all([exportApi.backupRuntime(), exportApi.backupList()]); + setRt(rtR); + setCfgForm({ ...rtR.config }); + setList(Array.isArray(listR?.list) ? listR.list : []); + if (showNotice) setNotice(new Date().toLocaleString('zh-CN') + ' 已刷新'); + setTimeout(() => setNotice(null), 2200); + } catch (e) { + setErrorMsg(errMsg(e)); + } finally { + setBusy(false); + } + })(); + }; + + useEffect(() => { + loadAll(); + refreshTimer.current = window.setInterval(() => loadAll(false), 30 * 1000); + return () => { + if (refreshTimer.current) window.clearInterval(refreshTimer.current); + }; + }, []); + + const doCreate = () => { + setCreating(true); + (async () => { + try { + const r = await exportApi.backupCreate(); + if (r?.ok) { + setNotice(`已创建手动备份:${r.item.filename}(${fmtSize(r.item.size_bytes)})`); + loadAll(false); + } + } catch (e) { + setErrorMsg(errMsg(e)); + } finally { + setCreating(false); + } + })(); + }; + + const doSaveCfg = () => { + if (!cfgForm) return; + setSaveCfgBusy(true); + (async () => { + try { + const r = await exportApi.backupSaveConfig(cfgForm); + if (r?.ok) { + setCfgForm({ ...r.config }); + if (r.runtime) setRt({ ...r.runtime, config: r.config }); + setNotice('调度配置已保存,下次运行时生效'); + setTimeout(() => setNotice(null), 2400); + } + } catch (e) { + setErrorMsg(errMsg(e)); + } finally { + setSaveCfgBusy(false); + } + })(); + }; + + const doDelete = (it: BackupItem) => { + const need = it.kind === 'restoresnap' + ? `删除还原前自动快照 ${it.filename}` + : `删除备份 ${it.filename}`; + const input = window.prompt( + `即将${need}\n文件大小:${fmtSize(it.size_bytes)}\n创建时间:${it.created_at}\n\n删除不可恢复,请谨慎操作。\n\n请在输入框内输入:"我确认删除 ${it.filename}"`, + '' + ); + if (input === null) return; + if (input.trim() !== `我确认删除 ${it.filename}`) { + alert('确认文本不匹配,已取消'); + return; + } + (async () => { + try { + const r = await exportApi.backupDelete(it.filename); + setList(Array.isArray(r?.list) ? r.list : []); + setNotice(it.kind === 'restoresnap' ? '已删除快照' : '已删除备份'); + setTimeout(() => setNotice(null), 1800); + } catch (e) { + setErrorMsg(errMsg(e)); + } + })(); + }; + + const doRestore = (it: BackupItem) => { + if (it.kind !== 'backup') { + alert('仅可从普通备份(restoresnap 为还原前的应急快照,不建议作为还原源)作为还原源。请在列表中选择 kind=backup 的文件。'); + return; + } + const m1 = + `即将从备份还原数据库:\n\n` + + ` · 备份文件:${it.filename}\n` + + ` · 备份时间:${it.created_at}\n` + + ` · 文件大小:${fmtSize(it.size_bytes)}\n\n` + + `还原后将覆盖当前所有数据(部员/活动/时长记录/四小时核销记录等),\n` + + `系统会先对当前数据库自动拍一张「还原前快照(restoresnap)」作为应急回滚点,\n` + + `然后覆盖数据库并自动重启后端进程(Docker/守护进程会拉起来)。\n\n` + + `请在下方输入框输入:"我确认从备份 ${it.filename} 还原并重启服务"`; + const i1 = window.prompt(m1, ''); + if (i1 === null) return; + const expected = `我确认从备份 ${it.filename} 还原并重启服务`; + if (i1.trim() !== expected) { alert('确认文本不匹配,已取消'); return; } + const i2 = window.prompt( + `最后一道确认:请再次输入 "YES, RESTORE NOW"(全大写,不含引号)以继续。`, + '' + ); + if (i2 === null) return; + if (i2.trim() !== 'YES, RESTORE NOW') { alert('确认文本不匹配,已取消'); return; } + (async () => { + try { + setRestarting(true); + const r = await exportApi.backupRestore(it.filename); + if (r?.ok) { + alert( + `还原已受理,后端将在 1 秒内重启。\n\n` + + `还原前自动快照:${r.snapshot.filename}\n` + + `快照大小:${fmtSize(r.snapshot.size_bytes)}\n` + + `快照时间:${r.snapshot.created_at}\n\n` + + `若重启后出现异常,请使用该快照文件再次还原或联系管理员。\n\n` + + `将在 5 秒后自动刷新当前页面。` + ); + setTimeout(() => window.location.reload(), 5000); + } + } catch (e) { + setRestarting(false); + setErrorMsg(errMsg(e)); + } + })(); + }; + + const cfg = cfgForm || rt?.config; + const stats = useMemo(() => { + const backups = list.filter(i => i.kind === 'backup'); + const snaps = list.filter(i => i.kind === 'restoresnap'); + const totalBytes = list.reduce((s, i) => s + Number(i.size_bytes || 0), 0); + const newest = list[0] || null; + return { backups, snaps, totalBytes, newest }; + }, [list]); + + return ( +
+
+
+
+ +
+
+

数据备份 / 还原

+

+ 管理 SQLite 数据库文件(volunteer.db)的备份、调度、下载与还原。 + 还原属于高风险操作:必须双重文本确认;还原前会先自动拍一张「restoresnap」快照用于应急回滚。 +

+
+
+
+ + {(notice || errorMsg) ? ( +
+
+
{errorMsg ? '⚠' : '✓'}
+
{errorMsg || notice}
+ +
+
+ ) : null} + + {restarting ? ( +
+
后端正在重启以完成还原…请稍候,页面将自动刷新
+
若 30 秒后仍无法访问,请手动刷新页面或检查容器/守护进程。
+
+ ) : null} + +
+
+
+
+

+ + 备份调度配置 +

+

所有时间均按服务器 UTC+8(Asia/Shanghai)计算,与系统其他时区策略保持一致。

+
+
+ + + +
+
+ + {cfg ? ( +
+
+ +
+ {MODE_OPTIONS.map(m => { + const active = cfg.mode === m.value; + return ( + + ); + })} +
+
+ +
+ + setCfgForm(c => c ? { ...c, daily_time: e.target.value } : c)} + disabled={cfg.mode === 'manual'} + /> +
+ 仅「每日指定时间」模式生效;其余模式按自身规则自动运行。 +
+
+
+ + { + const n = Math.min(365, Math.max(1, Math.floor(Number(e.target.value) || 1))); + setCfgForm(c => c ? { ...c, keep: n } : c); + }} + /> +
+ 超过保留份数的旧备份(含快照一起计入)会在每次备份成功后自动删除最老的文件。 +
+
+
+ +
+
+ ) : null} + +
+
+
最近一次备份
+
+ {rt?.last_run ? String(rt.last_run).slice(0, 19) : '暂无'} +
+
+
+
下次计划运行
+
+ {rt?.next_run ? String(rt.next_run).slice(0, 19) : (cfg?.mode === 'manual' || !cfg?.enabled ? '(调度已禁用)' : '—')} +
+
+
+
数据库 / 备份目录
+
+ {rt?.db_path || '—'} +
{rt?.backups_dir || ''}
+
+
+
+
+ +
+

存储空间概览

+
+
+
+
备份文件
+
{stats.backups.length}
+
+ +
+
+
+
还原前自动快照
+
{stats.snaps.length}
+
+ +
+
+
+
占用总空间
+
{fmtSize(stats.totalBytes)}
+
+
+ 保留上限 {cfg?.keep ?? 30} 份 +
+
+ {stats.newest ? ( +
+
最新文件
+
{stats.newest.filename}
+
+ {stats.newest.created_at?.slice(0, 19)} + {fmtSize(stats.newest.size_bytes)} +
+
+ ) : null} +
+
+
+ +
+
+
+

备份文件列表

+

共 {list.length} 个文件,每 30 秒自动刷新。下载链接通过 ?auth= 参数携带管理令牌。

+
+
+
+ backup = 手动/定时备份 +
+
+ restoresnap = 还原前自动快照 +
+
+
+ {list.length === 0 ? ( +
+ 暂无备份,请点击上方「立即备份」创建第一份 +
+ ) : ( +
+ + + + {['类型', '文件名', '创建时间', '大小', '操作'].map(h => ( + + ))} + + + + {list.map((it, i) => ( + + + + + + + + ))} + +
{h}
+ {it.kind === 'backup' ? ( + + 备份 + + ) : ( + + 快照 + + )} + + {it.filename} + {String(it.created_at || '').slice(0, 19)}{fmtSize(it.size_bytes)} +
+ + 下载 + + + +
+
+
+ )} +
+
+ ); +} diff --git a/client/src/pages/admin/FourHourExport.tsx b/client/src/pages/admin/FourHourExport.tsx index 96f4c53..bbec85c 100644 --- a/client/src/pages/admin/FourHourExport.tsx +++ b/client/src/pages/admin/FourHourExport.tsx @@ -1,8 +1,8 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button'; import { exportApi, errMsg } from '../../api'; import { exportToExcel, exportTableToImage, utc8DateStamp, utc8DateTimeStamp } from '../../utils/export'; -import type { FourHourPreviewResult } from '../../types'; +import type { FourHourExportHistoryItem, FourHourPreviewResult } from '../../types'; import { IconFourHour, IconFileExcel, @@ -11,6 +11,8 @@ import { IconCheck, IconUsers, IconClock, + IconUndo, + IconHistory, } from '../../components/Icons'; function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) { @@ -69,8 +71,29 @@ export default function FourHourExport() { const [confirming, setConfirming] = useState(false); const [preview, setPreview] = useState(null); const [confirmedMsg, setConfirmedMsg] = useState(null); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [voidingId, setVoidingId] = useState(null); const previewRef = useRef(null); + const loadHistory = () => { + setHistoryLoading(true); + (async () => { + try { + const r = await exportApi.fourHourHistory(); + setHistory(Array.isArray(r?.list) ? r.list : []); + } catch (e) { + console.error(e); + } finally { + setHistoryLoading(false); + } + })(); + }; + + useEffect(() => { + loadHistory(); + }, []); + const loadPreview = () => { setLoading(true); setConfirmedMsg(null); @@ -101,16 +124,22 @@ export default function FourHourExport() { setConfirming(true); (async () => { try { - const r = await exportApi.fourHourConfirm(preview.confirm_plan); + const r = await exportApi.fourHourConfirm(preview.confirm_plan, { + eligible_count: preview.summary.eligible_count, + eligible_hours_total: preview.summary.eligible_hours_total, + }); if (r?.ok) { const msg2 = `确认成功!\n\n` + + `批次编号:#${r.export_id || '—'}\n` + `计划处理明细条数:${r.total_processed}\n` + `实际更新成功:${r.updated}\n` + - `跳过(并发冲突/已结算):${r.skipped}\n\n` + + `跳过(并发冲突/已结算):${r.skipped}\n` + + `本次实际核销合计:${Number(r.total_applied || 0).toFixed(1)} h\n\n` + `请点击下方「重新生成预览」以查看核销后的最新数据。`; setConfirmedMsg(msg2); alert(msg2); + loadHistory(); } else { alert('确认失败:服务器未返回成功状态'); } @@ -122,6 +151,44 @@ export default function FourHourExport() { })(); }; + const doVoid = (it: FourHourExportHistoryItem) => { + const prefix = + `即将撤回批次 #${it.id}(${String(it.created_at || '').slice(0, 19)})\n` + + `该批次:${it.eligible_count} 人 / ${Number(it.eligible_hours_total || 0).toFixed(1)} h / ${it.items_updated} 条明细\n` + + `操作人:${it.admin_username || '—'}\n\n` + + `撤回后将:\n` + + ` 1. 把该批次核销掉的小时数逐条加回「未核销余额」;\n` + + ` 2. 标记该批次状态为 voided(不可再次撤回)。\n\n` + + `请在输入框内输入 "撤回批次 #${it.id}"(不含引号)以继续。`; + const input = window.prompt(prefix, ''); + if (input === null) return; + if (input.trim() !== `撤回批次 #${it.id}`) { + alert('确认文本不匹配,已取消'); + return; + } + setVoidingId(it.id); + (async () => { + try { + const r = await exportApi.fourHourVoid(it.id); + if (r?.ok) { + alert( + `撤回成功!\n\n` + + `批次:#${r.export_id}\n` + + `成功回退明细:${r.rolled}\n` + + `跳过:${r.skipped}\n` + + `累计回退小时数:${Number(r.rolled_hours || 0).toFixed(1)} h` + ); + loadHistory(); + if (preview) loadPreview(); + } + } catch (e) { + alert(errMsg(e)); + } finally { + setVoidingId(null); + } + })(); + }; + const eligibleCount = preview?.summary?.eligible_count || 0; const eligibleHours = Number(preview?.summary?.eligible_hours_total || 0).toFixed(1); @@ -290,6 +357,93 @@ export default function FourHourExport() { ) : null} +
+
+
+
+ +
+
+

核销批次历史

+

+ 最近 {Math.min(200, history.length || 0)} 条 · 状态为「active」的批次可一键撤回(把小时数加回未核销余额) +

+
+
+ +
+ {history.length === 0 ? ( +
+ 暂无核销批次记录 +
+ ) : ( +
+ + + + {['批次', '时间', '状态', '操作人', '达标部员', '核销合计', '明细条数', '撤回时间', '操作'].map((h) => ( + + ))} + + + + {history.map((it, i) => { + const isVoided = it.status === 'voided'; + return ( + + + + + + + + + + + + ); + })} + +
{h}
#{it.id}{String(it.created_at || '').slice(0, 19)} + {isVoided ? ( + + 已撤回 + + ) : ( + + active + + )} + {it.admin_username || '—'}{it.eligible_count} + {Number(it.eligible_hours_total || 0).toFixed(1)} h + {it.items_updated} + {isVoided ? String(it.voided_at || '').slice(0, 19) : '—'} + + +
+
+ )} +
+ {preview ? (
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;