perf(classroom): инкрементальный поллинг доски, картинки в файлы, ретеншн

#1 Студенческий поллинг: вместо полной перезагрузки доски каждые 2с —
   лёгкая сигнатура страницы (?meta=1 → maxSeq+count). Если доска совпадает
   с сервером (обычный случай при живом WS) — ничего не грузим. Полная
   перезагрузка только при расхождении. Счёт подтверждённых штрихов — по
   положительным id (без bookkeeping).
#2 Картинки-штрихи выносятся в файлы /uploads/classroom (вместо base64 в БД):
   меньше БД и payload поллинга. Имя с префиксом sessionId.
#5 Ретеншн: classroom-cleanup удаляет штрихи+файлы завершённых сессий старше
   N дней (app_settings.classroom_retention_days, по умолч. 30; 0 = выкл),
   историю/чат/посещаемость не трогает. Планировщик в server.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 13:02:26 +03:00
parent ddc260e114
commit 5d3db90b5d
4 changed files with 128 additions and 8 deletions
+65
View File
@@ -0,0 +1,65 @@
'use strict';
/* Ретеншн данных онлайн-уроков: у завершённых сессий старше N дней удаляем
* тяжёлые данные доски — штрихи + файлы-картинки. Саму сессию, посещаемость и
* чат НЕ трогаем (история/сводка/экспорт продолжают работать).
* Период: app_settings.classroom_retention_days (по умолчанию 30; 0 = выключено). */
const fs = require('fs');
const path = require('path');
const db = require('./db/db');
const logger = require('./utils/logger');
const IMG_DIR = path.join(__dirname, '../uploads/classroom');
function _retentionDays() {
try {
const r = db.prepare("SELECT value FROM app_settings WHERE key='classroom_retention_days'").get();
if (r && r.value != null) { const n = Number(r.value); if (Number.isFinite(n) && n >= 0) return n; }
} catch (e) {}
return 30;
}
function cleanupClassroomData() {
const days = _retentionDays();
if (!days) return { skipped: true };
let sessions = [];
try {
sessions = db.prepare(
"SELECT id FROM classroom_sessions WHERE status='ended' AND ended_at IS NOT NULL AND ended_at < datetime('now', ?)"
).all('-' + days + ' days');
} catch (e) { return { error: e.message }; }
if (!sessions.length) return { sessions: 0, strokes: 0, files: 0 };
const ids = new Set(sessions.map(s => s.id));
let strokes = 0;
const del = db.prepare('DELETE FROM classroom_strokes WHERE session_id=?');
for (const id of ids) { try { strokes += del.run(id).changes; } catch (e) {} }
// Файлы названы '<sessionId>-<rand>.<ext>' — удаляем по префиксу сессии.
let files = 0;
try {
for (const f of fs.readdirSync(IMG_DIR)) {
const m = /^(\d+)-/.exec(f);
if (m && ids.has(Number(m[1]))) {
try { fs.unlinkSync(path.join(IMG_DIR, f)); files++; } catch (e) {}
}
}
} catch (e) { /* директории может не быть — это норма */ }
return { sessions: ids.size, strokes, files };
}
/* Раз в сутки + один прогон через минуту после старта. unref — не держит процесс. */
function schedule() {
const run = () => {
try {
const r = cleanupClassroomData();
if (r && (r.strokes || r.files)) logger.info('classroom-cleanup', r);
} catch (e) {}
};
setTimeout(run, 60_000).unref();
setInterval(run, 24 * 60 * 60 * 1000).unref();
}
module.exports = { cleanupClassroomData, schedule };