Files
Learn_System/backend/src/controllers/imggenController.js
T
Maxim Dolgolyov ccfb151eca fix(reliability): дневной лимит imggen в БД + ретеншн error_log (Спринт3)
- imggen: дневной счётчик генераций перенесён из in-memory Map в таблицу
  imggen_usage (миграция 070) — переживает рестарт. Cooldown остаётся в памяти,
  но добавлена периодическая чистка Map + старых строк imggen_usage (>7 дн).
- classroom-cleanup: ретеншн error_log (app_settings.error_log_retention_days,
  по умолч. 30; 0 = выкл) в том же суточном джобе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:00:36 +03:00

163 lines
8.9 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.
'use strict';
/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell и др.).
* Конфиг в app_settings.imggen_provider: { provider, accountId, token, model, cooldownMs?, dailyCap? }.
* Картинка сохраняется в uploads/generated и отдаётся URL'ом.
* Управление — админ-раздел «Генерация картинок» (adminController.getImggen/saveImggen/testImggen). */
const fs = require('fs');
const path = require('path');
const db = require('../db/db');
const { isFeatureEnabledForUser } = require('../middleware/features');
const GEN_DIR = path.join(__dirname, '../../uploads/generated');
const _cooldown = new Map(); // userId → last ts (антиспам, короткоживущий, ок в памяти)
const COOLDOWN_DEFAULT = 4000;
const DAILY_DEFAULT = 40;
// Дневной счётчик — в БД (imggen_usage), переживает рестарт.
function _dailyCount(uid, day) {
try { const r = db.prepare('SELECT count FROM imggen_usage WHERE user_id=? AND day=?').get(uid, day); return r ? r.count : 0; }
catch (e) { return 0; }
}
function _dailyBump(uid, day) {
try { db.prepare('INSERT INTO imggen_usage (user_id, day, count) VALUES (?,?,1) ON CONFLICT(user_id, day) DO UPDATE SET count = count + 1').run(uid, day); }
catch (e) {}
}
// Чистка in-memory cooldown (старше минуты) + старых строк imggen_usage — не растём бесконечно.
setInterval(() => {
const cut = Date.now() - 60_000;
for (const [k, t] of _cooldown) if (t < cut) _cooldown.delete(k);
try { db.prepare("DELETE FROM imggen_usage WHERE day < date('now','-7 days')").run(); } catch (e) {}
}, 5 * 60_000).unref();
function _cfg() {
try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; }
}
function _configured(c) { c = (c === undefined) ? _cfg() : c; return !!(c && c.provider === 'cloudflare' && c.accountId && c.token); }
// «Включено» = настроено И не выключено тумблером (c.on !== false). Тумблер независим от наличия токена.
function _enabled() { const c = _cfg(); return _configured(c) && c.on !== false; }
function _limits() {
const c = _cfg() || {};
const cd = Number(c.cooldownMs);
const dc = Number(c.dailyCap);
return {
cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : COOLDOWN_DEFAULT,
dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : DAILY_DEFAULT,
};
}
// Фича включена для ЭТОГО пользователя (глобально + оверлей класса + free_student).
function _featOn(req) { return isFeatureEnabledForUser(req.user.id, req.user.role, 'imggen'); }
/* GET /api/imggen/status — для UI (показывать кнопки или нет) */
function status(req, res) { res.json({ enabled: _enabled() && _featOn(req) }); }
/* FLUX лучше понимает английский. Если в промпте есть кириллица — переводим
* через тот же LLM-провайдер, что и ассистент (с failover). При сбое — исходный текст. */
async function _toEnglish(prompt) {
if (!/[Ѐ-ӿ]/.test(prompt)) return prompt;
try {
const { callLLMFailover } = require('./assistantController');
const sys = 'You translate image-generation prompts into concise, vivid English. ' +
'Output ONLY the English prompt — no quotes, no notes, no preamble. Keep it short and descriptive.';
const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: prompt }], 120);
if (r && r.text) {
const en = String(r.text).replace(/^["'«»\s]+|["'«»\s]+$/g, '').replace(/\s+/g, ' ').trim();
if (en && !/[Ѐ-ӿ]/.test(en)) return en.slice(0, 500);
}
} catch (e) {}
return prompt; // перевод не удался — отправим как есть
}
/* Низкоуровневый вызов Cloudflare. Возвращает Buffer (PNG) или бросает Error
* (с опц. .detail). Поддерживает оба формата ответа CF: JSON {result:{image:b64}}
* (FLUX) и сырой бинарный image/* (SDXL и пр.). */
async function _callCF(cfg, prompt) {
if (typeof fetch !== 'function') throw new Error('fetch недоступен');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000);
try {
const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/ai/run/${cfg.model}`, {
method: 'POST',
headers: { Authorization: 'Bearer ' + cfg.token, 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, steps: 4 }),
signal: ctrl.signal,
});
if (!r.ok) {
const t = await r.text();
const e = new Error('Сервис картинок ответил ошибкой (' + r.status + ')');
e.detail = t.slice(0, 160);
throw e;
}
const ct = (r.headers.get('content-type') || '').toLowerCase();
let buf;
if (ct.includes('application/json')) {
const j = await r.json();
const b64 = j && j.result && j.result.image;
if (!b64) throw new Error('Пустой ответ от сервиса');
buf = Buffer.from(b64, 'base64');
} else {
buf = Buffer.from(await r.arrayBuffer());
}
if (buf.length < 500) throw new Error('Некорректное изображение');
return buf;
} catch (e) {
if (e.name === 'AbortError') throw new Error('Слишком долго — попробуй ещё раз');
throw e;
} finally { clearTimeout(timer); }
}
function _save(uid, buf) {
fs.mkdirSync(GEN_DIR, { recursive: true });
const name = (uid || 0) + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '.png';
fs.writeFileSync(path.join(GEN_DIR, name), buf);
return '/uploads/generated/' + name;
}
/* Публичная: перевод → генерация → сохранение. Без пер-юзер лимитов
* (их применяет route generate). Возвращает { url, prompt } или бросает Error. */
async function generateImage(prompt, uid) {
// Низкоуровневая: проверяем только наличие конфига (тумблер on/off — забота вызывающего
// route /api/imggen). Так админ-тест работает даже при выключенном тумблере.
if (!_configured()) throw new Error('Генерация изображений не настроена');
const p = String(prompt || '').trim().slice(0, 500);
if (p.length < 3) throw new Error('Опиши, что нарисовать (хотя бы пару слов)');
const en = await _toEnglish(p);
const buf = await _callCF(_cfg(), en);
return { url: _save(uid, buf), prompt: en };
}
/* Статистика для админки: сколько картинок сгенерировано (файлов) и общий размер. */
function stats() {
try {
const files = fs.readdirSync(GEN_DIR).filter(f => f.endsWith('.png'));
let bytes = 0;
for (const f of files) { try { bytes += fs.statSync(path.join(GEN_DIR, f)).size; } catch (e) {} }
return { count: files.length, bytes };
} catch (e) { return { count: 0, bytes: 0 }; }
}
/* POST /api/imggen { prompt } → { url } */
async function generate(req, res) {
if (!_featOn(req)) return res.status(403).json({ error: 'Генерация картинок отключена для вашего класса' });
if (!_enabled()) return res.status(503).json({ error: _configured() ? 'Генерация картинок временно выключена' : 'Генерация изображений не настроена' });
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, 500);
if (prompt.length < 3) return res.status(400).json({ error: 'Опиши, что нарисовать (хотя бы пару слов)' });
const uid = req.user.id, now = Date.now();
const L = _limits();
if (now - (_cooldown.get(uid) || 0) < L.cooldownMs) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' });
const today = new Date().toISOString().slice(0, 10);
if (L.dailyCap > 0 && _dailyCount(uid, today) >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' });
_cooldown.set(uid, now);
try {
const out = await generateImage(prompt, uid);
_dailyBump(uid, today);
res.json({ url: out.url });
} catch (e) {
res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail });
}
}
module.exports = { generate, status, generateImage, stats };