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:
+3
-2
@@ -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);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { validateLead } from '@kvarta/shared/lead';
|
||||
import {
|
||||
backupDirFor,
|
||||
backupFileName,
|
||||
createBackup,
|
||||
discardInspected,
|
||||
hasDailyBackupToday,
|
||||
inspectBackup,
|
||||
leadsMissingFrom,
|
||||
listBackups,
|
||||
nextDailyRun,
|
||||
pluralLeads,
|
||||
restoreFromInspected,
|
||||
RestoreRejected,
|
||||
rotateBackups,
|
||||
runBackupAndReport,
|
||||
} from './backup.js';
|
||||
import { openDb, type DbHolder } from './db/index.js';
|
||||
import { MIGRATIONS } from './db/migrations.js';
|
||||
import { saveLead } from './leads.js';
|
||||
import { validBody } from './testing.js';
|
||||
import { fakeTelegram } from './telegram/fake.js';
|
||||
import type { SystemReporter } from './telegram/system.js';
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tempDb() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kvarta-backup-'));
|
||||
dirs.push(root);
|
||||
const dbPath = join(root, 'kvarta.sqlite');
|
||||
const holder: DbHolder = { db: openDb(dbPath), paused: false };
|
||||
return { root, dbPath, holder, dir: backupDirFor(dbPath) };
|
||||
}
|
||||
|
||||
let phoneSeq = 1000000;
|
||||
function addLead(holder: DbHolder, at: Date) {
|
||||
const r = validateLead(validBody({ phone: `+37529${phoneSeq++}` }));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
return saveLead(holder.db, r.data, { now: at, userAgent: null });
|
||||
}
|
||||
|
||||
const leadIds = (holder: DbHolder) => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
|
||||
|
||||
// 18.10.2026 03:00 по Минску = 00:00 UTC
|
||||
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
|
||||
|
||||
describe('расписание и имена', () => {
|
||||
it('следующий запуск — 03:00 по Минску', () => {
|
||||
expect(nextDailyRun(new Date('2026-10-17T12:00:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z');
|
||||
// 02:59 по Минску — ещё сегодня
|
||||
expect(nextDailyRun(new Date('2026-10-17T23:59:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z');
|
||||
// ровно 03:00 — уже завтра
|
||||
expect(nextDailyRun(new Date('2026-10-18T00:00:00Z')).toISOString()).toBe('2026-10-19T00:00:00.000Z');
|
||||
// 01:00 по Минску, но по UTC ещё вчера
|
||||
expect(nextDailyRun(new Date('2026-10-17T22:00:00Z')).toISOString()).toBe('2026-10-18T00:00:00.000Z');
|
||||
// через границу года
|
||||
expect(nextDailyRun(new Date('2026-12-31T21:30:00Z')).toISOString()).toBe('2027-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('имя файла во времени Минска', () => {
|
||||
expect(backupFileName(NIGHT, 'daily')).toBe('kvarta-2026-10-18T0300.sqlite.gz');
|
||||
expect(backupFileName(NIGHT, 'pre-restore')).toBe('pre-restore-2026-10-18T0300.sqlite.gz');
|
||||
});
|
||||
|
||||
it('склонение', () => {
|
||||
expect([0, 1, 2, 5, 11, 12, 21, 24, 111].map(pluralLeads)).toEqual([
|
||||
'0 заявок', '1 заявка', '2 заявки', '5 заявок', '11 заявок', '12 заявок', '21 заявка', '24 заявки', '111 заявок',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBackup и ротация', () => {
|
||||
it('gzip-копия с данными, без временных файлов', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder, NIGHT);
|
||||
const result = await createBackup(holder.db, dir, NIGHT);
|
||||
|
||||
expect(result).toMatchObject({ file: 'kvarta-2026-10-18T0300.sqlite.gz', kind: 'daily', leads: 2 });
|
||||
expect(readdirSync(dir)).toEqual([result.file]);
|
||||
const inspected = await inspectBackup(join(dir, result.file), dir);
|
||||
expect(inspected.leads.map((l) => l.id)).toEqual([1, 2]);
|
||||
expect(inspected.userVersion).toBe(MIGRATIONS.length);
|
||||
discardInspected(inspected);
|
||||
expect(readdirSync(dir)).toEqual([result.file]);
|
||||
expect(hasDailyBackupToday(dir, new Date('2026-10-18T20:00:00Z'))).toBe(true);
|
||||
// 19.10 00:30 по Минску — уже другой день
|
||||
expect(hasDailyBackupToday(dir, new Date('2026-10-18T21:30:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('14 ежедневных, pre-restore — 30 дней, чужие файлы не трогаем', () => {
|
||||
const { dir } = tempDb();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
for (let i = 0; i < 20; i++) writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - i * day), 'daily')), 'x');
|
||||
writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - 29 * day), 'pre-restore')), 'x');
|
||||
writeFileSync(join(dir, backupFileName(new Date(NIGHT.getTime() - 31 * day), 'pre-restore')), 'x');
|
||||
writeFileSync(join(dir, 'notes.txt'), 'x');
|
||||
|
||||
const removed = rotateBackups(dir, NIGHT);
|
||||
expect(removed).toHaveLength(7);
|
||||
const left = listBackups(dir);
|
||||
expect(left.filter((b) => b.kind === 'daily')).toHaveLength(14);
|
||||
expect(left.filter((b) => b.kind === 'daily')[0]!.file).toBe('kvarta-2026-10-18T0300.sqlite.gz');
|
||||
expect(left.filter((b) => b.kind === 'pre-restore').map((b) => b.file)).toEqual(['pre-restore-2026-09-19T0300.sqlite.gz']);
|
||||
expect(existsSync(join(dir, 'notes.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('runBackupAndReport: файл админу, отчёт в «Систему»', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
const tg = fakeTelegram();
|
||||
const reports: string[] = [];
|
||||
const system: SystemReporter = { report: async (_k, t) => void reports.push(t) };
|
||||
await runBackupAndReport({ holder, dir, now: NIGHT, client: tg.client, adminId: 42, system });
|
||||
|
||||
const doc = tg.sent('sendDocument')[0];
|
||||
expect(doc).toMatchObject({ chat_id: 42, file: { name: 'kvarta-2026-10-18T0300.sqlite.gz' } });
|
||||
expect(reports).toEqual([expect.stringMatching(/^Бэкап 18\.10 03:00: 1 заявка, \d+ КБ ✅$/)]);
|
||||
});
|
||||
|
||||
it('runBackupAndReport: ошибка — в «Систему», не бросает', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
holder.db.close();
|
||||
const reports: string[] = [];
|
||||
const system: SystemReporter = { report: async (_k, t) => void reports.push(t) };
|
||||
await expect(runBackupAndReport({ holder, dir, now: NIGHT, system })).resolves.toBeUndefined();
|
||||
expect(reports[0]).toMatch(/^Бэкап 18\.10 03:00 не удался ❌/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('восстановление', () => {
|
||||
it('подменяет данные, создаёт pre-restore, пауза на время подмены', async () => {
|
||||
const { holder, dbPath, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
const backup = await createBackup(holder.db, dir, NIGHT);
|
||||
const later = new Date(NIGHT.getTime() + 60 * 60 * 1000);
|
||||
addLead(holder, later);
|
||||
addLead(holder, later);
|
||||
|
||||
const inspected = await inspectBackup(join(dir, backup.file), dir);
|
||||
expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([2, 3]);
|
||||
|
||||
const oldDb = holder.db;
|
||||
let pausedDuringIdle: boolean | undefined;
|
||||
const result = await restoreFromInspected({
|
||||
holder,
|
||||
dbPath,
|
||||
inspected,
|
||||
now: later,
|
||||
waitIdle: async () => void (pausedDuringIdle = holder.paused),
|
||||
});
|
||||
|
||||
expect(pausedDuringIdle).toBe(true);
|
||||
expect(holder.paused).toBe(false);
|
||||
expect(holder.db).not.toBe(oldDb);
|
||||
expect(oldDb.open).toBe(false);
|
||||
expect(leadIds(holder)).toEqual([1]);
|
||||
expect(result.leads).toBe(1);
|
||||
expect(result.preRestore.file).toBe('pre-restore-2026-10-18T0400.sqlite.gz');
|
||||
const pre = await inspectBackup(join(dir, result.preRestore.file), dir);
|
||||
expect(pre.leads.map((l) => l.id)).toEqual([1, 2, 3]);
|
||||
// Временные файлы убраны, рядом с базой только копии.
|
||||
discardInspected(pre);
|
||||
expect(readdirSync(dir).sort()).toEqual([backup.file, result.preRestore.file].sort());
|
||||
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
|
||||
// Новая база рабочая: пишем дальше.
|
||||
addLead(holder, later);
|
||||
expect(leadIds(holder)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('id после отката переиспользуются — пропавшие считаем по id и created_at', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
|
||||
// Заявку #1 пересоздали в другое время (как после прошлого восстановления).
|
||||
holder.db.prepare('UPDATE leads SET created_at = ? WHERE id = 1').run('2026-10-19T00:00:00.000Z');
|
||||
expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('битый файл отклоняется, база не тронута', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
await expect(inspectBackup(new Uint8Array([1, 2, 3]), dir)).rejects.toBeInstanceOf(RestoreRejected);
|
||||
await expect(inspectBackup(gzipSync(Buffer.from('not sqlite at all')), dir)).rejects.toThrow(/не база SQLite/);
|
||||
expect(leadIds(holder)).toEqual([1]);
|
||||
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('копия с более новой схемой отклоняется', async () => {
|
||||
const { root, dir } = tempDb();
|
||||
const futurePath = join(root, 'future.sqlite');
|
||||
const future = new Database(futurePath);
|
||||
future.exec(MIGRATIONS[0]!);
|
||||
future.pragma(`user_version = ${MIGRATIONS.length + 1}`);
|
||||
future.close();
|
||||
await expect(inspectBackup(gzipSync(readFileSync(futurePath)), dir)).rejects.toThrow(/более новой версии/);
|
||||
});
|
||||
|
||||
it('сбой после снимка — возвращаем старую базу и снимаем паузу', async () => {
|
||||
const { holder, dbPath, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder, NIGHT);
|
||||
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
|
||||
// Портим распакованную копию уже после проверки: openDb на ней упадёт.
|
||||
writeFileSync(inspected.path, 'garbage');
|
||||
|
||||
await expect(restoreFromInspected({ holder, dbPath, inspected, now: NIGHT })).rejects.toThrow();
|
||||
expect(holder.paused).toBe(false);
|
||||
expect(holder.db.open).toBe(true);
|
||||
expect(leadIds(holder)).toEqual([1, 2]);
|
||||
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
// Бэкапы базы и восстановление из копии. Копии лежат рядом с базой (/data/backups),
|
||||
// поэтому временные файлы на той же файловой системе и rename при подмене атомарен.
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createGunzip, createGzip } from 'node:zlib';
|
||||
import Database from 'better-sqlite3';
|
||||
import { openDb, type Db, type DbHolder } from './db/index.js';
|
||||
import { MIGRATIONS } from './db/migrations.js';
|
||||
import type { TelegramClient } from './telegram/client.js';
|
||||
import { shortError, type SystemReporter } from './telegram/system.js';
|
||||
|
||||
export const TIME_ZONE = 'Europe/Minsk';
|
||||
export const KEEP_DAILY = 14;
|
||||
export const KEEP_PRE_RESTORE_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type BackupKind = 'daily' | 'pre-restore';
|
||||
export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number };
|
||||
|
||||
const NAME_RE = /^(kvarta|pre-restore)-(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})\.sqlite\.gz$/;
|
||||
|
||||
export function backupDirFor(dbPath: string): string {
|
||||
return join(dirname(dbPath), 'backups');
|
||||
}
|
||||
|
||||
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number };
|
||||
|
||||
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
|
||||
export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)!.value);
|
||||
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** «18.10 03:00» — для кнопок и отчётов. */
|
||||
export function shortLabel(date: Date): string {
|
||||
const p = localParts(date);
|
||||
return `${pad(p.day)}.${pad(p.month)} ${pad(p.hour)}:${pad(p.minute)}`;
|
||||
}
|
||||
|
||||
export function backupFileName(date: Date, kind: BackupKind): string {
|
||||
const p = localParts(date);
|
||||
const prefix = kind === 'daily' ? 'kvarta' : 'pre-restore';
|
||||
return `${prefix}-${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}${pad(p.minute)}.sqlite.gz`;
|
||||
}
|
||||
|
||||
/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */
|
||||
function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date {
|
||||
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
||||
const shown = localParts(new Date(guess), timeZone);
|
||||
const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess;
|
||||
return new Date(guess - offset);
|
||||
}
|
||||
|
||||
function parseName(file: string): { kind: BackupKind; at: Date } | null {
|
||||
const m = NAME_RE.exec(file);
|
||||
if (!m) return null;
|
||||
const [, prefix, y, mo, d, h, mi] = m;
|
||||
return {
|
||||
kind: prefix === 'kvarta' ? 'daily' : 'pre-restore',
|
||||
at: fromLocal({ year: +y!, month: +mo!, day: +d!, hour: +h!, minute: +mi! }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */
|
||||
export function nextDailyRun(now: Date, hour = 3): Date {
|
||||
const today = localParts(now);
|
||||
let run = fromLocal({ ...today, hour, minute: 0 });
|
||||
if (run.getTime() <= now.getTime()) {
|
||||
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
|
||||
run = fromLocal({ ...tomorrow, hour, minute: 0 });
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
export function listBackups(dir: string): BackupFile[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const out: BackupFile[] = [];
|
||||
for (const file of readdirSync(dir)) {
|
||||
const parsed = parseName(file);
|
||||
if (parsed) out.push({ file, ...parsed, bytes: statSync(join(dir, file)).size });
|
||||
}
|
||||
return out.sort((a, b) => b.at.getTime() - a.at.getTime() || b.file.localeCompare(a.file));
|
||||
}
|
||||
|
||||
export function hasDailyBackupToday(dir: string, now: Date): boolean {
|
||||
const today = localParts(now);
|
||||
return listBackups(dir).some((b) => {
|
||||
const p = localParts(b.at);
|
||||
return b.kind === 'daily' && p.year === today.year && p.month === today.month && p.day === today.day;
|
||||
});
|
||||
}
|
||||
|
||||
/** 14 последних ежедневных; pre-restore — 30 дней. Возвращает удалённые имена. */
|
||||
export function rotateBackups(dir: string, now: Date): string[] {
|
||||
const removed: string[] = [];
|
||||
const all = listBackups(dir);
|
||||
for (const b of all.filter((b) => b.kind === 'daily').slice(KEEP_DAILY)) removed.push(b.file);
|
||||
for (const b of all.filter((b) => b.kind === 'pre-restore')) {
|
||||
if (now.getTime() - b.at.getTime() > KEEP_PRE_RESTORE_DAYS * DAY_MS) removed.push(b.file);
|
||||
}
|
||||
for (const file of removed) rmSync(join(dir, file), { force: true });
|
||||
return removed;
|
||||
}
|
||||
|
||||
function countLeads(db: Db): number {
|
||||
return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
export type BackupResult = BackupFile & { leads: number };
|
||||
|
||||
/** Снимок через db.backup() (консистентен при работающей записи) → gzip → атомарный rename. */
|
||||
export async function createBackup(db: Db, dir: string, now: Date, kind: BackupKind = 'daily'): Promise<BackupResult> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = backupFileName(now, kind);
|
||||
const raw = join(dir, `.tmp-${process.pid}-${Date.now()}.sqlite`);
|
||||
const gz = join(dir, `.tmp-${file}`);
|
||||
try {
|
||||
await db.backup(raw);
|
||||
// Копия наследует WAL из заголовка: переводим в DELETE, иначе рядом остаются -wal/-shm,
|
||||
// а в архив попадает база, которой нужны файлы-спутники.
|
||||
const snapshot = new Database(raw);
|
||||
snapshot.pragma('journal_mode = DELETE');
|
||||
const leads = countLeads(snapshot);
|
||||
snapshot.close();
|
||||
await pipeline(createReadStream(raw), createGzip(), createWriteStream(gz));
|
||||
renameSync(gz, join(dir, file));
|
||||
return { file, kind, at: now, bytes: statSync(join(dir, file)).size, leads };
|
||||
} finally {
|
||||
rmSync(raw, { force: true });
|
||||
removeSidecars(raw);
|
||||
rmSync(gz, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export class RestoreRejected extends Error {}
|
||||
|
||||
export type InspectedBackup = {
|
||||
/** Распакованная копия во временном файле; удаляет вызывающий (discard) */
|
||||
path: string;
|
||||
userVersion: number;
|
||||
leads: { id: number; created_at: string }[];
|
||||
};
|
||||
|
||||
/** gunzip → integrity_check → версия схемы. Кидает RestoreRejected с понятным текстом. */
|
||||
export async function inspectBackup(source: string | Uint8Array, dir: string): Promise<InspectedBackup> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const path = join(dir, `.restore-${stamp}.sqlite`);
|
||||
const gzPath = typeof source === 'string' ? source : join(dir, `.upload-${stamp}.sqlite.gz`);
|
||||
try {
|
||||
if (typeof source !== 'string') await writeFile(gzPath, source);
|
||||
try {
|
||||
await pipeline(createReadStream(gzPath), createGunzip(), createWriteStream(path));
|
||||
} catch {
|
||||
throw new RestoreRejected('Файл не распаковывается: это не .sqlite.gz или он повреждён.');
|
||||
}
|
||||
// Открываем на запись: это наша временная копия, а readonly на WAL-базе оставил бы -wal/-shm.
|
||||
let copy: Database.Database;
|
||||
let integrity: unknown;
|
||||
try {
|
||||
copy = new Database(path, { fileMustExist: true });
|
||||
} catch {
|
||||
throw new RestoreRejected('Внутри архива не база SQLite.');
|
||||
}
|
||||
try {
|
||||
try {
|
||||
copy.pragma('journal_mode = DELETE');
|
||||
integrity = copy.pragma('integrity_check', { simple: true });
|
||||
} catch {
|
||||
throw new RestoreRejected('Внутри архива не база SQLite.');
|
||||
}
|
||||
if (integrity !== 'ok') throw new RestoreRejected('Копия повреждена: integrity_check не прошёл.');
|
||||
const userVersion = copy.pragma('user_version', { simple: true }) as number;
|
||||
if (userVersion > MIGRATIONS.length) {
|
||||
throw new RestoreRejected(`Копия от более новой версии api (схема v${userVersion}, у нас v${MIGRATIONS.length}).`);
|
||||
}
|
||||
const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get();
|
||||
if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.');
|
||||
const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return { path, userVersion, leads };
|
||||
} finally {
|
||||
copy.close();
|
||||
}
|
||||
} catch (err) {
|
||||
rmSync(path, { force: true });
|
||||
removeSidecars(path);
|
||||
throw err;
|
||||
} finally {
|
||||
if (typeof source !== 'string') rmSync(gzPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function discardInspected(inspected: InspectedBackup): void {
|
||||
rmSync(inspected.path, { force: true });
|
||||
}
|
||||
|
||||
/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */
|
||||
export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] {
|
||||
const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`));
|
||||
const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
|
||||
}
|
||||
|
||||
function removeSidecars(path: string) {
|
||||
rmSync(`${path}-wal`, { force: true });
|
||||
rmSync(`${path}-shm`, { force: true });
|
||||
}
|
||||
|
||||
export type RestoreResult = { preRestore: BackupResult; leads: number };
|
||||
|
||||
/**
|
||||
* Подменить базу копией. Приём заявок на паузе с самого начала: заявка, пришедшая
|
||||
* между снимком pre-restore и подменой, иначе пропала бы без следа.
|
||||
*/
|
||||
export async function restoreFromInspected({
|
||||
holder,
|
||||
dbPath,
|
||||
inspected,
|
||||
now,
|
||||
waitIdle,
|
||||
}: {
|
||||
holder: DbHolder;
|
||||
dbPath: string;
|
||||
inspected: InspectedBackup;
|
||||
now: Date;
|
||||
/** Дождаться фоновых записей в базу (очередь Telegram), прежде чем её закрыть. */
|
||||
waitIdle?: () => Promise<void>;
|
||||
}): Promise<RestoreResult> {
|
||||
const dir = backupDirFor(dbPath);
|
||||
const rollback = `${dbPath}.rollback`;
|
||||
holder.paused = true;
|
||||
try {
|
||||
const preRestore = await createBackup(holder.db, dir, now, 'pre-restore');
|
||||
await waitIdle?.();
|
||||
|
||||
// От close до open — без await: никакой другой код не увидит закрытую базу.
|
||||
holder.db.close();
|
||||
removeSidecars(dbPath);
|
||||
renameSync(dbPath, rollback);
|
||||
try {
|
||||
renameSync(inspected.path, dbPath);
|
||||
holder.db = openDb(dbPath);
|
||||
} catch (err) {
|
||||
rmSync(dbPath, { force: true });
|
||||
removeSidecars(dbPath);
|
||||
renameSync(rollback, dbPath);
|
||||
holder.db = openDb(dbPath);
|
||||
throw err;
|
||||
}
|
||||
rmSync(rollback, { force: true });
|
||||
return { preRestore, leads: countLeads(holder.db) };
|
||||
} finally {
|
||||
holder.paused = false;
|
||||
discardInspected(inspected);
|
||||
}
|
||||
}
|
||||
|
||||
export type Scheduler = { start(): void; stop(): void };
|
||||
|
||||
/** Ежедневно в 03:00 по Минску и сразу при старте, если сегодняшней копии ещё нет. */
|
||||
export function createBackupScheduler({
|
||||
run,
|
||||
now,
|
||||
hasToday,
|
||||
}: {
|
||||
run: () => Promise<void>;
|
||||
now: () => Date;
|
||||
hasToday: () => boolean;
|
||||
}): Scheduler {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let stopped = false;
|
||||
|
||||
const safeRun = () => run().catch((err: unknown) => console.error('backup job failed', err));
|
||||
|
||||
function schedule() {
|
||||
if (stopped) return;
|
||||
const delay = Math.max(1000, nextDailyRun(now()).getTime() - now().getTime());
|
||||
timer = setTimeout(() => void safeRun().then(schedule), delay);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
stopped = false;
|
||||
if (!hasToday()) void safeRun();
|
||||
schedule();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pluralLeads(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
const word =
|
||||
mod10 === 1 && mod100 !== 11
|
||||
? 'заявка'
|
||||
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)
|
||||
? 'заявки'
|
||||
: 'заявок';
|
||||
return `${n} ${word}`;
|
||||
}
|
||||
|
||||
const kb = (bytes: number) => `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||||
|
||||
/** Копия → ротация → файл администратору → строка в «Систему». Не бросает: только отчёт. */
|
||||
export async function runBackupAndReport({
|
||||
holder,
|
||||
dir,
|
||||
now,
|
||||
client,
|
||||
adminId,
|
||||
system,
|
||||
}: {
|
||||
holder: DbHolder;
|
||||
dir: string;
|
||||
now: Date;
|
||||
client?: Pick<TelegramClient, 'sendDocument'>;
|
||||
adminId?: number;
|
||||
system: SystemReporter;
|
||||
}): Promise<BackupResult | undefined> {
|
||||
let result: BackupResult;
|
||||
try {
|
||||
result = await createBackup(holder.db, dir, now, 'daily');
|
||||
rotateBackups(dir, now);
|
||||
} catch (err) {
|
||||
await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
const summary = `Бэкап ${shortLabel(result.at)}: ${pluralLeads(result.leads)}, ${kb(result.bytes)}`;
|
||||
let delivery = '';
|
||||
if (client && adminId !== undefined) {
|
||||
try {
|
||||
await client.sendDocument({
|
||||
chat_id: adminId,
|
||||
file: { name: result.file, data: readFileSync(join(dir, result.file)) },
|
||||
caption: summary,
|
||||
});
|
||||
} catch (err) {
|
||||
delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
await system.report('backup', `${summary} ✅${delivery}`);
|
||||
return result;
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { MIGRATIONS } from './migrations.js';
|
||||
|
||||
export type Db = Database.Database;
|
||||
|
||||
/** Текущая база и пауза приёма. Восстановление подменяет db, поэтому все берут её отсюда, а не держат ссылку. */
|
||||
export type DbHolder = { db: Db; paused: boolean };
|
||||
|
||||
export const DEFAULT_DB_PATH = './data/kvarta.sqlite';
|
||||
|
||||
/** Открывает базу и доводит схему до текущей версии. `:memory:` — для тестов. */
|
||||
|
||||
+38
-8
@@ -1,9 +1,11 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app.js';
|
||||
import { backupDirFor, createBackupScheduler, hasDailyBackupToday, runBackupAndReport } from './backup.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { openDb, type Db } from './db/index.js';
|
||||
import { openDb, type DbHolder } from './db/index.js';
|
||||
import { noopNotifier, type Notifier } from './notifier.js';
|
||||
import { createRateLimiter } from './rateLimit.js';
|
||||
import { createBackupCommands } from './telegram/backupCommands.js';
|
||||
import { createBot, type Bot } from './telegram/bot.js';
|
||||
import { createTelegramClient } from './telegram/client.js';
|
||||
import { createOutbox, type Outbox } from './telegram/outbox.js';
|
||||
@@ -15,37 +17,64 @@ const now = () => new Date();
|
||||
const client = tg.token ? createTelegramClient({ token: tg.token }) : undefined;
|
||||
const system = createSystemReporter({ client, chatId: tg.chatId, threadId: tg.systemThreadId, now });
|
||||
|
||||
let db: Db;
|
||||
let holder: DbHolder;
|
||||
try {
|
||||
db = openDb(config.dbPath);
|
||||
holder = { db: openDb(config.dbPath), paused: false };
|
||||
} catch (err) {
|
||||
// Best effort: база не открылась или миграция упала — сообщаем и выходим, docker перезапустит.
|
||||
await system.report('startup', `Сбой старта api ${config.version}: ${shortError(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const getDb = () => holder.db;
|
||||
const isPaused = () => holder.paused;
|
||||
const backupDir = backupDirFor(config.dbPath);
|
||||
|
||||
let outbox: Outbox | undefined;
|
||||
let bot: Bot | undefined;
|
||||
if (client) {
|
||||
outbox = createOutbox({ getDb: () => db, client, chatId: tg.chatId, threadId: tg.leadsThreadId, now, system });
|
||||
bot = createBot({ client, telegram: tg, system });
|
||||
outbox = createOutbox({ getDb, isPaused, client, chatId: tg.chatId, threadId: tg.leadsThreadId, now, system });
|
||||
const backups =
|
||||
tg.adminId !== undefined
|
||||
? createBackupCommands({
|
||||
client,
|
||||
adminId: tg.adminId,
|
||||
holder,
|
||||
dbPath: config.dbPath,
|
||||
now,
|
||||
system,
|
||||
waitIdle: outbox.idle,
|
||||
})
|
||||
: undefined;
|
||||
bot = createBot({ client, telegram: tg, system, backups });
|
||||
}
|
||||
const notifier: Notifier = outbox ?? noopNotifier;
|
||||
|
||||
// Бэкапы делаем и без Telegram: копии на диске пригодятся при ручном восстановлении.
|
||||
const scheduler = createBackupScheduler({
|
||||
now,
|
||||
hasToday: () => hasDailyBackupToday(backupDir, now()),
|
||||
run: async () => {
|
||||
await runBackupAndReport({ holder, dir: backupDir, now: now(), client, adminId: tg.adminId, system });
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
db,
|
||||
getDb,
|
||||
now,
|
||||
rateLimiter: createRateLimiter(),
|
||||
notifier,
|
||||
isPaused,
|
||||
trustedProxies: config.trustedProxies,
|
||||
version: config.version,
|
||||
onServerError: (err) => void system.report(`500 ${err instanceof Error ? err.message : ''}`.slice(0, 120), `500: ${shortError(err)}`),
|
||||
onServerError: (err) =>
|
||||
void system.report(`500 ${err instanceof Error ? err.message : ''}`.slice(0, 120), `500: ${shortError(err)}`),
|
||||
});
|
||||
|
||||
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
console.log(`api listening on :${info.port}`);
|
||||
outbox?.start();
|
||||
bot?.start();
|
||||
scheduler.start();
|
||||
void system.report('started', `api запущен, версия ${config.version}`);
|
||||
});
|
||||
|
||||
@@ -58,11 +87,12 @@ async function shutdown(signal: string) {
|
||||
console.error('не дождались остановки, выходим');
|
||||
process.exit(1);
|
||||
}, 10_000).unref();
|
||||
scheduler.stop();
|
||||
await bot?.stop();
|
||||
await outbox?.stop();
|
||||
// Сначала дожидаемся текущих запросов, потом закрываем базу — иначе запись оборвётся на полуслове.
|
||||
server.close(() => {
|
||||
db.close();
|
||||
holder.db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { validateLead } from '@kvarta/shared/lead';
|
||||
import { backupDirFor, createBackup, createBackupScheduler, listBackups } from '../backup.js';
|
||||
import { openDb, type DbHolder } from '../db/index.js';
|
||||
import { saveLead } from '../leads.js';
|
||||
import { validBody } from '../testing.js';
|
||||
import { createBackupCommands, CONFIRM_TTL_MS } from './backupCommands.js';
|
||||
import { createBot } from './bot.js';
|
||||
import type { TgUpdate } from './client.js';
|
||||
import { fakeTelegram } from './fake.js';
|
||||
import type { SystemReporter } from './system.js';
|
||||
|
||||
const ADMIN = 42;
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// 18.10.2026 03:00 по Минску
|
||||
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
|
||||
let phone = 2000000;
|
||||
|
||||
function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kvarta-bot-'));
|
||||
dirs.push(root);
|
||||
const dbPath = join(root, 'kvarta.sqlite');
|
||||
const holder: DbHolder = { db: openDb(dbPath), paused: false };
|
||||
const clock = { now: NIGHT };
|
||||
const uploads = new Map<string, Uint8Array>();
|
||||
const tg = fakeTelegram({
|
||||
getFile: (fileId: string) => ({ file_id: fileId, file_path: `documents/${fileId}` }),
|
||||
downloadFile: ([path]: [string]) => uploads.get(path.replace('documents/', ''))!,
|
||||
});
|
||||
const reports: string[] = [];
|
||||
const system: SystemReporter = { report: async (_k, t) => void reports.push(t) };
|
||||
const backups = createBackupCommands({ client: tg.client, adminId: ADMIN, holder, dbPath, now: () => clock.now, system });
|
||||
const bot = createBot({ client: tg.client, telegram: { adminId: ADMIN }, system, backups });
|
||||
|
||||
const addLead = () => {
|
||||
const r = validateLead(validBody({ phone: `+37529${phone++}` }));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
saveLead(holder.db, r.data, { now: clock.now, userAgent: null });
|
||||
};
|
||||
const chat = { id: ADMIN, type: 'private' as const };
|
||||
const from = (id: number) => ({ id, is_bot: false, first_name: 'A' });
|
||||
const text = (t: string, fromId = ADMIN): TgUpdate => ({
|
||||
update_id: 1,
|
||||
message: { message_id: 1, from: from(fromId), chat: { ...chat, id: fromId }, text: t },
|
||||
});
|
||||
const press = (data: string, fromId = ADMIN): TgUpdate => ({
|
||||
update_id: 1,
|
||||
callback_query: { id: 'q', from: from(fromId), data, message: { message_id: 9, chat: { ...chat, id: fromId } } },
|
||||
});
|
||||
const lastText = () => tg.sent().at(-1)?.text as string;
|
||||
const lastButtons = () => tg.sent().at(-1)?.reply_markup?.inline_keyboard.flat() as { text: string; callback_data: string }[];
|
||||
const edits = () => tg.sent('editMessageText').map((p) => p.text as string);
|
||||
const leadIds = () => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
|
||||
|
||||
return { tg, bot, holder, dbPath, clock, reports, uploads, addLead, text, press, lastText, lastButtons, edits, leadIds };
|
||||
}
|
||||
|
||||
describe('бэкапы в личке администратора', () => {
|
||||
it('/backups — кнопки с датой и числом заявок', async () => {
|
||||
const s = setup();
|
||||
s.addLead();
|
||||
await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT);
|
||||
s.addLead();
|
||||
await createBackup(s.holder.db, backupDirFor(s.dbPath), new Date(NIGHT.getTime() + 60_000), 'pre-restore');
|
||||
|
||||
await s.bot.handleUpdate(s.text('/backups'));
|
||||
expect(s.lastButtons()).toEqual([
|
||||
{ text: 'pre-restore · 18.10 03:01 · 2 заявки', callback_data: 'r:pre-restore-2026-10-18T0301.sqlite.gz' },
|
||||
{ text: '18.10 03:00 · 1 заявка', callback_data: 'r:kvarta-2026-10-18T0300.sqlite.gz' },
|
||||
]);
|
||||
for (const b of s.lastButtons()) expect(Buffer.byteLength(b.callback_data)).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
it('/backup — копия сейчас, файлом в личку', async () => {
|
||||
const s = setup();
|
||||
s.addLead();
|
||||
await s.bot.handleUpdate(s.text('/backup'));
|
||||
expect(s.tg.sent('sendDocument')[0]).toMatchObject({ chat_id: ADMIN, file: { name: 'kvarta-2026-10-18T0300.sqlite.gz' } });
|
||||
expect(s.reports[0]).toMatch(/^Бэкап 18\.10 03:00: 1 заявка/);
|
||||
});
|
||||
|
||||
it('кнопка копии → подтверждение с пропадающими заявками → восстановление', async () => {
|
||||
const s = setup();
|
||||
s.addLead();
|
||||
await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT);
|
||||
s.addLead();
|
||||
s.addLead();
|
||||
|
||||
await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz'));
|
||||
expect(s.lastText()).toBe(
|
||||
'Восстановить базу из kvarta-2026-10-18T0300.sqlite.gz? Сейчас в базе 3 заявки, 2 из них новее этой копии и пропадут (#2, #3). Текущая база будет сохранена как pre-restore.',
|
||||
);
|
||||
const [yes] = s.lastButtons();
|
||||
expect(yes).toMatchObject({ text: 'Восстановить' });
|
||||
expect(s.leadIds()).toEqual([1, 2, 3]);
|
||||
|
||||
await s.bot.handleUpdate(s.press(yes!.callback_data));
|
||||
expect(s.leadIds()).toEqual([1]);
|
||||
expect(s.lastText()).toMatch(/^База восстановлена из kvarta-2026-10-18T0300\.sqlite\.gz: 1 заявка\. Прежняя база сохранена как pre-restore-/);
|
||||
expect(s.reports).toContain('База восстановлена из kvarta-2026-10-18T0300.sqlite.gz администратором');
|
||||
expect(listBackups(backupDirFor(s.dbPath)).some((b) => b.kind === 'pre-restore')).toBe(true);
|
||||
|
||||
// Повторное нажатие той же кнопки ничего не делает.
|
||||
await s.bot.handleUpdate(s.press(yes!.callback_data));
|
||||
expect(s.edits().at(-1)).toMatch(/устарело/);
|
||||
});
|
||||
|
||||
it('отмена и истёкшее подтверждение не трогают базу', async () => {
|
||||
const s = setup();
|
||||
s.addLead();
|
||||
await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT);
|
||||
s.addLead();
|
||||
|
||||
await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz'));
|
||||
await s.bot.handleUpdate(s.press(s.lastButtons()[1]!.callback_data));
|
||||
expect(s.edits().at(-1)).toBe('Восстановление отменено.');
|
||||
|
||||
await s.bot.handleUpdate(s.press('r:kvarta-2026-10-18T0300.sqlite.gz'));
|
||||
const yes = s.lastButtons()[0]!.callback_data;
|
||||
s.clock.now = new Date(NIGHT.getTime() + CONFIRM_TTL_MS + 1);
|
||||
await s.bot.handleUpdate(s.press(yes));
|
||||
expect(s.edits().at(-1)).toMatch(/устарело/);
|
||||
expect(s.leadIds()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('присланный .sqlite.gz → подтверждение; битый файл — отказ', async () => {
|
||||
const s = setup();
|
||||
s.addLead();
|
||||
const backup = await createBackup(s.holder.db, backupDirFor(s.dbPath), NIGHT);
|
||||
s.uploads.set('good', readFileSync(join(backupDirFor(s.dbPath), backup.file)));
|
||||
s.uploads.set('bad', new Uint8Array([1, 2, 3]));
|
||||
const doc = (fileId: string, name: string, size = 100): TgUpdate => ({
|
||||
update_id: 1,
|
||||
message: {
|
||||
message_id: 1,
|
||||
from: { id: ADMIN, is_bot: false, first_name: 'A' },
|
||||
chat: { id: ADMIN, type: 'private' },
|
||||
document: { file_id: fileId, file_name: name, file_size: size },
|
||||
},
|
||||
});
|
||||
|
||||
await s.bot.handleUpdate(doc('good', 'kvarta-old.sqlite.gz'));
|
||||
expect(s.lastText()).toMatch(/^Восстановить базу из kvarta-old\.sqlite\.gz\? Сейчас в базе 1 заявка, ни одна не пропадёт\./);
|
||||
|
||||
await s.bot.handleUpdate(doc('bad', 'broken.sqlite.gz'));
|
||||
expect(s.lastText()).toMatch(/^Из этой копии восстановить нельзя: Файл не распаковывается/);
|
||||
|
||||
await s.bot.handleUpdate(doc('good', 'huge.sqlite.gz', 21 * 1024 * 1024));
|
||||
expect(s.lastText()).toMatch(/больше 20 МБ/);
|
||||
await s.bot.handleUpdate(doc('good', 'notes.txt'));
|
||||
expect(s.lastText()).toMatch(/\.sqlite\.gz/);
|
||||
});
|
||||
|
||||
it('имя из callback_data не превращается в путь', async () => {
|
||||
const s = setup();
|
||||
await s.bot.handleUpdate(s.press('r:../kvarta.sqlite'));
|
||||
expect(s.lastText()).toMatch(/Этой копии уже нет/);
|
||||
});
|
||||
|
||||
it('чужому — тишина; /backups в группе — тишина', async () => {
|
||||
const s = setup();
|
||||
await s.bot.handleUpdate(s.text('/backups', 999));
|
||||
await s.bot.handleUpdate(s.press('r:x', 999));
|
||||
await s.bot.handleUpdate({
|
||||
update_id: 1,
|
||||
message: { message_id: 1, from: { id: ADMIN, is_bot: false, first_name: 'A' }, chat: { id: -100, type: 'supergroup' }, text: '/backups' },
|
||||
});
|
||||
expect(s.tg.calls.filter((c) => c.method !== 'getMe')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('планировщик бэкапов', () => {
|
||||
it('при старте без сегодняшней копии — сразу, дальше в 03:00 по Минску', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-17T12:00:00Z') });
|
||||
try {
|
||||
const runs: string[] = [];
|
||||
const scheduler = createBackupScheduler({
|
||||
now: () => new Date(),
|
||||
hasToday: () => runs.length > 0,
|
||||
run: async () => void runs.push(new Date().toISOString()),
|
||||
});
|
||||
scheduler.start();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(runs).toEqual(['2026-10-17T12:00:00.000Z']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(12 * 60 * 60 * 1000);
|
||||
expect(runs.at(-1)).toBe('2026-10-18T00:00:00.000Z');
|
||||
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
||||
expect(runs.at(-1)).toBe('2026-10-19T00:00:00.000Z');
|
||||
scheduler.stop();
|
||||
await vi.advanceTimersByTimeAsync(48 * 60 * 60 * 1000);
|
||||
expect(runs).toHaveLength(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('сегодняшняя копия есть — при старте не запускаем', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-10-17T12:00:00Z') });
|
||||
try {
|
||||
let runs = 0;
|
||||
const scheduler = createBackupScheduler({ now: () => new Date(), hasToday: () => true, run: async () => void runs++ });
|
||||
scheduler.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(runs).toBe(0);
|
||||
scheduler.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Бэкапы в личке администратора: /backups, /backup, восстановление по кнопке или присланному файлу.
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
backupDirFor,
|
||||
discardInspected,
|
||||
inspectBackup,
|
||||
leadsMissingFrom,
|
||||
listBackups,
|
||||
pluralLeads,
|
||||
restoreFromInspected,
|
||||
RestoreRejected,
|
||||
runBackupAndReport,
|
||||
shortLabel,
|
||||
type InspectedBackup,
|
||||
} from '../backup.js';
|
||||
import type { DbHolder } from '../db/index.js';
|
||||
import type { InlineKeyboard, TelegramClient, TgCallbackQuery, TgMessage } from './client.js';
|
||||
import { shortError, type SystemReporter } from './system.js';
|
||||
|
||||
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
export const CONFIRM_TTL_MS = 10 * 60 * 1000;
|
||||
const LIST_LIMIT = 10;
|
||||
|
||||
type Client = Pick<
|
||||
TelegramClient,
|
||||
'sendMessage' | 'sendDocument' | 'answerCallbackQuery' | 'editMessageText' | 'getFile' | 'downloadFile'
|
||||
>;
|
||||
|
||||
type Pending = { inspected: InspectedBackup; label: string; expiresAt: number };
|
||||
|
||||
export type BackupCommands = ReturnType<typeof createBackupCommands>;
|
||||
|
||||
export function createBackupCommands({
|
||||
client,
|
||||
adminId,
|
||||
holder,
|
||||
dbPath,
|
||||
now,
|
||||
system,
|
||||
waitIdle,
|
||||
}: {
|
||||
client: Client;
|
||||
adminId: number;
|
||||
holder: DbHolder;
|
||||
dbPath: string;
|
||||
now: () => Date;
|
||||
system: SystemReporter;
|
||||
waitIdle?: () => Promise<void>;
|
||||
}) {
|
||||
const dir = backupDirFor(dbPath);
|
||||
// Число заявок в копии ради подписи кнопки: файл копии не меняется, считаем один раз.
|
||||
const leadsInFile = new Map<string, number>();
|
||||
// В callback_data ≤ 64 байт: только короткий id, сама копия — здесь.
|
||||
const pending = new Map<string, Pending>();
|
||||
let seq = 0;
|
||||
|
||||
const say = (text: string, reply_markup?: InlineKeyboard) => client.sendMessage({ chat_id: adminId, text, reply_markup });
|
||||
|
||||
function sweep() {
|
||||
const t = now().getTime();
|
||||
for (const [id, p] of pending) {
|
||||
if (p.expiresAt <= t) {
|
||||
discardInspected(p.inspected);
|
||||
pending.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function countIn(file: string): Promise<number | undefined> {
|
||||
if (leadsInFile.has(file)) return leadsInFile.get(file);
|
||||
try {
|
||||
const inspected = await inspectBackup(join(dir, file), dir);
|
||||
discardInspected(inspected);
|
||||
leadsInFile.set(file, inspected.leads.length);
|
||||
return inspected.leads.length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function listCommand() {
|
||||
const backups = listBackups(dir).slice(0, LIST_LIMIT);
|
||||
if (backups.length === 0) {
|
||||
await say('Копий пока нет. /backup — сделать сейчас.');
|
||||
return;
|
||||
}
|
||||
const rows = [];
|
||||
for (const b of backups) {
|
||||
const leads = await countIn(b.file);
|
||||
const text = [b.kind === 'pre-restore' ? 'pre-restore' : null, shortLabel(b.at), leads === undefined ? 'битая' : pluralLeads(leads)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
rows.push([{ text, callback_data: `r:${b.file}` }]);
|
||||
}
|
||||
await say('Последние копии базы. Нажмите на копию, чтобы восстановить из неё:', { inline_keyboard: rows });
|
||||
}
|
||||
|
||||
async function backupCommand() {
|
||||
const result = await runBackupAndReport({ holder, dir, now: now(), client, adminId, system });
|
||||
if (!result) await say('Не получилось сделать копию, подробности — в теме «Система».');
|
||||
}
|
||||
|
||||
async function offerRestore(inspected: InspectedBackup, label: string) {
|
||||
sweep();
|
||||
const missing = leadsMissingFrom(holder.db, inspected.leads);
|
||||
const total = (holder.db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
|
||||
const id = String(++seq);
|
||||
pending.set(id, { inspected, label, expiresAt: now().getTime() + CONFIRM_TTL_MS });
|
||||
|
||||
const lost =
|
||||
missing.length === 0
|
||||
? 'ни одна не пропадёт'
|
||||
: `${missing.length} из них новее этой копии и пропадут (${missing.map((n) => `#${n}`).join(', ')})`;
|
||||
await say(
|
||||
`Восстановить базу из ${label}? Сейчас в базе ${pluralLeads(total)}, ${lost}. Текущая база будет сохранена как pre-restore.`,
|
||||
{
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: 'Восстановить', callback_data: `y:${id}` },
|
||||
{ text: 'Отмена', callback_data: `n:${id}` },
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function prepare(source: string | Uint8Array, label: string) {
|
||||
try {
|
||||
await offerRestore(await inspectBackup(source, dir), label);
|
||||
} catch (err) {
|
||||
if (err instanceof RestoreRejected) await say(`Из этой копии восстановить нельзя: ${err.message}`);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm(id: string, query: TgCallbackQuery) {
|
||||
sweep();
|
||||
const p = pending.get(id);
|
||||
pending.delete(id);
|
||||
const edit = (text: string) =>
|
||||
query.message
|
||||
? client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text })
|
||||
: say(text);
|
||||
if (!p) {
|
||||
await edit('Подтверждение устарело. Выберите копию заново: /backups');
|
||||
return;
|
||||
}
|
||||
await edit(`Восстанавливаю базу из ${p.label}…`);
|
||||
try {
|
||||
const result = await restoreFromInspected({ holder, dbPath, inspected: p.inspected, now: now(), waitIdle });
|
||||
await say(
|
||||
`База восстановлена из ${p.label}: ${pluralLeads(result.leads)}. Прежняя база сохранена как ${result.preRestore.file}.`,
|
||||
);
|
||||
await system.report('restore', `База восстановлена из ${p.label} администратором`);
|
||||
} catch (err) {
|
||||
await say(`Не удалось восстановить: ${err instanceof Error ? err.message : String(err)}. База осталась прежней.`);
|
||||
await system.report('restore failed', `Восстановление из ${p.label} не удалось, база прежняя\n${shortError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** true — команда наша и обработана. */
|
||||
async onCommand(command: string): Promise<boolean> {
|
||||
if (command === 'backups') await listCommand();
|
||||
else if (command === 'backup') await backupCommand();
|
||||
else return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
async onDocument(msg: TgMessage) {
|
||||
const doc = msg.document!;
|
||||
const name = doc.file_name ?? 'файл';
|
||||
if (!name.endsWith('.sqlite.gz')) {
|
||||
await say('Пришлите копию базы файлом .sqlite.gz.');
|
||||
return;
|
||||
}
|
||||
if ((doc.file_size ?? 0) > MAX_UPLOAD_BYTES) {
|
||||
await say('Файл больше 20 МБ — бот такой скачать не может.');
|
||||
return;
|
||||
}
|
||||
const file = await client.getFile(doc.file_id);
|
||||
if (!file.file_path) throw new Error('getFile не вернул file_path');
|
||||
await prepare(await client.downloadFile(file.file_path, MAX_UPLOAD_BYTES), name);
|
||||
},
|
||||
|
||||
async onCallback(query: TgCallbackQuery) {
|
||||
await client.answerCallbackQuery(query.id);
|
||||
const data = query.data ?? '';
|
||||
const [action, arg = ''] = [data.slice(0, 1), data.slice(2)];
|
||||
if (action === 'r') {
|
||||
// Имя проверяем по списку, а не склеиваем путь из callback_data: никакого ../
|
||||
const backup = listBackups(dir).find((b) => b.file === arg);
|
||||
if (!backup) await say('Этой копии уже нет. /backups — свежий список.');
|
||||
else await prepare(join(dir, backup.file), backup.file);
|
||||
} else if (action === 'y') {
|
||||
await confirm(arg, query);
|
||||
} else if (action === 'n') {
|
||||
const p = pending.get(arg);
|
||||
if (p) discardInspected(p.inspected);
|
||||
pending.delete(arg);
|
||||
if (query.message) {
|
||||
await client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text: 'Восстановление отменено.' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// (ADR 0002). Слушаемся только TELEGRAM_ADMIN_ID, остальным не отвечаем вовсе.
|
||||
import type { TelegramConfig } from '../config.js';
|
||||
import { TelegramError, type TelegramClient, type TgMessage, type TgUpdate, type TgUser } from './client.js';
|
||||
import type { BackupCommands } from './backupCommands.js';
|
||||
import { shortError, type SystemReporter } from './system.js';
|
||||
|
||||
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
|
||||
@@ -33,11 +34,13 @@ export function createBot({
|
||||
client,
|
||||
telegram,
|
||||
system,
|
||||
backups,
|
||||
retryPauseMs = RETRY_PAUSE_MS,
|
||||
}: {
|
||||
client: Client;
|
||||
telegram: TelegramConfig;
|
||||
system: SystemReporter;
|
||||
backups?: Pick<BackupCommands, 'onCommand' | 'onDocument' | 'onCallback'>;
|
||||
retryPauseMs?: number;
|
||||
}): Bot {
|
||||
let me: TgUser | undefined;
|
||||
@@ -103,13 +106,27 @@ export function createBot({
|
||||
await reply(msg, 'Темы созданы, настройки отправил администратору в личку.');
|
||||
}
|
||||
|
||||
const isAdmin = (user?: TgUser) => telegram.adminId !== undefined && user?.id === telegram.adminId;
|
||||
|
||||
async function handleUpdate(update: TgUpdate) {
|
||||
const query = update.callback_query;
|
||||
if (query) {
|
||||
if (isAdmin(query.from) && query.message?.chat.type === 'private') await backups?.onCallback(query);
|
||||
return;
|
||||
}
|
||||
const msg = update.message;
|
||||
if (!msg?.from || telegram.adminId === undefined || msg.from.id !== telegram.adminId) return;
|
||||
if (!msg || !isAdmin(msg.from)) return;
|
||||
const isPrivate = msg.chat.type === 'private';
|
||||
if (isPrivate && msg.document) {
|
||||
await backups?.onDocument(msg);
|
||||
return;
|
||||
}
|
||||
me ??= await client.getMe();
|
||||
const cmd = parseCommand(msg.text, me?.username);
|
||||
if (!cmd) return;
|
||||
if (cmd.command === 'setup') await setup(msg, cmd.args[0] === 'force');
|
||||
// Бэкапы — только в личке: в группе их видели бы менеджеры.
|
||||
else if (isPrivate) await backups?.onCommand(cmd.command);
|
||||
}
|
||||
|
||||
const pause = (ms: number) =>
|
||||
|
||||
@@ -16,7 +16,7 @@ function lead(overrides: Parameters<typeof validBody>[0] = {}): ValidLead {
|
||||
return r.data;
|
||||
}
|
||||
|
||||
function setup({ failures = [] as unknown[], chatId = -100 as number | null } = {}) {
|
||||
function setup({ failures = [] as unknown[], chatId = -100 as number | null, paused = { value: false } } = {}) {
|
||||
const db = openDb(':memory:');
|
||||
const clock = { now: new Date('2026-10-17T12:00:00.000Z') };
|
||||
const queue = [...failures];
|
||||
@@ -29,7 +29,7 @@ function setup({ failures = [] as unknown[], chatId = -100 as number | null } =
|
||||
});
|
||||
const reports: { kind: string; text: string }[] = [];
|
||||
const system: SystemReporter = { report: async (kind, text) => void reports.push({ kind, text }) };
|
||||
const outbox = createOutbox({ getDb: () => db, client: tg.client, chatId: chatId ?? undefined, threadId: 5, now: () => clock.now, system });
|
||||
const outbox = createOutbox({ getDb: () => db, client: tg.client, chatId: chatId ?? undefined, threadId: 5, now: () => clock.now, system, isPaused: () => paused.value });
|
||||
const advance = (ms: number) => (clock.now = new Date(clock.now.getTime() + ms));
|
||||
const save = (l: ValidLead) => saveLead(db, l, { now: clock.now, userAgent: null });
|
||||
const row = (table: string, id = 1) =>
|
||||
@@ -140,6 +140,17 @@ describe('outbox', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('на паузе (восстановление базы) не отправляет', async () => {
|
||||
const paused = { value: true };
|
||||
const { tg, outbox, save, row } = setup({ paused });
|
||||
save(lead());
|
||||
await outbox.tick();
|
||||
expect(tg.calls).toEqual([]);
|
||||
paused.value = false;
|
||||
await outbox.tick();
|
||||
expect(row('leads').tg_status).toBe('sent');
|
||||
});
|
||||
|
||||
it('без TELEGRAM_CHAT_ID ничего не шлём, записи остаются pending', async () => {
|
||||
const { tg, outbox, save, row } = setup({ chatId: null });
|
||||
save(lead());
|
||||
|
||||
@@ -29,6 +29,8 @@ export type Outbox = Notifier & {
|
||||
tick(): Promise<void>;
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
/** Дождаться текущей отправки: перед подменой базы. */
|
||||
idle(): Promise<void>;
|
||||
};
|
||||
|
||||
/** Пауза перед попыткой n+1 после n неудач: 1, 2, 4… минуты. */
|
||||
@@ -43,6 +45,7 @@ export function createOutbox({
|
||||
threadId,
|
||||
now,
|
||||
system,
|
||||
isPaused,
|
||||
intervalMs = TICK_MS,
|
||||
}: {
|
||||
getDb: () => Db;
|
||||
@@ -51,6 +54,7 @@ export function createOutbox({
|
||||
threadId?: number;
|
||||
now: () => Date;
|
||||
system: SystemReporter;
|
||||
isPaused?: () => boolean;
|
||||
intervalMs?: number;
|
||||
}): Outbox {
|
||||
const enabled = client !== undefined && chatId !== undefined;
|
||||
@@ -149,8 +153,10 @@ export function createOutbox({
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (!enabled) return;
|
||||
const db = getDb();
|
||||
for (;;) {
|
||||
// На время восстановления базу не трогаем; геттер — потому что после него база другая.
|
||||
if (isPaused?.()) return;
|
||||
const db = getDb();
|
||||
const item = nextDue(db, now().toISOString());
|
||||
if (!item) return;
|
||||
// После неудачи останавливаемся: остальное подождёт следующего тика, порядок не перемешается.
|
||||
@@ -174,6 +180,7 @@ export function createOutbox({
|
||||
timer.unref();
|
||||
void tick();
|
||||
},
|
||||
idle: () => chain,
|
||||
async stop() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function setup(overrides: Partial<AppDeps> = {}) {
|
||||
const notifier: Notifier = { leadSaved: (e) => void events.push(e) };
|
||||
// Лимит проверяется отдельно; в остальных тестах он бы мешал сериям запросов.
|
||||
const rateLimiter: RateLimiter = { hit: () => ({ ok: true }) };
|
||||
const app = createApp({ db, now: () => clock.now, rateLimiter, notifier, ...overrides });
|
||||
const app = createApp({ getDb: () => db, now: () => clock.now, rateLimiter, notifier, ...overrides });
|
||||
|
||||
/** socket — адрес, который увидел бы getConnInfo в node-server. */
|
||||
const post = (body: unknown, headers: Record<string, string> = {}, socket?: string) =>
|
||||
|
||||
Reference in New Issue
Block a user