feat(admin): сброс системы «чистый запуск» в веб-панели
Добавлено такое же действие, как [Z] в control-panel: POST /api/admin/reset-system (+ /reset-system/plan для предпросмотра), только admin. Общая логика вынесена в src/services/systemReset.js (classify/pickKeptAdmin/runReset) — реюзится CLI и эндпоинтом. Веб-эндпоинт безопаснее CLI: сохраняет ТЕКУЩЕГО админа (оператор остаётся залогинен), делает бэкап БД ДО сброса (wal_checkpoint + копия в data/backups/), требует body.confirm='СБРОС'. UI — «Опасная зона» в overview-секции: предпросмотр плана + ввод «СБРОС» + результат с именем бэкапа. db.js: добавлен db._path (нужен бэкапу при сбросе). Логика проверена смоуком на копии живой БД (16 юзеров удалено, контент сохранён, REASSIGN на админа, гейм-счётчики обнулены, 0 висячих FK). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+25
-138
@@ -1,174 +1,61 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* ───────────────────────────────────────────────────────────────────────────
|
/* ───────────────────────────────────────────────────────────────────────────
|
||||||
reset-system.js — «ЧИСТЫЙ ЗАПУСК»: убрать всех пользователей и их активность,
|
reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js).
|
||||||
СОХРАНИВ весь контент, настройки/права и одного админа.
|
|
||||||
|
|
||||||
⚠️ ДЕСТРУКТИВНО. Перед запуском СДЕЛАЙТЕ БЭКАП (control-panel «Бэкап БД» делает
|
⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET.
|
||||||
это автоматически перед вызовом). По умолчанию — DRY-RUN (только показывает план).
|
Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически).
|
||||||
|
Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system).
|
||||||
Что делает (--apply --confirm=RESET):
|
|
||||||
• Сохраняет ОДНОГО админа (min id среди role='admin') — чтобы не залочиться.
|
|
||||||
• REASSIGN: авторский контент (courses/tests/flashcard_decks/custom_sims/шаблоны/
|
|
||||||
библиотека/lab-ссылки/board-шаблоны) переписывается на сохранённого админа —
|
|
||||||
иначе при удалении автора он бы каскадно удалился.
|
|
||||||
• WIPE: все аккаунты (кроме админа), классы, задания, сессии, сдачи, геймификация,
|
|
||||||
уведомления, прогресс, материалы, история классрума/викторин, доступы, логи.
|
|
||||||
• KEEP: учебники, вопросы, темы, уроки, exam-prep, симуляции, биохимия, красная
|
|
||||||
книга, магазин-товары, достижения-определения, роли/права, app_settings.
|
|
||||||
• Сбрасывает игровые счётчики у сохранённого админа (xp/coins/стрик/…).
|
|
||||||
• Неизвестные (не классифицированные) таблицы НЕ трогает + предупреждает.
|
|
||||||
|
|
||||||
Запуск:
|
Запуск:
|
||||||
node backend/scripts/reset-system.js # DRY-RUN (план)
|
node backend/scripts/reset-system.js # план
|
||||||
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
|
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
|
||||||
─────────────────────────────────────────────────────────────────────────── */
|
─────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const reset = require('../src/services/systemReset');
|
||||||
|
|
||||||
const APPLY = process.argv.includes('--apply');
|
const APPLY = process.argv.includes('--apply');
|
||||||
const CONFIRM = process.argv.includes('--confirm=RESET');
|
const CONFIRM = process.argv.includes('--confirm=RESET');
|
||||||
|
|
||||||
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
|
|
||||||
const REASSIGN = {
|
|
||||||
courses: 'created_by', tests: 'created_by',
|
|
||||||
flashcard_decks: 'user_id', custom_sims: 'owner_id',
|
|
||||||
course_templates: 'created_by', lesson_templates: 'created_by',
|
|
||||||
assignment_templates: 'created_by', lab_sim_links: 'created_by',
|
|
||||||
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Активность/организация — полностью очищается. */
|
|
||||||
const WIPE = new Set([
|
|
||||||
'test_sessions', 'session_questions', 'user_answers',
|
|
||||||
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
|
|
||||||
'assignments', 'assignment_sessions', 'assignment_completion',
|
|
||||||
'submissions', 'submission_log',
|
|
||||||
'classes', 'class_members', 'class_courses',
|
|
||||||
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
|
|
||||||
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
|
|
||||||
'classroom_notes', 'classroom_pages', 'classroom_strokes',
|
|
||||||
'live_sessions', 'live_answers',
|
|
||||||
'content_access',
|
|
||||||
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
|
|
||||||
'notifications', 'parent_notifications', 'parent_links',
|
|
||||||
'student_materials', 'material_collections',
|
|
||||||
'game_progress',
|
|
||||||
'lesson_progress', 'lesson_comments', 'lesson_notes',
|
|
||||||
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
|
|
||||||
'flashcard_reviews', 'flashcard_deck_access',
|
|
||||||
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
|
|
||||||
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
|
|
||||||
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
|
|
||||||
'imggen_usage',
|
|
||||||
'folder_access', 'file_access',
|
|
||||||
'avatar_requests',
|
|
||||||
'geometry_submissions', 'geometry_tasks',
|
|
||||||
'security_events', 'error_log', 'admin_audit_log',
|
|
||||||
'student_prep',
|
|
||||||
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/* Контент/конфиг — НЕ трогаем (явный список для контроля «неизвестных» таблиц). */
|
|
||||||
const KEEP = new Set([
|
|
||||||
'subjects', 'questions', 'options', 'topics',
|
|
||||||
'textbooks', 'textbook_chunks',
|
|
||||||
'lessons', 'lesson_blocks', 'course_sections',
|
|
||||||
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
|
|
||||||
'test_questions', 'flashcard_cards', 'lab_sims',
|
|
||||||
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
|
|
||||||
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
|
|
||||||
'shop_items', 'achievements',
|
|
||||||
'roles', 'role_permissions', 'app_settings', '_migrations',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/* Игровые счётчики сохранённого админа — обнуляем (чистый старт). */
|
|
||||||
const ADMIN_RESET_SQL =
|
|
||||||
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
|
|
||||||
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
|
|
||||||
pet_petting_streak = 0 WHERE id = ?`;
|
|
||||||
|
|
||||||
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
|
||||||
const db = new DatabaseSync(DB);
|
const db = new DatabaseSync(DB);
|
||||||
|
|
||||||
/* Сохраняемый админ — наименьший id среди админов. */
|
const keptAdmin = reset.pickKeptAdmin(db);
|
||||||
const keptAdmin = db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
|
||||||
if (!keptAdmin) {
|
if (!keptAdmin) {
|
||||||
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
|
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
|
||||||
db.close();
|
db.close(); process.exit(1);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTables = db.prepare(
|
const plan = reset.classify(db);
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`);
|
||||||
).all().map(r => r.name);
|
|
||||||
|
|
||||||
const cnt = t => { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return '?'; } };
|
|
||||||
|
|
||||||
/* Классификация + предупреждение о неизвестных. */
|
|
||||||
const unknown = allTables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
|
|
||||||
|
|
||||||
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'APPLY без --confirm=RESET → отказ') : 'DRY-RUN'}) ===`);
|
|
||||||
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
|
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
|
||||||
const totalUsers = cnt('users');
|
console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`);
|
||||||
console.log(`Пользователей сейчас: ${totalUsers} → останется 1 (админ), удалится ${totalUsers - 1}\n`);
|
|
||||||
|
|
||||||
console.log('REASSIGN (контент → админу):');
|
console.log('REASSIGN (контент → админу):');
|
||||||
for (const [t, col] of Object.entries(REASSIGN)) console.log(` ${t.padEnd(22)} ${col.padEnd(12)} строк: ${cnt(t)}`);
|
plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`));
|
||||||
console.log('\nWIPE (полная очистка):');
|
console.log('\nWIPE (полная очистка):');
|
||||||
let wipeTotal = 0;
|
plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`));
|
||||||
for (const t of WIPE) { const c = cnt(t); if (typeof c === 'number') wipeTotal += c; console.log(` ${t.padEnd(28)} строк: ${c}`); }
|
console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`);
|
||||||
console.log(` — всего строк к удалению (без каскада users): ~${wipeTotal}`);
|
console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`);
|
||||||
console.log(`\nKEEP (контент/конфиг сохраняется): ${KEEP.size} таблиц.`);
|
if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`);
|
||||||
if (unknown.length) {
|
|
||||||
console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем — проверьте вручную): ${unknown.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!APPLY) {
|
if (!APPLY) {
|
||||||
console.log('\nDRY-RUN: ничего не изменено. Для выполнения: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
|
console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
|
||||||
db.close();
|
db.close(); process.exit(0);
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
if (!CONFIRM) {
|
if (!CONFIRM) {
|
||||||
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
|
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
|
||||||
db.close();
|
db.close(); process.exit(1);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Выполнение ── */
|
|
||||||
db.exec('PRAGMA foreign_keys = OFF'); // снимаем каскады — управляем удалением вручную, детерминированно
|
|
||||||
db.exec('BEGIN');
|
|
||||||
try {
|
try {
|
||||||
// 1) Переназначить контент на сохранённого админа
|
const res = reset.runReset(db, keptAdmin.id);
|
||||||
for (const [t, col] of Object.entries(REASSIGN)) {
|
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`);
|
||||||
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdmin.id, keptAdmin.id); }
|
console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`);
|
||||||
catch (e) { console.error(` ! reassign ${t}: ${e.message}`); }
|
if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`);
|
||||||
}
|
console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`);
|
||||||
// 2) Очистить активность/организацию
|
|
||||||
for (const t of WIPE) {
|
|
||||||
try { db.prepare(`DELETE FROM "${t}"`).run(); }
|
|
||||||
catch (e) { console.error(` ! wipe ${t}: ${e.message}`); }
|
|
||||||
}
|
|
||||||
// 3) Удалить всех пользователей, кроме сохранённого админа
|
|
||||||
db.prepare('DELETE FROM users WHERE id != ?').run(keptAdmin.id);
|
|
||||||
// 4) Обнулить игровые счётчики у админа
|
|
||||||
db.prepare(ADMIN_RESET_SQL).run(keptAdmin.id);
|
|
||||||
db.exec('COMMIT');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
db.exec('ROLLBACK');
|
|
||||||
db.exec('PRAGMA foreign_keys = ON');
|
|
||||||
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
|
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
|
||||||
db.close();
|
db.close(); process.exit(1);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
db.exec('PRAGMA foreign_keys = ON');
|
|
||||||
|
|
||||||
/* Проверка целостности + сжатие файла. */
|
|
||||||
const fkBad = db.prepare('PRAGMA foreign_key_check').all();
|
|
||||||
if (fkBad.length) console.log(`⚠️ foreign_key_check: ${fkBad.length} висячих ссылок (в неизвестных таблицах?) — проверьте.`);
|
|
||||||
try { db.exec('VACUUM'); } catch {}
|
|
||||||
|
|
||||||
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Осталось пользователей: ${cnt('users')} (только админ id=${keptAdmin.id}).`);
|
|
||||||
console.log(`✓ Контент сохранён: учебники ${cnt('textbooks')}, вопросы ${cnt('questions')}, тесты ${cnt('tests')}, курсы ${cnt('courses')}, exam-prep ${cnt('exam_tasks')}.`);
|
|
||||||
console.log(`\nВойдите под админом ${keptAdmin.email}. Не забудьте перезапустить сервер.\n`);
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
const { audit } = require('../utils/audit');
|
const { audit } = require('../utils/audit');
|
||||||
const { purgeAccessFor } = require('../services/contentAccess');
|
const { purgeAccessFor } = require('../services/contentAccess');
|
||||||
|
const sysReset = require('../services/systemReset');
|
||||||
|
|
||||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||||
const stmts = {
|
const stmts = {
|
||||||
@@ -586,6 +589,56 @@ function updateFreeStudentFeatures(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
|
||||||
|
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
|
||||||
|
function getResetPlan(req, res) {
|
||||||
|
try {
|
||||||
|
const plan = sysReset.classify(db);
|
||||||
|
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
|
||||||
|
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/admin/reset-system ──────────────────────────────────────
|
||||||
|
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
|
||||||
|
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
|
||||||
|
стирает остальных пользователей + активность, переназначает контент. */
|
||||||
|
function resetSystem(req, res) {
|
||||||
|
const confirm = (req.body && req.body.confirm) || '';
|
||||||
|
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
|
||||||
|
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
|
||||||
|
}
|
||||||
|
const keptId = req.user.id;
|
||||||
|
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
|
||||||
|
let backupName = null;
|
||||||
|
try {
|
||||||
|
const dbPath = db._path;
|
||||||
|
if (!dbPath) throw new Error('путь к БД неизвестен');
|
||||||
|
const backupsDir = path.join(path.dirname(dbPath), 'backups');
|
||||||
|
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
||||||
|
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
|
||||||
|
const d = new Date();
|
||||||
|
const p2 = n => String(n).padStart(2, '0');
|
||||||
|
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
|
||||||
|
backupName = `learnspace-prereset-${ts}.db`;
|
||||||
|
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
|
||||||
|
}
|
||||||
|
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
|
||||||
|
let summary;
|
||||||
|
try {
|
||||||
|
summary = sysReset.runReset(db, keptId);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
|
||||||
|
}
|
||||||
|
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
|
||||||
|
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
|
||||||
|
res.json({ ok: true, backup: backupName, ...summary });
|
||||||
|
}
|
||||||
|
|
||||||
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
|
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */
|
||||||
function getAuditLog(req, res) {
|
function getAuditLog(req, res) {
|
||||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
||||||
@@ -659,8 +712,6 @@ function clearSecurityLog(req, res) {
|
|||||||
|
|
||||||
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||||
const sse = require('../sse');
|
const sse = require('../sse');
|
||||||
@@ -1157,6 +1208,7 @@ module.exports = {
|
|||||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||||
|
getResetPlan, resetSystem,
|
||||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||||
getSecurityLog, clearSecurityLog,
|
getSecurityLog, clearSecurityLog,
|
||||||
getTopics, createTopic, updateTopic, deleteTopic,
|
getTopics, createTopic, updateTopic, deleteTopic,
|
||||||
|
|||||||
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
|
|||||||
/* Everything below is admin-only */
|
/* Everything below is admin-only */
|
||||||
router.use(requireRole('admin'));
|
router.use(requireRole('admin'));
|
||||||
|
|
||||||
|
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
|
||||||
|
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
|
||||||
|
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
|
||||||
|
|
||||||
router.get('/assistant', ctrl.getAssistant);
|
router.get('/assistant', ctrl.getAssistant);
|
||||||
router.put('/assistant', ctrl.saveAssistant);
|
router.put('/assistant', ctrl.saveAssistant);
|
||||||
router.post('/assistant/test', ctrl.testAssistant);
|
router.post('/assistant/test', ctrl.testAssistant);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use strict';
|
||||||
|
/* ───────────────────────────────────────────────────────────────────────────
|
||||||
|
systemReset.js — общая логика «чистого запуска» (используют и CLI
|
||||||
|
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
|
||||||
|
|
||||||
|
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
|
||||||
|
|
||||||
|
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
|
||||||
|
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
|
||||||
|
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
|
||||||
|
─────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
|
||||||
|
const REASSIGN = {
|
||||||
|
courses: 'created_by', tests: 'created_by',
|
||||||
|
flashcard_decks: 'user_id', custom_sims: 'owner_id',
|
||||||
|
course_templates: 'created_by', lesson_templates: 'created_by',
|
||||||
|
assignment_templates: 'created_by', lab_sim_links: 'created_by',
|
||||||
|
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Активность/организация — полностью очищается. */
|
||||||
|
const WIPE = new Set([
|
||||||
|
'test_sessions', 'session_questions', 'user_answers',
|
||||||
|
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
|
||||||
|
'assignments', 'assignment_sessions', 'assignment_completion',
|
||||||
|
'submissions', 'submission_log',
|
||||||
|
'classes', 'class_members', 'class_courses',
|
||||||
|
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
|
||||||
|
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
|
||||||
|
'classroom_notes', 'classroom_pages', 'classroom_strokes',
|
||||||
|
'live_sessions', 'live_answers',
|
||||||
|
'content_access',
|
||||||
|
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
|
||||||
|
'notifications', 'parent_notifications', 'parent_links',
|
||||||
|
'student_materials', 'material_collections',
|
||||||
|
'game_progress',
|
||||||
|
'lesson_progress', 'lesson_comments', 'lesson_notes',
|
||||||
|
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
|
||||||
|
'flashcard_reviews', 'flashcard_deck_access',
|
||||||
|
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
|
||||||
|
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
|
||||||
|
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
|
||||||
|
'imggen_usage',
|
||||||
|
'folder_access', 'file_access',
|
||||||
|
'avatar_requests',
|
||||||
|
'geometry_submissions', 'geometry_tasks',
|
||||||
|
'security_events', 'error_log', 'admin_audit_log',
|
||||||
|
'student_prep',
|
||||||
|
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
|
||||||
|
const KEEP = new Set([
|
||||||
|
'subjects', 'questions', 'options', 'topics',
|
||||||
|
'textbooks', 'textbook_chunks',
|
||||||
|
'lessons', 'lesson_blocks', 'course_sections',
|
||||||
|
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
|
||||||
|
'test_questions', 'flashcard_cards', 'lab_sims',
|
||||||
|
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
|
||||||
|
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
|
||||||
|
'shop_items', 'achievements',
|
||||||
|
'roles', 'role_permissions', 'app_settings', '_migrations',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ADMIN_RESET_SQL =
|
||||||
|
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
|
||||||
|
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
|
||||||
|
pet_petting_streak = 0 WHERE id = ?`;
|
||||||
|
|
||||||
|
function allTables(db) {
|
||||||
|
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||||
|
.all().map(r => r.name);
|
||||||
|
}
|
||||||
|
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
|
||||||
|
|
||||||
|
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
|
||||||
|
function pickKeptAdmin(db) {
|
||||||
|
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
|
||||||
|
function classify(db) {
|
||||||
|
const tables = allTables(db);
|
||||||
|
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
|
||||||
|
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
|
||||||
|
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
|
||||||
|
const totalUsers = rowCount(db, 'users');
|
||||||
|
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
|
||||||
|
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
|
||||||
|
* которого сохраняем (ему переназначается контент). Возвращает сводку.
|
||||||
|
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
|
||||||
|
*/
|
||||||
|
function runReset(db, keptAdminId) {
|
||||||
|
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
|
||||||
|
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
|
||||||
|
|
||||||
|
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const [t, col] of Object.entries(REASSIGN)) {
|
||||||
|
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
|
||||||
|
}
|
||||||
|
for (const t of WIPE) {
|
||||||
|
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
|
||||||
|
}
|
||||||
|
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
|
||||||
|
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
var deletedUsers = del.changes;
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
let fkBad = 0;
|
||||||
|
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
|
||||||
|
try { db.exec('VACUUM'); } catch {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
keptAdminId,
|
||||||
|
deletedUsers,
|
||||||
|
remainingUsers: rowCount(db, 'users'),
|
||||||
|
fkDangling: fkBad,
|
||||||
|
kept: {
|
||||||
|
textbooks: rowCount(db, 'textbooks'),
|
||||||
|
questions: rowCount(db, 'questions'),
|
||||||
|
tests: rowCount(db, 'tests'),
|
||||||
|
courses: rowCount(db, 'courses'),
|
||||||
|
exam_tasks: rowCount(db, 'exam_tasks'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
|
||||||
@@ -452,6 +452,24 @@
|
|||||||
<i data-lucide="file-text"></i> Audit log
|
<i data-lucide="file-text"></i> Audit log
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
|
||||||
|
<div class="ov-card danger" style="padding-bottom:16px">
|
||||||
|
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
|
||||||
|
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
|
||||||
|
Сброс системы «чистый запуск»
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
|
||||||
|
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
|
||||||
|
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются — авторский контент
|
||||||
|
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
|
||||||
|
Действие необратимо.
|
||||||
|
</div>
|
||||||
|
<button class="ov-quick-btn" id="ov-reset-system-btn"
|
||||||
|
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
|
||||||
|
<i data-lucide="trash-2"></i> Сбросить систему…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/* ── wire quick-links via event delegation ───────────────── */
|
/* ── wire quick-links via event delegation ───────────────── */
|
||||||
@@ -459,9 +477,119 @@
|
|||||||
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
|
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetBtn = el.querySelector('#ov-reset-system-btn');
|
||||||
|
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
if (window.lucide) lucide.createIcons({ nodes: [el] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
|
||||||
|
async function openResetModal() {
|
||||||
|
const e = LS.esc;
|
||||||
|
const m = LS.modal({
|
||||||
|
title: 'Сброс системы — чистый запуск',
|
||||||
|
size: 'md',
|
||||||
|
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
|
||||||
|
actions: [{ label: 'Отмена' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
let plan;
|
||||||
|
try {
|
||||||
|
plan = await LS.api('/api/admin/reset-system/plan');
|
||||||
|
} catch (err) {
|
||||||
|
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = plan.keptAdmin || {};
|
||||||
|
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
|
||||||
|
const wipeRows = plan.wipeRows || 0;
|
||||||
|
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
|
||||||
|
return a + (typeof r.rows === 'number' ? r.rows : 0);
|
||||||
|
}, 0);
|
||||||
|
const unknownNote = (plan.unknown && plan.unknown.length)
|
||||||
|
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
|
||||||
|
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
|
||||||
|
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
m.setBody(
|
||||||
|
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
|
||||||
|
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
|
||||||
|
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
|
||||||
|
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
|
||||||
|
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
|
||||||
|
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
|
||||||
|
' <span style="color:#56687A">(вы)</span></div>' +
|
||||||
|
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
|
||||||
|
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
|
||||||
|
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
|
||||||
|
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
|
||||||
|
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
|
||||||
|
'</ul>' +
|
||||||
|
unknownNote +
|
||||||
|
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
|
||||||
|
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
|
||||||
|
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
|
||||||
|
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const inp = m.body.querySelector('#ov-reset-confirm-inp');
|
||||||
|
function syncBtn() {
|
||||||
|
const ok = inp && inp.value.trim() === 'СБРОС';
|
||||||
|
const btn = document.getElementById('ov-reset-go');
|
||||||
|
if (btn) btn.disabled = !ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReadyActions() {
|
||||||
|
m.setActions([
|
||||||
|
{ label: 'Отмена' },
|
||||||
|
{
|
||||||
|
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
|
||||||
|
onClick: doReset,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const btn = document.getElementById('ov-reset-go');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doReset() {
|
||||||
|
const btn = document.getElementById('ov-reset-go');
|
||||||
|
if (!inp || inp.value.trim() !== 'СБРОС') return;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
|
||||||
|
m.setError('');
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
|
||||||
|
} catch (err) {
|
||||||
|
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m.setBody(
|
||||||
|
'<div style="text-align:center;padding:14px 0">' +
|
||||||
|
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
|
||||||
|
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
|
||||||
|
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
|
||||||
|
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
|
||||||
|
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
|
||||||
|
(res.remainingUsers || 1) + '</strong>.<br>' +
|
||||||
|
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
|
||||||
|
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
|
||||||
|
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setReadyActions();
|
||||||
|
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const el = document.getElementById('overview-content');
|
const el = document.getElementById('overview-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user