From 205290139d915f2fbc704cf47882026a1551fc35 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 23 Jun 2026 11:32:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(control-panel):=20=D1=81=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20=C2=AB?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=C2=BB=20(=D1=81=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт [Z] в control-panel.ps1: предпросмотр → бэкап БД → подтверждение вводом «СБРОС» → очистка. Скрипт backend/scripts/reset-system.js (dry-run по умолчанию, выполнение только с --apply --confirm=RESET): • сохраняет одного админа (min id), переназначает ему авторский контент (courses/tests/flashcard_decks/custom_sims/шаблоны/библиотека/lab-ссылки/board); • стирает всех остальных пользователей + классы/задания/сессии/геймификацию/ уведомления/прогресс/историю классрума/доступы/логи; • сохраняет контент: учебники, вопросы, темы, уроки, exam-prep, симуляции, биохимия, красная книга, магазин/достижения-определения, роли/права, app_settings; • обнуляет игровые счётчики админа; классифицирует ВСЕ 119 таблиц, неизвестные не трогает; • FK off + транзакция + foreign_key_check + VACUUM. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/reset-system.js | 174 ++++++++++++++++++++++++++++++++ tools/control-panel.ps1 | 26 +++++ 2 files changed, 200 insertions(+) create mode 100644 backend/scripts/reset-system.js diff --git a/backend/scripts/reset-system.js b/backend/scripts/reset-system.js new file mode 100644 index 0000000..8d6600f --- /dev/null +++ b/backend/scripts/reset-system.js @@ -0,0 +1,174 @@ +'use strict'; +/* ─────────────────────────────────────────────────────────────────────────── + reset-system.js — «ЧИСТЫЙ ЗАПУСК»: убрать всех пользователей и их активность, + СОХРАНИВ весь контент, настройки/права и одного админа. + + ⚠️ ДЕСТРУКТИВНО. Перед запуском СДЕЛАЙТЕ БЭКАП (control-panel «Бэкап БД» делает + это автоматически перед вызовом). По умолчанию — DRY-RUN (только показывает план). + + Что делает (--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 --apply --confirm=RESET # выполнить + ─────────────────────────────────────────────────────────────────────────── */ + +const { DatabaseSync } = require('node:sqlite'); +const path = require('path'); + +const APPLY = process.argv.includes('--apply'); +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 = new DatabaseSync(DB); + +/* Сохраняемый админ — наименьший id среди админов. */ +const keptAdmin = db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); +if (!keptAdmin) { + console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.'); + db.close(); + process.exit(1); +} + +const allTables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" +).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}»`); +const totalUsers = cnt('users'); +console.log(`Пользователей сейчас: ${totalUsers} → останется 1 (админ), удалится ${totalUsers - 1}\n`); + +console.log('REASSIGN (контент → админу):'); +for (const [t, col] of Object.entries(REASSIGN)) console.log(` ${t.padEnd(22)} ${col.padEnd(12)} строк: ${cnt(t)}`); +console.log('\nWIPE (полная очистка):'); +let wipeTotal = 0; +for (const t of WIPE) { const c = cnt(t); if (typeof c === 'number') wipeTotal += c; console.log(` ${t.padEnd(28)} строк: ${c}`); } +console.log(` — всего строк к удалению (без каскада users): ~${wipeTotal}`); +console.log(`\nKEEP (контент/конфиг сохраняется): ${KEEP.size} таблиц.`); +if (unknown.length) { + console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем — проверьте вручную): ${unknown.join(', ')}`); +} + +if (!APPLY) { + console.log('\nDRY-RUN: ничего не изменено. Для выполнения: node backend/scripts/reset-system.js --apply --confirm=RESET\n'); + db.close(); + process.exit(0); +} +if (!CONFIRM) { + console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.'); + db.close(); + process.exit(1); +} + +/* ── Выполнение ── */ +db.exec('PRAGMA foreign_keys = OFF'); // снимаем каскады — управляем удалением вручную, детерминированно +db.exec('BEGIN'); +try { + // 1) Переназначить контент на сохранённого админа + for (const [t, col] of Object.entries(REASSIGN)) { + try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdmin.id, keptAdmin.id); } + catch (e) { console.error(` ! reassign ${t}: ${e.message}`); } + } + // 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) { + db.exec('ROLLBACK'); + db.exec('PRAGMA foreign_keys = ON'); + console.error('\n✗ Ошибка — откат, изменений нет:', e.message); + db.close(); + 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(); diff --git a/tools/control-panel.ps1 b/tools/control-panel.ps1 index 199f298..9cc7604 100644 --- a/tools/control-panel.ps1 +++ b/tools/control-panel.ps1 @@ -183,6 +183,30 @@ function Backup-Db { Prune-Backups } +function Reset-System { + Write-Host '' + Write-Host ' ============================================================' -ForegroundColor Red + Write-Host ' ВНИМАНИЕ: ЧИСТЫЙ ЗАПУСК - НЕОБРАТИМАЯ ОЧИСТКА' -ForegroundColor Red + Write-Host ' ============================================================' -ForegroundColor Red + Write-Host ' УДАЛЯТСЯ: все пользователи (кроме одного админа), классы,' -ForegroundColor Yellow + Write-Host ' задания, сессии, геймификация, уведомления, прогресс, история.' -ForegroundColor Yellow + Write-Host ' СОХРАНЯТСЯ: учебники, вопросы, тесты, курсы, уроки, exam-prep,' -ForegroundColor Gray + Write-Host ' симуляции, настройки/права и один админ (контент переходит ему).' -ForegroundColor Gray + Write-Host '' + Write-Host ' План (предпросмотр, без изменений):' -ForegroundColor Cyan + try { & node scripts/reset-system.js | Out-Host } catch { Write-Host (' Ошибка плана: ' + $_.Exception.Message) -ForegroundColor Red; return } + Write-Host '' + $ans = (Read-Host ' Для подтверждения введите СБРОС (иначе отмена)').Trim().ToUpper() + if ($ans -ne 'СБРОС' -and $ans -ne 'RESET') { Write-Host ' Отменено.' -ForegroundColor Yellow; return } + if (Server-Proc) { Write-Host ' Сервер работает - остановите его ([2]) перед сбросом для надёжности.' -ForegroundColor Yellow } + Write-Host ' Шаг 1/2: бэкап БД...' -ForegroundColor Cyan + Backup-Db + Write-Host ' Шаг 2/2: очистка...' -ForegroundColor Cyan + try { & node scripts/reset-system.js --apply --confirm=RESET | Out-Host } + catch { Write-Host (' Ошибка сброса: ' + $_.Exception.Message) -ForegroundColor Red; return } + Write-Host ' Готово. Перезапустите сервер ([3]). Бэкап сохранён в data\backups.' -ForegroundColor Green +} + function Restore-Db { if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return } $files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) @@ -389,6 +413,7 @@ while ($run) { Menu-Row '[5]' 'Применить миграции' '[A]' 'Создать админа' Menu-Row ' ' '' '[W]' 'Сторож (авто-рестарт)' Menu-Row ' ' '' '[E]' 'Ошибки в логах' + Menu-Row ' ' '' '[Z]' 'Сброс системы (чистый запуск)' Write-Host '' Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' '' Write-Host ' ' -NoNewline @@ -417,6 +442,7 @@ while ($run) { '^(U|Г)$' { Run-Cmd 'Обновление из репозитория' { Update-FromRepo }; Refresh-Status } '^(W|Ц)$' { Watchdog; Refresh-Status } '^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } } + '^(Z|Я)$' { Reset-System; Refresh-Status; Start-Sleep 1 } '^0$' { $run = $false } default { } }