Files
Learn_System/backend/src/controllers/adminController.js
T
Maxim Dolgolyov 6e0a00fd8b feat(assistant): авто-получение лимитов моделей для любого провайдера
Новый GET /admin/assistant/models: тянет список моделей провайдера с лимитами
(OpenAI-совместимый /models: context_length+max_completion_tokens+pricing;
нативный Google generativelanguage: inputTokenLimit/outputTokenLimit) и кэширует
лимиты текущей модели на провайдере. Карточка показывает лимиты у ВСЕХ провайдеров
(не только Kilo), для отсутствующих — фоновая авто-подгрузка. В форме — кнопка
«Загрузить модели провайдера» с выбором модели и её лимитами. Так Gemini и любые
новые модели получают лимиты автоматически.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:28:34 +03:00

1107 lines
54 KiB
JavaScript

const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit');
const { purgeAccessFor } = require('../services/contentAccess');
/* ── 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 } = 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);
const where = ['ts.status = \'completed\''];
const params = [];
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 ${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'];
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/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 path = require('path');
const fs = require('fs');
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-ultra-550b-a55b: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: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
];
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
const KILO_MODELS = [
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман (1M)', ctx: 1000000, out: 65536 },
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс (1M)', ctx: 1000000, out: 262144 },
{ id: 'qwen/qwen3.7-plus:free', label: 'Qwen3.7 Plus — умная, медленная (1M)', ctx: 1000000, out: 65536 },
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048756, out: 262144 },
{ 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 — лёгкая (262K)', ctx: 262144, out: 32768 },
{ id: 'openrouter/free', label: 'Free Router — авто-выбор (быстро)', ctx: 200000, out: null },
];
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
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 || ''); }
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, 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 || _aIsLocal(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',
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: 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 (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 });
}
/* 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 || override.local);
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);
}
module.exports = {
getStats, getOverview, globalSearch,
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
};