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:
@@ -1,7 +1,10 @@
|
||||
const db = require('../db/db');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { audit } = require('../utils/audit');
|
||||
const { purgeAccessFor } = require('../services/contentAccess');
|
||||
const sysReset = require('../services/systemReset');
|
||||
|
||||
/* ── Prepared statements ──────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
@@ -586,6 +589,56 @@ function updateFreeStudentFeatures(req, res) {
|
||||
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 ───────────────────────────────────────── */
|
||||
function getAuditLog(req, res) {
|
||||
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 ─────────────────────────────────────────── */
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { monitorEventLoopDelay } = require('perf_hooks');
|
||||
const sse = require('../sse');
|
||||
@@ -1157,6 +1208,7 @@ module.exports = {
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getResetPlan, resetSystem,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
|
||||
Reference in New Issue
Block a user