feat(lab-content-engine): phase 4 - каталог симуляций в БД + API + админка
- Миграция 042_lab_sims.sql: таблица lab_sims (id, cat, title, subject, grade,
sort_order, enabled, featured, tags JSON), сид 40 симуляций в порядке каталога
- backend/src/routes/lab.js: GET /api/lab/sims (мёрж БД + legacy-флаги, auth),
PATCH /api/lab/sims/:id (admin), POST /api/lab/sims/reorder (admin).
enabled зеркалится в legacy sim_disabled_ids -> lab.html без правок фронта
- server.js: монтирование /api/lab
- tests/lab-sims.test.js: 11 тестов (auth/роли/вкл-выкл+зеркало/featured/tags/
валидация/reorder/404), все проходят; +0 к baseline (3 pre-existing)
- admin/sections/sims.js: убран захардкоженный ADMIN_SIMS, каталог из /api/lab/sims,
тумблеры вкл-выкл и «рекомендуемая»; XSS-эскейп, иконки .ic
- plans/: Фаза 4 done + handoff
Независимое ревью: PASS, блокеров нет. route-auth lint: PATCH-роут защищён inline
requireRole('admin'). Миграция применена к живой БД.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4.
|
||||
-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги,
|
||||
-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для
|
||||
-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend).
|
||||
--
|
||||
-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids
|
||||
-- на уровне API, поэтому существующая логика lab.html не ломается.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_sims (
|
||||
id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...)
|
||||
cat TEXT NOT NULL, -- math | phys | chem | bio | game
|
||||
title TEXT NOT NULL,
|
||||
subject TEXT, -- курикулум (Фаза 5), напр. 'physics'
|
||||
grade INTEGER, -- класс (Фаза 5)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге
|
||||
featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая»
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order);
|
||||
|
||||
-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS).
|
||||
INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES
|
||||
('graph', 'math', 'График функции', 1),
|
||||
('graphtransform', 'math', 'Трансформации графиков', 2),
|
||||
('geometry', 'math', 'Планиметрия', 3),
|
||||
('triangle', 'math', 'Геометрия треугольника', 4),
|
||||
('quadratic', 'math', 'Корни квадратного уравнения', 5),
|
||||
('stereo', 'math', 'Стереометрия 3D', 6),
|
||||
('probability', 'math', 'Теория вероятностей', 7),
|
||||
('trigcircle', 'math', 'Тригонометрическая окружность', 8),
|
||||
('normaldist', 'math', 'Нормальное распределение', 9),
|
||||
('projectile', 'phys', 'Бросок тела', 10),
|
||||
('pendulum', 'phys', 'Маятник', 11),
|
||||
('collision', 'phys', 'Столкновение шаров', 12),
|
||||
('emfield', 'phys', 'Электромагнитные поля', 13),
|
||||
('circuit', 'phys', 'Электрические цепи', 14),
|
||||
('hydrostatics', 'phys', 'Гидростатика', 15),
|
||||
('dynamics', 'phys', 'Динамика', 16),
|
||||
('opticsbench', 'phys', 'Оптическая скамья', 17),
|
||||
('isoprocess', 'phys', 'Изопроцессы', 18),
|
||||
('waves', 'phys', 'Волны и звук', 19),
|
||||
('radioactive', 'phys', 'Радиоактивный распад', 20),
|
||||
('race', 'phys', 'Гонка с задачами', 21),
|
||||
('heatengine', 'phys', 'Тепловые двигатели', 22),
|
||||
('logic', 'phys', 'Логические схемы', 23),
|
||||
('molphys', 'chem', 'Молекулярная физика', 24),
|
||||
('chemistry', 'chem', 'Химические реакции', 25),
|
||||
('equilibrium', 'chem', 'Химическое равновесие', 26),
|
||||
('electrolysis', 'chem', 'Электролиз', 27),
|
||||
('bohratom', 'chem', 'Атом Бора', 28),
|
||||
('orbitals', 'chem', 'Молекулярные орбитали', 29),
|
||||
('titration', 'chem', 'pH и кривая титрования', 30),
|
||||
('chemsandbox', 'chem', 'Химическая песочница', 31),
|
||||
('stoichiometry', 'chem', 'Стехиометрия', 32),
|
||||
('crystal', 'chem', 'Кристаллическая решётка', 33),
|
||||
('qualanalysis', 'chem', 'Качественный анализ', 34),
|
||||
('periodic', 'chem', 'Периодическая таблица', 35),
|
||||
('organic', 'chem', 'Органическая химия', 36),
|
||||
('solutions', 'chem', 'Растворы', 37),
|
||||
('celldivision', 'bio', 'Деление клетки', 38),
|
||||
('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39),
|
||||
('angrybirds', 'game', 'Angry Birds Physics', 40);
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фаза 4).
|
||||
*
|
||||
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги модуля.
|
||||
* Чтение: любой авторизованный пользователь.
|
||||
* PATCH /api/lab/sims/:id — изменить enabled/featured/tags/subject/grade. admin.
|
||||
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
|
||||
*
|
||||
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
|
||||
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
|
||||
* корректно скрывать отключённые симуляции без правок фронта. */
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
|
||||
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* ── helpers ───────────────────────────────────────────────────────────── */
|
||||
function readModuleDisabled() {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get();
|
||||
return row ? row.value === '1' : false;
|
||||
}
|
||||
function readLegacyDisabledIds() {
|
||||
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get();
|
||||
try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); }
|
||||
}
|
||||
function writeLegacyDisabledIds(set) {
|
||||
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||||
.run(JSON.stringify([...set]));
|
||||
}
|
||||
function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } }
|
||||
|
||||
function rowToSim(r) {
|
||||
return {
|
||||
id: r.id, cat: r.cat, title: r.title,
|
||||
subject: r.subject || null, grade: r.grade != null ? r.grade : null,
|
||||
sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured,
|
||||
tags: parseTags(r.tags),
|
||||
};
|
||||
}
|
||||
|
||||
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
|
||||
router.get('/sims', (_req, res) => {
|
||||
const rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
|
||||
const legacyDisabled = readLegacyDisabledIds();
|
||||
const sims = rows.map(r => {
|
||||
const s = rowToSim(r);
|
||||
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
|
||||
s.enabled = s.enabled && !legacyDisabled.has(r.id);
|
||||
return s;
|
||||
});
|
||||
res.json({ module_disabled: readModuleDisabled(), sims });
|
||||
});
|
||||
|
||||
/* ── admin mutations ───────────────────────────────────────────────────── */
|
||||
router.use(requireRole('admin'));
|
||||
|
||||
/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */
|
||||
router.patch('/sims/:id', requireRole('admin'), (req, res) => {
|
||||
const id = String(req.params.id || '');
|
||||
const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
|
||||
if (!row) return res.status(404).json({ error: 'симуляция не найдена' });
|
||||
|
||||
const b = req.body || {};
|
||||
const sets = [];
|
||||
const vals = [];
|
||||
|
||||
if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); }
|
||||
if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); }
|
||||
if (b.title !== undefined) {
|
||||
const t = String(b.title).trim();
|
||||
if (!t) return res.status(400).json({ error: 'пустой title' });
|
||||
sets.push('title = ?'); vals.push(t);
|
||||
}
|
||||
if (b.cat !== undefined) {
|
||||
if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' });
|
||||
sets.push('cat = ?'); vals.push(b.cat);
|
||||
}
|
||||
if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); }
|
||||
if (b.grade !== undefined) {
|
||||
const g = b.grade === null || b.grade === '' ? null : Number(b.grade);
|
||||
if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) {
|
||||
return res.status(400).json({ error: 'grade должен быть 1..11 или null' });
|
||||
}
|
||||
sets.push('grade = ?'); vals.push(g);
|
||||
}
|
||||
if (b.tags !== undefined) {
|
||||
if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' });
|
||||
const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20);
|
||||
sets.push('tags = ?'); vals.push(JSON.stringify(clean));
|
||||
}
|
||||
|
||||
if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' });
|
||||
|
||||
sets.push("updated_at = datetime('now')");
|
||||
vals.push(id);
|
||||
db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
||||
|
||||
// Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html.
|
||||
if (b.enabled !== undefined) {
|
||||
const set = readLegacyDisabledIds();
|
||||
if (b.enabled) set.delete(id); else set.add(id);
|
||||
writeLegacyDisabledIds(set);
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
|
||||
res.json({ ok: true, sim: rowToSim(updated) });
|
||||
});
|
||||
|
||||
/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */
|
||||
router.post('/sims/reorder', (req, res) => {
|
||||
const order = (req.body && req.body.order) || [];
|
||||
if (!Array.isArray(order) || !order.length) {
|
||||
return res.status(400).json({ error: 'order должен быть непустым массивом id' });
|
||||
}
|
||||
const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id));
|
||||
for (const id of order) {
|
||||
if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id });
|
||||
}
|
||||
const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?");
|
||||
db.transaction(() => {
|
||||
order.forEach((id, i) => upd.run(i + 1, id));
|
||||
})();
|
||||
res.json({ ok: true, count: order.length });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep');
|
||||
const textbookRoutes = require('./routes/textbooks');
|
||||
const accessRoutes = require('./routes/access');
|
||||
const teacherStudentsRoutes = require('./routes/teacherStudents');
|
||||
const labRoutes = require('./routes/lab');
|
||||
|
||||
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
@@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes);
|
||||
app.use('/api/textbooks', textbookRoutes);
|
||||
app.use('/api/access', accessRoutes);
|
||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
app.use('/api/lab', labRoutes);
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
const _featDb = require('./db/db');
|
||||
|
||||
Reference in New Issue
Block a user