From 5d3db90b5d70d49841634a1e216aebe0679bd590 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 12 Jun 2026 13:02:26 +0300 Subject: [PATCH] =?UTF-8?q?perf(classroom):=20=D0=B8=D0=BD=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BE=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B8,=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=B2=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,?= =?UTF-8?q?=20=D1=80=D0=B5=D1=82=D0=B5=D0=BD=D1=88=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- backend/src/classroom-cleanup.js | 65 ++++++++++++++++++++ backend/src/controllers/classroom/strokes.js | 46 +++++++++++++- backend/src/server.js | 4 ++ frontend/classroom.html | 21 +++++-- 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 backend/src/classroom-cleanup.js diff --git a/backend/src/classroom-cleanup.js b/backend/src/classroom-cleanup.js new file mode 100644 index 0000000..ca22887 --- /dev/null +++ b/backend/src/classroom-cleanup.js @@ -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) {} } + + // Файлы названы '-.' — удаляем по префиксу сессии. + 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 }; diff --git a/backend/src/controllers/classroom/strokes.js b/backend/src/controllers/classroom/strokes.js index 4c3be11..2d47282 100644 --- a/backend/src/controllers/classroom/strokes.js +++ b/backend/src/controllers/classroom/strokes.js @@ -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 поллинга. +// Имя '-.' — чтобы чистка по завершённой сессии находила файлы по префиксу. +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 }); } diff --git a/backend/src/server.js b/backend/src/server.js index b17c2a5..689fd6c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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`); diff --git a/frontend/classroom.html b/frontend/classroom.html index e22edf3..338d4ff 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -4550,8 +4550,17 @@ } } - /* Student-side full-sync poll: replaces board state every 2s. - Handles missed SSE events for strokes AND clears — no since_seq tricks needed. */ + /* Кол-во подтверждённых сервером штрихов на доске: у них положительный id + (локальные, ещё не отправленные — отрицательный). */ + function _wbLoadedServerCount() { + try { return (_wb && _wb._strokes) ? _wb._strokes.filter(s => s.id > 0).length : 0; } + catch (e) { return 0; } + } + + /* Student-side poll (2s, страховка к WS/SSE). Сначала тянет лёгкую сигнатуру + страницы (maxSeq + count); если доска уже совпадает с сервером (обычный случай + при живом WS) — НИЧЕГО не грузит. Полная перезагрузка — только при расхождении + (пропущенные при обрыве события, клиры, удаления). */ function wbStartPoll() { wbStopPoll(); _strokePollTimer = setInterval(async () => { @@ -4559,14 +4568,16 @@ const page = _wbCurrentPage; const gen = _wbClearGen; try { + const sig = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}&meta=1`); + if (_wbClearGen !== gen || _wbCurrentPage !== page) return; + // Доска идентична серверу → пропускаем тяжёлую перезагрузку + if (sig && sig.maxSeq === _wbMaxSeq && sig.count === _wbLoadedServerCount()) return; + // Расхождение → полная перезагрузка (проверенный путь: корректно отражает клиры/undo/пропуски) const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`); - // Discard if board was cleared or page switched while fetching if (_wbClearGen !== gen || _wbCurrentPage !== page) return; const strokes = res.strokes || []; - // Update seq cursor so SSE dedup still works _wbMaxSeq = 0; _wbUpdateMaxSeq(strokes); - // Full replace — correctly reflects clears, undos, and any missed events _wb.loadStrokes(strokes); if (res.template) _wb.setTemplate(res.template); wbUpdateThumbnail(page);