22c7b38e9a
Добавлено такое же действие, как [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>
142 lines
7.4 KiB
JavaScript
142 lines
7.4 KiB
JavaScript
'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 };
|