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:
@@ -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 };
|
||||
@@ -1,7 +1,36 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../../db/db');
|
||||
const { emitToSession, hasAccess, canDraw } = require('./_shared');
|
||||
|
||||
// Картинки-штрихи храним файлами (а не base64 в БД): меньше БД и легче payload поллинга.
|
||||
// Имя '<sessionId>-<rand>.<ext>' — чтобы чистка по завершённой сессии находила файлы по префиксу.
|
||||
const STROKE_IMG_DIR = path.join(__dirname, '../../../uploads/classroom');
|
||||
function _saveStrokeImage(sessionId, dataUrl) {
|
||||
try {
|
||||
const m = /^data:image\/(png|jpe?g|webp|gif);base64,(.+)$/i.exec(dataUrl);
|
||||
if (!m) return null;
|
||||
const ext = m[1].toLowerCase() === 'jpeg' ? 'jpg' : m[1].toLowerCase();
|
||||
const buf = Buffer.from(m[2], 'base64');
|
||||
if (buf.length < 64 || buf.length > 8 * 1024 * 1024) return null;
|
||||
fs.mkdirSync(STROKE_IMG_DIR, { recursive: true });
|
||||
const name = sessionId + '-' + crypto.randomBytes(12).toString('hex') + '.' + ext;
|
||||
fs.writeFileSync(path.join(STROKE_IMG_DIR, name), buf);
|
||||
return '/uploads/classroom/' + name;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
// Если штрих несёт картинку base64 — выносим в файл, в data.src кладём URL.
|
||||
function _externalizeImage(sessionId, s) {
|
||||
const d = s && s.data;
|
||||
if (d && typeof d.src === 'string' && d.src.startsWith('data:image/')) {
|
||||
const url = _saveStrokeImage(sessionId, d.src);
|
||||
if (url) return { ...s, data: { ...d, src: url } };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function postStrokes(req, res) {
|
||||
const sessionId = Number(req.params.id);
|
||||
const { strokes, page_num = 1 } = req.body;
|
||||
@@ -16,10 +45,13 @@ function postStrokes(req, res) {
|
||||
const insert = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)');
|
||||
const getMaxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?');
|
||||
|
||||
// Вынос картинок base64 → файлы делаем ДО транзакции (диск не держит lock БД).
|
||||
const prepared = strokes.map(s => _externalizeImage(sessionId, s));
|
||||
|
||||
const saved = [];
|
||||
db.transaction(() => {
|
||||
let seq = getMaxSeq.get(sessionId, page_num).m;
|
||||
for (const s of strokes) {
|
||||
for (const s of prepared) {
|
||||
seq++;
|
||||
const { lastInsertRowid } = insert.run(sessionId, page_num, req.user.id, s.tool || 'pencil', JSON.stringify(s.data), seq);
|
||||
saved.push({ id: Number(lastInsertRowid), tool: s.tool || 'pencil', data: s.data, seq });
|
||||
@@ -38,6 +70,13 @@ function getStrokes(req, res) {
|
||||
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
||||
if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
// Лёгкая «сигнатура» страницы — для инкрементального поллинга клиента (без штрихов).
|
||||
if (req.query.meta === '1') {
|
||||
const m = db.prepare('SELECT COUNT(*) AS c, COALESCE(MAX(seq),0) AS s FROM classroom_strokes WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
|
||||
const pr = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
|
||||
return res.json({ meta: true, count: m.c, maxSeq: m.s, template: pr?.template || 'blank' });
|
||||
}
|
||||
|
||||
const STROKES_PAGE_LIMIT = 5000;
|
||||
const rows = sinceSeq >= 0
|
||||
? db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq LIMIT ?').all(sessionId, pageNum, sinceSeq, STROKES_PAGE_LIMIT)
|
||||
@@ -65,8 +104,9 @@ function updateStroke(req, res) {
|
||||
if (existing.user_id !== req.user.id && session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Нет доступа' });
|
||||
|
||||
db.prepare('UPDATE classroom_strokes SET data=? WHERE id=?').run(JSON.stringify(data), strokeId);
|
||||
emitToSession(sessionId, { type: 'classroom_stroke_updated', sessionId, strokeId, pageNum: existing.page_num, data });
|
||||
const finalData = _externalizeImage(sessionId, { data }).data;
|
||||
db.prepare('UPDATE classroom_strokes SET data=? WHERE id=?').run(JSON.stringify(finalData), strokeId);
|
||||
emitToSession(sessionId, { type: 'classroom_stroke_updated', sessionId, strokeId, pageNum: existing.page_num, data: finalData });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), {
|
||||
app.use('/uploads/flashcards', express.static(path.join(__dirname, '../uploads/flashcards'), { maxAge: '7d' }));
|
||||
app.use('/uploads/generated', express.static(path.join(__dirname, '../uploads/generated'), { maxAge: '7d' }));
|
||||
app.use('/uploads/materials', express.static(path.join(__dirname, '../uploads/materials'), { maxAge: '7d' }));
|
||||
app.use('/uploads/classroom', express.static(path.join(__dirname, '../uploads/classroom'), { maxAge: '7d' }));
|
||||
|
||||
// Redirect legacy .html URLs → clean URLs (301)
|
||||
app.use((req, res, next) => {
|
||||
@@ -526,6 +527,9 @@ const server = app.listen(PORT, () => logger.info(`Server running on port ${PORT
|
||||
/* ── WebSocket server for low-latency classroom events (cursor + preview) ── */
|
||||
require('./ws-server').attach(server);
|
||||
|
||||
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
|
||||
try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); }
|
||||
|
||||
/* ── Graceful shutdown ── */
|
||||
function shutdown(signal) {
|
||||
logger.info(`${signal} received — shutting down gracefully`);
|
||||
|
||||
Reference in New Issue
Block a user