feat(api): ежедневный бэкап, копия в личку, восстановление через бота

backup.ts: db.backup() → gzip в backups/ рядом с базой, имя по времени
Минска (Intl), 14 ежедневных и pre-restore на 30 дней; планировщик
в 03:00 по Минску и при старте, если сегодня копии нет; файл админу,
итог в «Систему». Восстановление: gunzip → integrity_check → версия
схемы → пауза приёма (503) → pre-restore → атомарная подмена файла →
миграции; при сбое возвращаем прежнюю базу. createApp и очередь берут
базу через геттер, чтобы увидеть подменённую.
Бот в личке: /backups с кнопками, /backup, присланный .sqlite.gz,
подтверждение со списком пропадающих заявок, живёт 10 минут.
This commit is contained in:
2026-10-17 16:07:26 +03:00
parent 667c6a6e6d
commit 249b4bb627
11 changed files with 1103 additions and 15 deletions
+3 -2
View File
@@ -8,7 +8,8 @@ import { notifyLater, type Notifier } from './notifier.js';
import { clientIp, LOOPBACK, type RateLimiter } from './rateLimit.js';
export type AppDeps = {
db: Db;
/** Геттер, а не объект: восстановление из бэкапа подменяет базу на лету. */
getDb: () => Db;
now: () => Date;
rateLimiter: RateLimiter;
notifier: Notifier;
@@ -79,7 +80,7 @@ export function createApp(deps: AppDeps) {
if (result.data.honeypot) return reply({ ok: true, duplicate: false }, 201);
const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null;
const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent });
const saved = saveLead(deps.getDb(), result.data, { now: deps.now(), userAgent });
if (saved.kind === 'unchanged') return reply({ ok: true, duplicate: true, clarified: false }, 200);
notifyLater(deps.notifier, saved.event);
if (saved.kind === 'clarified') return reply({ ok: true, duplicate: true, clarified: true }, 200);