Files
Learn_System/backend/src/controllers/adminController.js
T
Maxim Dolgolyov 5b4d9324a4 feat(assistant): поддержка keyless-шлюзов + пресет Pollinations
Pollinations (text.pollinations.ai/openai, модель openai) даёт бесплатный
инференс БЕЗ ключа — проверено: 98% чистый русский. Чтобы такой провайдер
считался рабочим (раньше ключ требовался всем, кроме localhost):
- _noKeyNeeded/_aNoKey: localhost ИЛИ pollinations.ai → ключ не обязателен
  (используется в providersOrdered, pingLLM, active-check, testAssistant)
- пресет «Pollinations (без ключа)» в ASSISTANT_PRESETS
- бейдж провайдера: «без ключа» (зелёный) вместо «нет ключа» для keyless

Кейд-провайдеры (Kilo/Gemini/HF/…) по-прежнему требуют ключ — затронуты
только URL с pollinations.ai (спуф в пути отвергается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:35:56 +03:00

1327 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = {
totalUsers: db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'student'"),
totalTests: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE status = 'completed'"),
avgScore: db.prepare(
"SELECT ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg FROM test_sessions WHERE status = 'completed'"
),
bySubject: db.prepare(`
SELECT s.name, s.slug,
COUNT(ts.id) AS tests,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM test_sessions ts
JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
GROUP BY s.id ORDER BY s.id
`),
};
/* ── GET /api/admin/stats ─────────────────────────────────────────────── */
function getStats(_req, res) {
res.json({
totalUsers: stmts.totalUsers.get().n,
totalTests: stmts.totalTests.get().n,
avgScore: stmts.avgScore.get().avg,
bySubject: stmts.bySubject.all(),
});
}
/* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */
const overviewStmts = {
newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"),
newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"),
activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"),
abandonedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status = 'abandoned'"),
classesTotal: db.prepare('SELECT COUNT(*) AS n FROM classes'),
stuckSessions: db.prepare(`
SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.started_at
FROM test_sessions ts
JOIN users u ON u.id = ts.user_id
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'in_progress'
AND ts.started_at < datetime('now', '-1 hour')
ORDER BY ts.started_at
LIMIT 5
`),
// No banned_at column — fall back to audit log for recent bans (last 7 days)
bannedThisWeek: db.prepare(`
SELECT u.id, u.name, u.email, al.created_at AS banned_at
FROM admin_audit_log al
JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER)
WHERE al.action = 'user.ban'
AND al.created_at >= datetime('now', '-7 days')
AND u.is_banned = 1
GROUP BY u.id
ORDER BY al.created_at DESC
LIMIT 10
`),
topSessions24h: db.prepare(`
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
ts.score, ts.total,
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
ts.finished_at
FROM test_sessions ts
JOIN users u ON u.id = ts.user_id
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
AND ts.finished_at >= datetime('now', '-24 hours')
AND ts.total > 0
ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC
LIMIT 5
`),
worstSessions24h: db.prepare(`
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
ts.score, ts.total,
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
ts.finished_at
FROM test_sessions ts
JOIN users u ON u.id = ts.user_id
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
AND ts.finished_at >= datetime('now', '-24 hours')
AND ts.total > 0
ORDER BY (CAST(ts.score AS REAL) / ts.total) ASC, ts.finished_at DESC
LIMIT 5
`),
sparkUsers7d: db.prepare(`
SELECT date(created_at) AS d, COUNT(*) AS n FROM users
WHERE created_at >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
sparkSessions7d: db.prepare(`
SELECT date(started_at) AS d, COUNT(*) AS n FROM test_sessions
WHERE started_at >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
sparkActiveUsers7d: db.prepare(`
SELECT date(last_login) AS d, COUNT(DISTINCT id) AS n FROM users
WHERE last_login IS NOT NULL AND last_login >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
inventory: db.prepare(`
SELECT
(SELECT COUNT(*) FROM questions) AS questions,
(SELECT COUNT(*) FROM tests) AS tests,
(SELECT COUNT(*) FROM courses) AS courses,
(SELECT COUNT(*) FROM classes) AS classes
`),
sessionsBySubject24h: db.prepare(`
SELECT s.slug, s.name, COUNT(*) AS n FROM test_sessions ts
JOIN subjects s ON s.id = ts.subject_id
WHERE ts.started_at >= datetime('now', '-24 hours')
GROUP BY s.id ORDER BY n DESC
`),
};
/* ── GET /api/admin/overview ──────────────────────────────────────────── */
function getOverview(_req, res) {
try {
res.json({
newUsers24h: overviewStmts.newUsers24h.get().n,
newSessions24h: overviewStmts.newSessions24h.get().n,
activeUsers24h: overviewStmts.activeUsers24h.get().n,
classesTotal: overviewStmts.classesTotal.get().n,
abandonedSessions24h: overviewStmts.abandonedSessions24h.get().n,
stuckSessions: overviewStmts.stuckSessions.all(),
bannedThisWeek: overviewStmts.bannedThisWeek.all(),
topSessions24h: overviewStmts.topSessions24h.all(),
worstSessions24h: overviewStmts.worstSessions24h.all(),
inventory: overviewStmts.inventory.get(),
sessionsBySubject24h: overviewStmts.sessionsBySubject24h.all(),
sparks: {
users: overviewStmts.sparkUsers7d.all(),
sessions: overviewStmts.sparkSessions7d.all(),
active: overviewStmts.sparkActiveUsers7d.all(),
},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/* ── Global search (Phase 4 command palette) — prepared statements ────── */
const searchStmts = {
users: db.prepare(`
SELECT id, name, email, role
FROM users
WHERE name LIKE ? OR email LIKE ?
ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC
LIMIT 5
`),
tests: db.prepare(`
SELECT id, title AS name, subject_slug
FROM tests
WHERE title LIKE ?
ORDER BY id DESC
LIMIT 3
`),
classes: db.prepare(`
SELECT id, name, invite_code AS code
FROM classes
WHERE name LIKE ? OR invite_code LIKE ?
ORDER BY id DESC
LIMIT 3
`),
};
/* ── GET /api/admin/search?q=X ────────────────────────────────────────── */
function globalSearch(req, res) {
const q = (req.query.q || '').trim();
if (q.length < 2) {
return res.json({ users: [], tests: [], classes: [] });
}
try {
const like = `%${q}%`;
const prefix = `${q}%`;
res.json({
users: searchStmts.users.all(like, like, prefix),
tests: searchStmts.tests.all(like),
classes: searchStmts.classes.all(like, like),
});
} catch (err) {
console.error('[admin.search]', err.message);
res.status(500).json({ error: 'Search failed' });
}
}
/* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */
function getUsers(req, res) {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
const cursor = Number(req.query.cursor) || 0;
const role = req.query.role;
const search = req.query.q?.trim();
let where = 'WHERE 1=1';
const args = [];
if (role) { where += ' AND u.role = ?'; args.push(role); }
if (search) { where += ' AND (u.name LIKE ? OR u.email LIKE ?)'; args.push(`%${search}%`, `%${search}%`); }
// Cursor-based pagination
if (cursor) {
const cursorWhere = where + ' AND u.id < ?';
const cursorArgs = [...args, cursor];
const users = db.prepare(`
SELECT u.id, u.name, u.email, u.role, u.custom_role, u.created_at, u.last_login, u.is_banned,
COUNT(ts.id) AS tests_count,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM users u
LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed'
${cursorWhere}
GROUP BY u.id
ORDER BY u.id DESC
LIMIT ?
`).all(...cursorArgs, limit);
const nextCursor = users.length === limit ? users[users.length - 1].id : null;
return res.json({ users, nextCursor, limit });
}
// Offset-based (legacy)
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM users u ${where}`).get(...args);
const users = db.prepare(`
SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned,
COUNT(ts.id) AS tests_count,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM users u
LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed'
${where}
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
`).all(...args, limit, offset);
res.json({ users, total, page, limit });
}
/* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */
function updateRole(req, res) {
const { role } = req.body;
const BUILTIN = ['student', 'teacher', 'admin', 'free_student'];
// Кастомная роль: хранится как функциональная база (base_roles[0]) в users.role
// + имя в users.custom_role. Встроенная роль: role = built-in, custom_role = NULL.
let baseRole = role, customRole = null;
if (!BUILTIN.includes(role)) {
let cr = null;
try { cr = db.prepare('SELECT base_roles, is_builtin FROM roles WHERE name = ?').get(role); } catch (_e) { cr = null; }
if (!cr || cr.is_builtin) return res.status(400).json({ error: `Unknown role: ${role}` });
let bases = [];
try { bases = JSON.parse(cr.base_roles || '[]'); } catch (_e) { bases = []; }
baseRole = bases.find(b => BUILTIN.includes(b)) || 'student';
customRole = role;
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (Number(req.params.id) === req.user.id)
return res.status(400).json({ error: 'Cannot change your own role' });
const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role;
db.prepare('UPDATE users SET role = ?, custom_role = ?, token_version = token_version + 1 WHERE id = ?')
.run(baseRole, customRole, req.params.id);
audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${customRole || baseRole}`);
res.json({ id: Number(req.params.id), role: customRole || baseRole, base: baseRole });
}
/* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */
function getUserSessions(req, res) {
const user = db.prepare('SELECT id, name, email, role, is_banned FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const sessions = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.started_at DESC
LIMIT 100
`).all(req.params.id);
res.json({ user, sessions });
}
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */
function getAllSessions(req, res) {
const { subject, user_id, status } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
const offset = Math.max(0, Number(req.query.offset) || 0);
// По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
const where = [];
const params = [];
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
where.push('ts.status = ?'); params.push(status);
}
if (subject) { where.push('s.slug = ?'); params.push(subject); }
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
params.push(limit, offset);
const sessions = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(...params);
res.json(sessions);
}
/* ── GET /api/admin/sessions/:id ─────────────────────────────────────── */
function getSessionDetail(req, res) {
const session = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.name AS subject_name, s.slug AS subject_slug,
u.id AS user_id, u.name AS user_name, u.email AS user_email,
CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id
WHERE ts.id = ?
`).get(req.params.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const questions = db.prepare(`
SELECT q.id, q.text, q.explanation, q.difficulty,
ua.chosen_option_id, ua.is_correct, ua.time_spent_sec,
sq.order_index
FROM session_questions sq
JOIN questions q ON q.id = sq.question_id
LEFT JOIN user_answers ua ON ua.session_id = sq.session_id AND ua.question_id = q.id
WHERE sq.session_id = ?
ORDER BY sq.order_index
`).all(req.params.id);
if (questions.length) {
const ids = questions.map(q => q.id);
const allOptions = db.prepare(
`SELECT id, question_id, text, is_correct FROM options WHERE question_id IN (${ids.map(() => '?').join(',')}) ORDER BY order_index`
).all(...ids);
const byQ = {};
for (const o of allOptions) {
(byQ[o.question_id] ||= []).push(o);
}
session.questions = questions.map(q => ({ ...q, options: byQ[q.id] || [] }));
} else {
session.questions = [];
}
res.json(session);
}
/* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */
const _deleteSessionTx = db.transaction((sid) => {
// assignment_sessions references test_sessions with ON DELETE SET NULL,
// but we explicitly null it so the assignment slot stays usable.
db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid);
// user_answers / session_questions cascade via ON DELETE CASCADE,
// but delete explicitly for visibility and to mirror clearUserSessions().
db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid);
db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid);
db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid);
});
function deleteSession(req, res, next) {
const sid = Number(req.params.id);
if (!Number.isInteger(sid) || sid <= 0)
return res.status(400).json({ error: 'Invalid session id' });
try {
const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid);
if (!sess) return res.status(404).json({ error: 'Session not found' });
_deleteSessionTx(sid);
audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`);
res.json({ ok: true });
} catch (err) {
next(err);
}
}
/* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */
function clearUserSessions(req, res, next) {
const uid = Number(req.params.id);
try {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid);
if (!user) return res.status(404).json({ error: 'User not found' });
const sessions = db.prepare('SELECT id FROM test_sessions WHERE user_id = ?').all(uid);
const n = sessions.length;
if (n > 0) {
const stmtNullAsgn = db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?');
const stmtDelAns = db.prepare('DELETE FROM user_answers WHERE session_id = ?');
const stmtDelSQ = db.prepare('DELETE FROM session_questions WHERE session_id = ?');
const stmtDelSess = db.prepare('DELETE FROM test_sessions WHERE id = ?');
db.transaction(() => {
for (const { id } of sessions) {
stmtNullAsgn.run(id);
stmtDelAns.run(id);
stmtDelSQ.run(id);
stmtDelSess.run(id);
}
})();
}
audit(req, 'user.clear_sessions', `user:${uid}`, `${n} sessions deleted`);
res.json({ ok: true, deleted: n });
} catch (err) {
next(err);
}
}
/* ── PATCH /api/admin/users/:id ──────────────────────────────────────── */
const bcrypt = require('bcryptjs');
const { BCRYPT_ROUNDS } = require('../config');
async function updateUser(req, res, next) {
try {
const { name, email, password } = req.body;
const uid = Number(req.params.id);
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid);
if (!user) return res.status(404).json({ error: 'User not found' });
if (uid === req.user.id)
return res.status(400).json({ error: 'Cannot edit your own data here' });
if (name !== undefined && !name?.trim())
return res.status(400).json({ error: 'Name cannot be empty' });
if (email !== undefined) {
if (!email?.trim() || !/\S+@\S+\.\S+/.test(email.trim()))
return res.status(400).json({ error: 'Invalid email format' });
const existing = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.trim().toLowerCase(), uid);
if (existing) return res.status(400).json({ error: 'Email already in use' });
}
if (password !== undefined && password.length < 6)
return res.status(400).json({ error: 'Password must be at least 6 characters' });
const fields = [];
const vals = [];
if (name !== undefined) { fields.push('name = ?'); vals.push(stripTags(name.trim())); }
if (email !== undefined) { fields.push('email = ?'); vals.push(email.trim().toLowerCase()); }
if (password !== undefined) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
fields.push('password_hash = ?', 'token_version = token_version + 1');
vals.push(hash);
}
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(uid);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
const changed = [];
if (name !== undefined) changed.push('name');
if (email !== undefined) changed.push('email');
if (password !== undefined) changed.push('password');
audit(req, 'user.edit', `user:${uid}`, changed.join(', '));
res.json({ ok: true });
} catch (err) {
next(err);
}
}
/* ── PATCH /api/admin/users/:id/ban { banned: true|false } ──────────── */
function banUser(req, res) {
const uid = Number(req.params.id);
if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя заблокировать себя' });
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя заблокировать администратора' });
const banned = req.body.banned ? 1 : 0;
db.prepare('UPDATE users SET is_banned = ?, token_version = token_version + 1 WHERE id = ?').run(banned, uid);
audit(req, banned ? 'user.ban' : 'user.unban', `user:${uid}`, target.role);
res.json({ ok: true, banned: !!banned });
}
/* ── DELETE /api/admin/users/:id ─────────────────────────────────────── */
const _deleteUserTx = db.transaction((uid) => {
// Tables with REFERENCES users(id) WITHOUT ON DELETE CASCADE:
db.prepare('DELETE FROM questions WHERE created_by = ?').run(uid);
db.prepare('DELETE FROM assignments WHERE created_by = ?').run(uid);
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
// Персональные правила доступа к контенту (нет FK — единая чистка):
purgeAccessFor('student', uid);
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
function deleteUser(req, res) {
const uid = Number(req.params.id);
if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя удалить себя' });
const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя удалить администратора' });
const uname = db.prepare('SELECT name, email FROM users WHERE id = ?').get(uid);
_deleteUserTx(uid);
audit(req, 'user.delete', `user:${uid}`, `${uname?.name} (${uname?.email})`);
res.json({ ok: true });
}
/* ── GET /api/admin/features ─────────────────────────────────────────── */
function getFeatures(_req, res) {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all();
const features = {};
for (const r of rows) {
const name = r.key.replace('feature_', '').replace('_enabled', '');
features[name] = r.value === '1';
}
res.json(features);
}
/* ── PATCH /api/admin/features ──────────────────────────────────────── */
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
let touchedGamification = false;
for (const [name, enabled] of Object.entries(updates)) {
if (!allowed.includes(name)) continue;
const settingKey = `feature_${name}_enabled`;
const oldRow = getOld.get(settingKey);
const oldVal = oldRow ? oldRow.value : null;
const newVal = enabled ? '1' : '0';
stmt.run(settingKey, newVal);
audit(req, 'feature.update', `feature:${name}`, `${oldVal} -> ${newVal}`);
if (name === 'gamification') touchedGamification = true;
}
// Invalidate the gamification cache so the kill-switch takes effect
// immediately (otherwise awardXP keeps running for up to 30s).
if (touchedGamification) {
try {
const { invalidateGamificationCache } = require('./gamification/_shared');
invalidateGamificationCache();
} catch { /* defensive — shouldn't fail */ }
}
res.json({ ok: true });
}
/* ── GET /api/admin/free-student-features ────────────────────────────── */
const FREE_STUDENT_MODULES = [
'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection',
'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz',
];
function getFreeStudentFeatures(_req, res) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
let features = {};
if (row?.value) {
try { features = JSON.parse(row.value); } catch {}
}
// Default: all enabled
const result = {};
for (const m of FREE_STUDENT_MODULES) {
result[m] = features[m] !== false;
}
res.json(result);
}
/* ── PATCH /api/admin/free-student-features ─────────────────────────── */
function updateFreeStudentFeatures(req, res) {
const updates = req.body;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
let current = {};
if (row?.value) {
try { current = JSON.parse(row.value); } catch {}
}
for (const [key, val] of Object.entries(updates)) {
if (!FREE_STUDENT_MODULES.includes(key)) continue;
current[key] = Boolean(val);
}
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('free_student_features', ?)").run(JSON.stringify(current));
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));
const rows = db.prepare(`
SELECT al.*, u.name AS admin_name, u.email AS admin_email
FROM admin_audit_log al
LEFT JOIN users u ON u.id = al.admin_id
ORDER BY al.created_at DESC LIMIT ?
`).all(limit);
res.json(rows);
}
/* ── DELETE /api/admin/audit-log ────────────────────────────────────── */
function clearAuditLog(req, res) {
db.prepare('DELETE FROM admin_audit_log').run();
res.json({ ok: true });
}
/* ── GET /api/admin/error-log ──────────────────────────────────────── */
function getErrorLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const rows = db.prepare(`
SELECT * FROM error_log ORDER BY created_at DESC LIMIT ?
`).all(limit);
res.json(rows);
}
/* ── DELETE /api/admin/error-log ───────────────────────────────────── */
function clearErrorLog(req, res) {
db.prepare('DELETE FROM error_log').run();
res.json({ ok: true });
}
/* ── GET /api/admin/security-log?limit&category&q ──────────────────────
Журнал событий безопасности/входа (security_events, миграция 047).
category ∈ auth | access_denied | rate_limit; q — поиск по email/ip/route/detail/event. */
function getSecurityLog(req, res) {
const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 200));
const category = req.query.category;
const q = (req.query.q || '').trim();
const where = [], args = [];
if (['auth', 'access_denied', 'rate_limit'].includes(category)) {
where.push('se.category = ?'); args.push(category);
}
if (q) {
where.push('(se.email LIKE ? OR se.ip LIKE ? OR se.route LIKE ? OR se.detail LIKE ? OR se.event LIKE ?)');
const like = `%${q}%`;
args.push(like, like, like, like, like);
}
const rows = db.prepare(`
SELECT se.*, u.name AS user_name, u.email AS user_email
FROM security_events se
LEFT JOIN users u ON u.id = se.user_id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY se.created_at DESC LIMIT ?
`).all(...args, limit);
res.json(rows);
}
/* ── DELETE /api/admin/security-log[?days=N] ───────────────────────────
Без days — очистить весь журнал; с days=N — прунинг записей старше N дней. */
function clearSecurityLog(req, res) {
const days = Number(req.query.days);
if (Number.isInteger(days) && days > 0) {
const r = db.prepare(`DELETE FROM security_events WHERE created_at < datetime('now', ?)`).run(`-${days} days`);
return res.json({ ok: true, deleted: r.changes });
}
const r = db.prepare('DELETE FROM security_events').run();
res.json({ ok: true, deleted: r.changes });
}
/* ── GET /api/admin/health ─────────────────────────────────────────── */
const os = require('os');
const { execSync } = require('child_process');
const { monitorEventLoopDelay } = require('perf_hooks');
const sse = require('../sse');
const { DB_PATH, UPLOADS_DIR, NODE_ENV } = require('../config');
// Монитор лага event-loop (включается один раз при загрузке модуля).
const _eluMonitor = monitorEventLoopDelay({ resolution: 20 });
_eluMonitor.enable();
// Версия приложения + git-commit (кэшируются один раз).
const _appVersion = (() => { try { return require('../../package.json').version; } catch { return '?'; } })();
const _gitCommit = (() => {
try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); }
catch { return null; }
})();
function _dirSize(dir) {
let total = 0;
try { for (const f of fs.readdirSync(dir)) { try { total += fs.statSync(path.join(dir, f)).size; } catch {} } } catch {}
return total;
}
// Топ таблиц БД по числу строк (исключая служебные sqlite_* и _migrations).
function _dbTables() {
try {
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_%' ESCAPE '\\'"
).all();
const rows = tables.map(t => {
let n = 0;
try { n = db.prepare(`SELECT COUNT(*) AS n FROM "${t.name}"`).get().n; } catch {}
return { name: t.name, rows: n };
});
rows.sort((a, b) => b.rows - a.rows);
return rows.slice(0, 12);
} catch { return []; }
}
// Активные проверки: отклик БД (мс) и тест записи на диск рядом с БД.
function _runChecks() {
let dbPingMs = null, dbOk = false;
try { const t = process.hrtime.bigint(); db.prepare('SELECT 1 AS ok').get(); dbPingMs = Number(process.hrtime.bigint() - t) / 1e6; dbOk = true; } catch {}
let diskWritable = false;
try {
const f = path.join(path.dirname(path.resolve(DB_PATH)), '.health-write-test');
fs.writeFileSync(f, 'ok'); fs.unlinkSync(f); diskWritable = true;
} catch {}
return { dbPingMs, dbOk, diskWritable };
}
const _recentErrStmt = db.prepare(
"SELECT id, level, message, route, method, created_at FROM error_log ORDER BY id DESC LIMIT 8"
);
function getHealth(_req, res) {
const uptimeSec = process.uptime();
const mem = process.memoryUsage();
const dbSizeBytes = (() => { try { return fs.statSync(DB_PATH).size; } catch { return 0; } })();
const walSizeBytes = (() => { try { return fs.statSync(DB_PATH + '-wal').size; } catch { return 0; } })();
const uploadsSizeBytes = _dirSize(UPLOADS_DIR);
// Свободное место на разделе, где лежит БД.
let disk = null;
try { const s = fs.statfsSync(path.dirname(path.resolve(DB_PATH))); disk = { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize }; } catch {}
const totalMem = os.totalmem(), freeMem = os.freemem();
const memPercent = totalMem ? (totalMem - freeMem) / totalMem : 0;
const eventLoopLagMs = _eluMonitor.mean / 1e6;
const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n;
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n;
const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n;
const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n;
let sseStats = { users: 0, guests: 0, connections: 0 };
try { sseStats = sse.stats(); } catch {}
// Активные health-проверки (Level 4): отклик БД и запись на диск.
const checks = _runChecks();
const recentErrorList = (() => { try { return _recentErrStmt.all(); } catch { return []; } })();
// Вердикт здоровья по порогам.
const reasons = [];
let status = 'ok';
const warn = (m) => { reasons.push(m); if (status === 'ok') status = 'warning'; };
const crit = (m) => { reasons.push(m); status = 'critical'; };
if (memPercent > 0.92) crit(`Память ${Math.round(memPercent * 100)}%`);
else if (memPercent > 0.80) warn(`Память ${Math.round(memPercent * 100)}%`);
if (disk) {
if (disk.freeBytes < 500e6) crit('Мало места на диске (<500 МБ)');
else if (disk.freeBytes < 2e9) warn('Места на диске <2 ГБ');
}
if (recentErrors > 50) crit(`${recentErrors} ошибок за 24ч`);
else if (recentErrors > 5) warn(`${recentErrors} ошибок за 24ч`);
if (eventLoopLagMs > 200) crit(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
else if (eventLoopLagMs > 70) warn(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
if (dbSizeBytes > 1.5e9) warn('БД >1.5 ГБ');
if (!checks.dbOk) crit('БД недоступна');
if (!checks.diskWritable) crit('Диск недоступен для записи');
if (checks.dbPingMs != null && checks.dbPingMs > 100) warn(`Медленный отклик БД ${checks.dbPingMs.toFixed(0)} мс`);
res.json({
status, reasons,
checks, recentErrorList,
uptime: uptimeSec,
startedAt: new Date(Date.now() - uptimeSec * 1000).toISOString(),
memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
memPercent, eventLoopLagMs,
loadavg: os.loadavg(),
disk,
db: { sizeBytes: dbSizeBytes, walBytes: walSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, tables: _dbTables() },
uploads: { sizeBytes: uploadsSizeBytes },
sse: sseStats,
node: process.version,
platform: os.platform(),
arch: os.arch(),
cpus: os.cpus().length,
freeMem, totalMem,
pid: process.pid,
env: NODE_ENV,
version: _appVersion,
commit: _gitCommit,
recentErrors,
});
}
/* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */
const metrics = require('../utils/metrics');
function getMetrics(_req, res) {
res.json(metrics.snapshot());
}
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
function getTopics(req, res) {
const { subject_id } = req.query;
let rows;
if (subject_id) {
rows = db.prepare(`
SELECT t.*, s.name AS subject_name, s.slug AS subject_slug,
(SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count
FROM topics t JOIN subjects s ON s.id = t.subject_id
WHERE t.subject_id = ? ORDER BY t.order_index, t.name
`).all(Number(subject_id));
} else {
rows = db.prepare(`
SELECT t.*, s.name AS subject_name, s.slug AS subject_slug,
(SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count
FROM topics t JOIN subjects s ON s.id = t.subject_id
ORDER BY s.name, t.order_index, t.name
`).all();
}
res.json(rows);
}
function createTopic(req, res) {
const { subject_id, name } = req.body;
if (!subject_id || !name?.trim())
return res.status(400).json({ error: 'subject_id and name required' });
const subj = db.prepare('SELECT id FROM subjects WHERE id = ?').get(Number(subject_id));
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const maxOrder = db.prepare('SELECT MAX(order_index) AS m FROM topics WHERE subject_id = ?').get(Number(subject_id));
const order = (maxOrder?.m || 0) + 1;
const r = db.prepare('INSERT INTO topics (subject_id, name, order_index) VALUES (?, ?, ?)').run(Number(subject_id), stripTags(name.trim()), order);
audit(req, 'topic.create', `topic:${r.lastInsertRowid}`, name.trim());
res.status(201).json({ id: r.lastInsertRowid, name: name.trim(), order_index: order });
}
function updateTopic(req, res) {
const id = Number(req.params.id);
const topic = db.prepare('SELECT * FROM topics WHERE id = ?').get(id);
if (!topic) return res.status(404).json({ error: 'Topic not found' });
const { name, order_index } = req.body;
const newName = name !== undefined ? stripTags(name.trim()) : topic.name;
const newOrder = order_index !== undefined ? Number(order_index) : topic.order_index;
if (!newName) return res.status(400).json({ error: 'Name cannot be empty' });
db.prepare('UPDATE topics SET name = ?, order_index = ? WHERE id = ?').run(newName, newOrder, id);
audit(req, 'topic.update', `topic:${id}`, newName);
res.json({ ok: true });
}
function deleteTopic(req, res) {
const id = Number(req.params.id);
const topic = db.prepare('SELECT t.*, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS qcount FROM topics t WHERE t.id = ?').get(id);
if (!topic) return res.status(404).json({ error: 'Topic not found' });
if (topic.qcount > 0)
return res.status(400).json({ error: `Cannot delete topic with ${topic.qcount} questions. Reassign questions first.` });
db.prepare('DELETE FROM topics WHERE id = ?').run(id);
audit(req, 'topic.delete', `topic:${id}`, topic.name);
res.json({ ok: true });
}
/* ── Broadcast notification ──────────────────────────────────────────── */
const { pushNotif } = require('../utils/notifications');
function broadcast(req, res) {
const { message, role, link } = req.body;
if (!message?.trim()) return res.status(400).json({ error: 'message required' });
const msg = stripTags(message.trim()).slice(0, 500);
const lnk = link?.trim() || null;
let users;
if (role && role !== 'all') {
users = db.prepare('SELECT id FROM users WHERE role = ? AND is_banned = 0').all(role);
} else {
users = db.prepare('SELECT id FROM users WHERE is_banned = 0').all();
}
const ins = db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'broadcast', ?, ?)");
db.transaction(() => {
for (const u of users) ins.run(u.id, msg, lnk);
})();
audit(req, 'broadcast', role || 'all', `${users.length} users: ${msg.slice(0, 80)}`);
res.json({ ok: true, sent: users.length });
}
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
const ASSISTANT_PRESETS = [
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-super-120b-a12b:free' },
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
{ name: 'HuggingFace Router', url: 'https://router.huggingface.co/v1/chat/completions', model: 'Qwen/Qwen2.5-72B-Instruct' },
{ name: 'Pollinations (без ключа)', url: 'https://text.pollinations.ai/openai', model: 'openai' },
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
];
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
// Сверено с live-списком шлюза и протестировано на русский 2026-06-24 (% — доля кириллицы в тест-ответе):
// owl-alpha 95%, nemotron-super 91%, nano-omni 99%, laguna-xs.2 92%, openrouter/free 100% — чисто;
// nemotron-ultra/laguna-m.1 — существуют, но на free-тарифе бывает таймаут; nex-n2-pro удалён со шлюза.
const KILO_MODELS = [
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс, быстрый (262K)', ctx: 262144, out: 262144 },
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048576, out: 262144 },
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман, медленный (1M)', ctx: 1000000, out: 65536 },
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрый, мультимодальный (256K)', ctx: 256000, out: 65536 },
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 (262K)', ctx: 262144, out: 32768 },
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS.2 — лёгкая, быстрая (262K)', ctx: 262144, out: 32768 },
{ id: 'openrouter/free', label: 'Авто-роутер (бесплатные модели)', ctx: 200000, out: 32768 },
];
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
// Рабочий список бесплатных моделей: обновлённый сканом (app_settings) либо хардкод KILO_MODELS как сид.
function _kiloModels() {
try { const r = _aset('assistant_kilo_models'); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
return KILO_MODELS;
}
function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен.
function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
function getAssistant(_req, res) {
// Миграция legacy-настроек в список провайдеров (один раз)
if (!_aset('assistant_providers')) {
const lurl = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL;
const lkey = _aset('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY;
const lmodel = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL;
if (lurl || lkey || lmodel) {
_aSetProviders([{ id: 'p1', name: 'Провайдер 1', url: lurl || ASSISTANT_PRESETS[1].url, model: lmodel || ASSISTANT_PRESETS[1].model, key: lkey || '' }]);
if (!_aset('assistant_active')) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', 'p1')").run();
}
}
const list = _aProviders();
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, noKey: _aNoKey(p.url), ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
const ap = list.find(p => p.id === activeId);
const active = !!(ap && (ap.key || _aNoKey(ap.url)));
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
try {
const t = db.prepare('SELECT model_calls, cache_hits, faq FROM assistant_usage WHERE day = ?').get(new Date().toISOString().slice(0, 10));
if (t) usage = t;
const s = db.prepare("SELECT COALESCE(SUM(model_calls),0) model_calls, COALESCE(SUM(cache_hits),0) cache_hits, COALESCE(SUM(faq),0) faq FROM assistant_usage WHERE day > date('now','-30 days')").get();
if (s) usage30 = s;
} catch (e) {}
let feedback = { up: 0, down: 0, recent: [] };
try {
const f = db.prepare("SELECT COALESCE(SUM(CASE WHEN rating=1 THEN 1 ELSE 0 END),0) up, COALESCE(SUM(CASE WHEN rating=-1 THEN 1 ELSE 0 END),0) down FROM assistant_feedback WHERE created_at > date('now','-30 days')").get();
if (f) { feedback.up = f.up; feedback.down = f.down; }
feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all();
} catch (e) {}
let failover = null;
try { var fv = _aset('assistant_failover'); if (fv) failover = JSON.parse(fv); } catch (e) {}
res.json({
providers, activeId, active,
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
memory: _aset('assistant_memory') !== '0',
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
});
}
/* PATCH /api/admin/assistant — только тумблеры (RAG, кнопки экзамена) */
function saveAssistant(req, res) {
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, v);
const b = req.body || {};
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
audit(req, 'assistant.config', 'assistant', 'настройки');
res.json({ ok: true });
}
/* POST /api/admin/assistant/provider — добавить/обновить провайдера */
function saveProvider(req, res) {
const arr = _aProviders();
const b = req.body || {};
let p;
if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } }
else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); }
const prevModel = p.model;
if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер';
if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300);
if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120);
if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400);
if (!p.name) p.name = 'Провайдер';
// Лимиты модели (ctx/out/free): из тела или сброс при смене модели (перезапросятся авто)
if (b.ctx !== undefined || b.out !== undefined || b.free !== undefined) {
if (b.ctx !== undefined) p.ctx = (b.ctx === null || b.ctx === '') ? null : (Number(b.ctx) || null);
if (b.out !== undefined) p.out = (b.out === null || b.out === '') ? null : (Number(b.out) || null);
if (b.free !== undefined) p.free = (typeof b.free === 'boolean') ? b.free : null;
} else if (typeof b.model === 'string' && p.model !== prevModel) {
p.ctx = null; p.out = null; p.free = null;
}
_aSetProviders(arr);
if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id);
audit(req, 'assistant.provider', p.id, 'сохранён');
res.json({ ok: true, id: p.id });
}
/* DELETE /api/admin/assistant/provider/:id */
function deleteProvider(req, res) {
let arr = _aProviders();
arr = arr.filter(x => x.id !== req.params.id);
_aSetProviders(arr);
if (_aset('assistant_active') === req.params.id && arr[0]) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(arr[0].id);
audit(req, 'assistant.provider.del', req.params.id, 'удалён');
res.json({ ok: true });
}
/* Запрос списка моделей провайдера с лимитами. Понимает OpenAI-совместимый /models
* (context_length + max_completion_tokens + pricing) и нативный Google generativelanguage
* (inputTokenLimit / outputTokenLimit). */
async function _fetchModels(url, key) {
if (typeof fetch !== 'function') return { error: 'fetch недоступен' };
url = String(url || '');
const isGoogle = /generativelanguage\.googleapis\.com/.test(url);
let ep; const headers = {};
if (isGoogle) {
const base = url.replace(/\/openai\/chat\/completions.*$/, '').replace(/\/chat\/completions.*$/, '');
ep = base + '/models?pageSize=200' + (key ? '&key=' + encodeURIComponent(key) : '');
} else {
ep = url.replace(/\/chat\/completions.*$/, '/models');
if (key) headers.Authorization = 'Bearer ' + key;
}
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const r = await fetch(ep, { headers, signal: ctrl.signal });
if (!r.ok) return { error: 'HTTP ' + r.status, status: r.status };
const j = await r.json();
const raw = j.data || j.models || [];
const models = [];
for (const m of raw) {
const methods = m.supportedGenerationMethods;
if (methods && methods.indexOf('generateContent') === -1) continue; // Google: только генеративные
const id = String(m.id || m.name || '').replace(/^models\//, '');
if (!id) continue;
const tp = m.top_provider || {};
const pr = m.pricing || {};
const ctx = m.context_length || tp.context_length || m.inputTokenLimit || null;
const out = tp.max_completion_tokens || m.outputTokenLimit || null;
let free = null;
if (pr && (pr.prompt != null || pr.completion != null)) free = Number(pr.prompt) === 0 && Number(pr.completion) === 0;
models.push({ id, ctx: ctx || null, out: out || null, free });
}
return { models };
} catch (e) { return { error: e.name === 'AbortError' ? 'timeout' : 'network' }; }
finally { clearTimeout(timer); }
}
/* GET /api/admin/assistant/models?id=&url=&key= — модели провайдера с лимитами.
* С id: берём сохранённого провайдера и кэшируем лимиты его текущей модели. */
async function getProviderModels(req, res) {
const id = req.query.id ? String(req.query.id) : '';
let url = req.query.url, key = req.query.key, prov = null;
if (id) { prov = _aProviders().find(x => x.id === id); if (!prov) return res.json({ error: 'провайдер не найден' }); url = prov.url; key = prov.key; }
const r = await _fetchModels(url, key);
if (r.error) return res.json({ error: r.error, status: r.status });
let current = null;
if (prov) {
const arr = _aProviders(); const p2 = arr.find(x => x.id === id);
const m = p2 && r.models.find(x => x.id === p2.model);
if (p2 && m) { p2.ctx = m.ctx; p2.out = m.out; p2.free = m.free; _aSetProviders(arr); current = { ctx: m.ctx, out: m.out, free: m.free }; }
}
res.json({ models: r.models, current });
}
/* ── Сканер бесплатных моделей шлюза (наполняет список KILO_MODELS) ───────── */
// Заведомо не-чат модели (музыка/картинки/эмбеддинги/модерация) — не тестируем.
const _NONCHAT_RE = /(lyria|whisper|tts|embed|rerank|moderation|content-safety|guard|dall-?e|imagen|sora|veo|\bmusic\b)/i;
// Kilo-провайдер (со шлюзом kilocode.ai): по id, иначе активный, иначе первый с ключом.
function _pickKiloProvider(id) {
const arr = _aProviders();
if (id) return arr.find(p => p.id === id) || null;
const active = arr.find(p => p.id === _aset('assistant_active'));
if (active && /kilocode\.ai/.test(active.url || '') && active.key) return active;
return arr.find(p => /kilocode\.ai/.test(p.url || '') && p.key)
|| arr.find(p => /kilocode\.ai/.test(p.url || '')) || null;
}
/* POST /api/admin/assistant/scan { id? } — найти бесплатные модели на шлюзе провайдера.
* Без инференса: список + сверка с текущим рабочим списком (что новое / что исчезло). */
async function scanModels(req, res) {
const prov = _pickKiloProvider(req.body && req.body.id);
if (!prov) return res.json({ error: 'Нет Kilo-провайдера. Добавьте провайдера со шлюзом kilocode.ai с ключом.' });
const r = await _fetchModels(prov.url, prov.key);
if (r.error) return res.json({ error: r.error, status: r.status });
const cur = _kiloModels();
const curIds = new Set(cur.map(m => m.id));
const liveIds = new Set(r.models.map(m => m.id));
const free = r.models
.filter(m => (m.free === true || /:free$/.test(m.id)) && !_NONCHAT_RE.test(m.id))
.map(m => ({ id: m.id, ctx: m.ctx, out: m.out, status: curIds.has(m.id) ? 'current' : 'new' }));
free.sort((a, b) => (a.status === b.status ? (b.ctx || 0) - (a.ctx || 0) : a.status === 'current' ? -1 : 1));
const gone = cur.filter(m => !liveIds.has(m.id)).map(m => ({ id: m.id, label: m.label }));
res.json({ providerId: prov.id, providerName: prov.name, total: r.models.length, models: free, gone, current: cur });
}
/* POST /api/admin/assistant/probe { id?, model } — один тест-запрос на русском. */
async function probeModel(req, res) {
const b = req.body || {};
const prov = _pickKiloProvider(b.id);
if (!prov) return res.json({ ok: false, error: 'нет провайдера' });
const model = String(b.model || '').trim().slice(0, 120);
if (!model) return res.json({ ok: false, error: 'нет модели' });
if (typeof fetch !== 'function') return res.json({ ok: false, error: 'fetch недоступен' });
const PROMPT = 'Ученик 9 класса спрашивает: что такое синус острого угла в прямоугольном треугольнике? Объясни кратко и понятно. Отвечай только на русском языке.';
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 22000);
const t0 = Date.now();
try {
const r = await fetch(prov.url, {
method: 'POST', signal: ctrl.signal,
headers: Object.assign({ 'Content-Type': 'application/json' }, prov.key ? { Authorization: 'Bearer ' + prov.key } : {}),
body: JSON.stringify({ model, max_tokens: 160, temperature: 0.3, messages: [{ role: 'user', content: PROMPT }] }),
});
const ms = Date.now() - t0;
const txt = await r.text();
if (!r.ok) {
let msg = txt.slice(0, 200);
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 200); } catch (e) {}
return res.json({ ok: false, status: r.status, ms, error: msg });
}
let sample = '';
try { const j = JSON.parse(txt); const m = j.choices && j.choices[0] && j.choices[0].message; sample = String((m && (m.content || m.reasoning)) || '').trim(); } catch (e) {}
const letters = (sample.match(/[A-Za-zА-Яа-яЁё一-鿿]/g) || []).length;
const cyr = (sample.match(/[А-Яа-яЁё]/g) || []).length;
const cjk = (sample.match(/[一-鿿]/g) || []).length;
const ratio = letters ? cyr / letters : 0;
const verdict = !sample ? 'пусто' : cjk > 0 ? 'иероглифы' : ratio > 0.55 ? 'чистый русский' : ratio > 0.2 ? 'смешанный' : 'не русский';
res.json({ ok: true, ms, ratio: Math.round(ratio * 100), cjk, verdict, sample: sample.replace(/\s+/g, ' ').slice(0, 180) });
} catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: e.name === 'AbortError' ? 'таймаут' : 'сеть' }); }
finally { clearTimeout(timer); }
}
/* POST /api/admin/assistant/models/apply { models:[{id,label,ctx,out}] | reset:true } */
function applyModels(req, res) {
const b = req.body || {};
if (b.reset) {
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_kilo_models'").run(); } catch (e) {}
audit(req, 'assistant.models', 'kilo', 'сброс к встроенному');
return res.json({ ok: true, reset: true });
}
const arr = Array.isArray(b.models) ? b.models : null;
if (!arr) return res.status(400).json({ error: 'models[] обязателен' });
const clean = [];
for (const m of arr.slice(0, 40)) {
const id = String((m && m.id) || '').trim().slice(0, 120);
if (!id) continue;
clean.push({ id, label: String((m && m.label) || id).trim().slice(0, 80), ctx: Number(m && m.ctx) || null, out: Number(m && m.out) || null });
}
if (!clean.length) return res.status(400).json({ error: 'пустой список' });
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_kilo_models', ?)").run(JSON.stringify(clean));
audit(req, 'assistant.models', 'kilo', clean.length + ' моделей');
res.json({ ok: true, count: clean.length });
}
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
function setActiveProvider(req, res) {
const id = String((req.body && req.body.id) || '');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(id);
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} // смена активного снимает старый флаг
audit(req, 'assistant.active', id, 'активный провайдер');
res.json({ ok: true });
}
/* POST /api/admin/assistant/reindex — переиндексировать учебники для RAG */
function reindexTextbooks(req, res) {
try {
const { reindex } = require('../../scripts/index-textbooks');
const r = reindex();
audit(req, 'assistant.reindex', 'assistant', `chunks:${r.chunks || 0}`);
res.json(r);
} catch (e) { res.status(500).json({ error: e.message || 'reindex failed' }); }
}
async function testAssistant(req, res) {
const a = require('./assistantController');
const b = req.body || {};
let override;
if (b.id) {
const p = _aProviders().find(x => x.id === b.id);
if (!p) return res.json({ ok: false, error: 'провайдер не найден' });
// если ключ пуст (не вводили) — берём сохранённый у этого провайдера
override = { url: (b.url && b.url.trim()) || p.url, model: (b.model && b.model.trim()) || p.model, key: (b.key && b.key.trim()) || p.key };
} else {
const cfg = a.llmConfig();
override = {
url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url,
model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model,
key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key,
};
}
override.local = _aIsLocal(override.url);
override.on = !!(override.key || _aNoKey(override.url));
const r = await a.pingLLM(override);
// Успешный тест активного провайдера снимает устаревший флаг failover
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
res.json(r);
}
/* ── Генерация картинок (Cloudflare Workers AI) ──────────────────────────── */
const IMGGEN_MODELS = [
{ id: '@cf/black-forest-labs/flux-1-schnell', label: 'FLUX.1 schnell — рекомендуется, быстрый' },
{ id: '@cf/stabilityai/stable-diffusion-xl-base-1.0', label: 'Stable Diffusion XL' },
{ id: '@cf/bytedance/stable-diffusion-xl-lightning', label: 'SDXL Lightning — очень быстрый' },
{ id: '@cf/lykon/dreamshaper-8-lcm', label: 'DreamShaper 8 LCM' },
];
function _imgCfg() { try { return JSON.parse(_aset('imggen_provider') || '{}') || {}; } catch (e) { return {}; } }
function getImggen(_req, res) {
const c = _imgCfg();
let stats = { count: 0, bytes: 0 };
try { stats = require('./imggenController').stats(); } catch (e) {}
const cd = Number(c.cooldownMs), dc = Number(c.dailyCap);
const configured = !!(c.provider === 'cloudflare' && c.accountId && c.token);
const on = c.on !== false;
res.json({
provider: c.provider || 'cloudflare',
accountId: c.accountId || '',
model: c.model || '@cf/black-forest-labs/flux-1-schnell',
hasToken: !!c.token,
cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : 4000,
dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : 40,
on,
configured,
enabled: configured && on,
models: IMGGEN_MODELS,
stats,
});
}
function saveImggen(req, res) {
const b = req.body || {};
const c = _imgCfg();
c.provider = b.provider || c.provider || 'cloudflare';
if (typeof b.on === 'boolean') c.on = b.on;
if (b.accountId !== undefined) c.accountId = String(b.accountId || '').trim();
if (b.model !== undefined) c.model = String(b.model || '').trim();
if (b.clearToken) c.token = '';
else if (b.token && b.token !== '••••••••') c.token = String(b.token).trim();
if (b.cooldownMs !== undefined) { const n = Number(b.cooldownMs); if (Number.isFinite(n) && n >= 0) c.cooldownMs = n; }
if (b.dailyCap !== undefined) { const n = Number(b.dailyCap); if (Number.isFinite(n) && n >= 0) c.dailyCap = n; }
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('imggen_provider', ?)").run(JSON.stringify(c));
audit(req, 'imggen.config', 'imggen', 'настройки генерации картинок');
res.json({ ok: true });
}
async function testImggen(req, res) {
const prompt = (String((req.body && req.body.prompt) || '').trim()) || 'a cute friendly mascot, flat illustration, warm tones';
try {
const out = await require('./imggenController').generateImage(prompt, 0);
res.json({ ok: true, url: out.url, prompt: out.prompt });
} catch (e) {
res.status(502).json({ ok: false, error: e.message || 'Ошибка', detail: e.detail });
}
}
module.exports = {
getStats, getOverview, globalSearch,
getImggen, saveImggen, testImggen,
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,
broadcast,
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
scanModels, probeModel, applyModels,
};