fix(reliability): дневной лимит imggen в БД + ретеншн error_log (Спринт3)

- imggen: дневной счётчик генераций перенесён из in-memory Map в таблицу
  imggen_usage (миграция 070) — переживает рестарт. Cooldown остаётся в памяти,
  но добавлена периодическая чистка Map + старых строк imggen_usage (>7 дн).
- classroom-cleanup: ретеншн error_log (app_settings.error_log_retention_days,
  по умолч. 30; 0 = выкл) в том же суточном джобе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 23:00:36 +03:00
parent 9d622454d6
commit ccfb151eca
3 changed files with 43 additions and 6 deletions
+16 -1
View File
@@ -50,16 +50,31 @@ function cleanupClassroomData() {
return { sessions: ids.size, strokes, files }; return { sessions: ids.size, strokes, files };
} }
/* Ретеншн error_log: не копим бесконечно (период app_settings.error_log_retention_days,
по умолчанию 30; 0 = выключено). */
function cleanupErrorLog() {
let days = 30;
try {
const r = db.prepare("SELECT value FROM app_settings WHERE key='error_log_retention_days'").get();
if (r && r.value != null) { const n = Number(r.value); if (Number.isFinite(n) && n >= 0) days = n; }
} catch (e) {}
if (!days) return 0;
try { return db.prepare("DELETE FROM error_log WHERE created_at < datetime('now', ?)").run('-' + days + ' days').changes; }
catch (e) { return 0; }
}
/* Раз в сутки + один прогон через минуту после старта. unref — не держит процесс. */ /* Раз в сутки + один прогон через минуту после старта. unref — не держит процесс. */
function schedule() { function schedule() {
const run = () => { const run = () => {
try { try {
const r = cleanupClassroomData(); const r = cleanupClassroomData();
if (r && (r.strokes || r.files)) logger.info('classroom-cleanup', r); if (r && (r.strokes || r.files)) logger.info('classroom-cleanup', r);
const errLogged = cleanupErrorLog();
if (errLogged) logger.info('error-log-cleanup', { deleted: errLogged });
} catch (e) {} } catch (e) {}
}; };
setTimeout(run, 60_000).unref(); setTimeout(run, 60_000).unref();
setInterval(run, 24 * 60 * 60 * 1000).unref(); setInterval(run, 24 * 60 * 60 * 1000).unref();
} }
module.exports = { cleanupClassroomData, schedule }; module.exports = { cleanupClassroomData, cleanupErrorLog, schedule };
+19 -5
View File
@@ -9,11 +9,26 @@ const db = require('../db/db');
const { isFeatureEnabledForUser } = require('../middleware/features'); const { isFeatureEnabledForUser } = require('../middleware/features');
const GEN_DIR = path.join(__dirname, '../../uploads/generated'); const GEN_DIR = path.join(__dirname, '../../uploads/generated');
const _cooldown = new Map(); // userId → last ts (антиспам) const _cooldown = new Map(); // userId → last ts (антиспам, короткоживущий, ок в памяти)
const _daily = new Map(); // userId → { day, count }
const COOLDOWN_DEFAULT = 4000; const COOLDOWN_DEFAULT = 4000;
const DAILY_DEFAULT = 40; const DAILY_DEFAULT = 40;
// Дневной счётчик — в БД (imggen_usage), переживает рестарт.
function _dailyCount(uid, day) {
try { const r = db.prepare('SELECT count FROM imggen_usage WHERE user_id=? AND day=?').get(uid, day); return r ? r.count : 0; }
catch (e) { return 0; }
}
function _dailyBump(uid, day) {
try { db.prepare('INSERT INTO imggen_usage (user_id, day, count) VALUES (?,?,1) ON CONFLICT(user_id, day) DO UPDATE SET count = count + 1').run(uid, day); }
catch (e) {}
}
// Чистка in-memory cooldown (старше минуты) + старых строк imggen_usage — не растём бесконечно.
setInterval(() => {
const cut = Date.now() - 60_000;
for (const [k, t] of _cooldown) if (t < cut) _cooldown.delete(k);
try { db.prepare("DELETE FROM imggen_usage WHERE day < date('now','-7 days')").run(); } catch (e) {}
}, 5 * 60_000).unref();
function _cfg() { function _cfg() {
try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; } try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; }
} }
@@ -132,13 +147,12 @@ async function generate(req, res) {
const L = _limits(); const L = _limits();
if (now - (_cooldown.get(uid) || 0) < L.cooldownMs) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' }); if (now - (_cooldown.get(uid) || 0) < L.cooldownMs) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' });
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const d = _daily.get(uid); if (L.dailyCap > 0 && _dailyCount(uid, today) >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' });
if (L.dailyCap > 0 && d && d.day === today && d.count >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' });
_cooldown.set(uid, now); _cooldown.set(uid, now);
try { try {
const out = await generateImage(prompt, uid); const out = await generateImage(prompt, uid);
_daily.set(uid, { day: today, count: (d && d.day === today ? d.count : 0) + 1 }); _dailyBump(uid, today);
res.json({ url: out.url }); res.json({ url: out.url });
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail }); res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail });
@@ -0,0 +1,8 @@
-- Дневной счётчик генераций картинок на пользователя (переживает рестарт,
-- в отличие от прежней in-memory Map). Лимит применяется в imggenController.
CREATE TABLE IF NOT EXISTS imggen_usage (
user_id INTEGER NOT NULL,
day TEXT NOT NULL, -- 'YYYY-MM-DD'
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);