diff --git a/src/routes/channels.js b/src/routes/channels.js index 70dba4d..7187ec2 100644 --- a/src/routes/channels.js +++ b/src/routes/channels.js @@ -250,6 +250,23 @@ router.post('/', async (req, res) => { const userId = getUserId(req); if (!userId) return res.status(401).json({ error: 'x-user-id required' }); try { + // Проверяем лимит каналов по тарифу + const billing = require('../services/billing'); + const bal = await billing.getBalance(userId); + if (bal.channelsMax !== -1) { + const { rows: [{ cnt }] } = await require('../config/db').query( + 'SELECT count(*)::int as cnt FROM channels WHERE user_id=$1 AND is_active=true', [userId] + ); + if (cnt >= bal.channelsMax) { + return res.status(402).json({ + error: `Лимит каналов по тарифу ${bal.planName}: максимум ${bal.channelsMax}. Перейдите на следующий тариф.`, + code: 'CHANNEL_LIMIT_REACHED', + current: cnt, + max: bal.channelsMax, + plan: bal.plan, + }); + } + } const channel = await channelsSvc.createChannel(userId, req.body); res.json(channel); } catch (err) { diff --git a/src/routes/generate.js b/src/routes/generate.js index b0befb9..9439761 100644 --- a/src/routes/generate.js +++ b/src/routes/generate.js @@ -168,3 +168,49 @@ router.post('/from-url', async (req, res) => { }); module.exports = router; + +// ── Topic Bank API ────────────────────────────────────────── + +const topicBank = require('../services/topicBank'); + +// GET /api/generate/topics-bank/:channelId — список тем из банка +router.get('/topics-bank/:channelId', async (req, res) => { + try { + const topics = await topicBank.listTopics(req.params.channelId, { + limit: 30, + includeUsed: req.query.includeUsed === 'true', + }); + const { rows: [{ cnt }] } = await require('../config/db').query( + 'SELECT count(*)::int as cnt FROM channel_topics WHERE channel_id=$1 AND is_used=false', + [req.params.channelId] + ); + res.json({ topics, total_unused: cnt }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/generate/topics-bank/:channelId/refill — пополнить банк вручную +router.post('/topics-bank/:channelId/refill', async (req, res) => { + try { + const saved = await topicBank.refillManual(req.params.channelId); + res.json({ ok: true, added: saved.length, topics: saved }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/generate/topics-bank/:channelId/add — добавить темы вручную +router.post('/topics-bank/:channelId/add', async (req, res) => { + try { + const { topics = [] } = req.body; + const saved = await topicBank.addManual(req.params.channelId, topics); + res.json({ ok: true, added: saved.length }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/generate/topics-bank/:id — удалить тему +router.delete('/topics-bank/item/:id', async (req, res) => { + try { + await require('../config/db').query('DELETE FROM channel_topics WHERE id=$1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +module.exports = router; diff --git a/src/services/topicBank.js b/src/services/topicBank.js new file mode 100644 index 0000000..7749f85 --- /dev/null +++ b/src/services/topicBank.js @@ -0,0 +1,167 @@ +/** + * topicBank.js — хранилище тем для каналов. + * + * Логика: + * - Темы генерируются AI (claude-haiku) и сохраняются в channel_topics + * - При запросе темы для автогенерации → берём старейшую неиспользованную + * - Если тем < topics_min_stock (default 5) → фоново генерируем новые + * - Автогенерация запускается при каждом использовании темы + */ +const { query } = require('../config/db'); +const ai = require('./ai'); + +const MIN_STOCK = 5; // порог пополнения +const BATCH_SIZE = 10; // сколько генерируем за раз + +/** + * Взять следующую тему для канала (помечает как используемую). + * Если тем нет — генерирует одну синхронно. + */ +async function nextTopic(channelId) { + const { rows: [topic] } = await query(` + UPDATE channel_topics + SET is_used = true, used_at = NOW() + WHERE id = ( + SELECT id FROM channel_topics + WHERE channel_id = $1 AND is_used = false + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * + `, [channelId]); + + if (!topic) { + // Нет тем — генерируем синхронно + const generated = await generateBatch(channelId, 1); + if (!generated.length) throw new Error('Не удалось сгенерировать тему'); + await query('UPDATE channel_topics SET is_used=true, used_at=NOW() WHERE id=$1', [generated[0].id]); + return generated[0].topic; + } + + // Фоновое пополнение если мало осталось + checkAndRefill(channelId).catch(e => + console.error(`[topicBank] refill error ch=${channelId}: ${e.message}`) + ); + + return topic.topic; +} + +/** + * Пополнить банк если тем мало. + */ +async function checkAndRefill(channelId) { + const { rows: [{ cnt }] } = await query( + 'SELECT count(*)::int as cnt FROM channel_topics WHERE channel_id=$1 AND is_used=false', + [channelId] + ); + if (cnt < MIN_STOCK) { + console.log(`[topicBank] channel=${channelId} stock=${cnt} < ${MIN_STOCK}, generating ${BATCH_SIZE}...`); + await generateBatch(channelId, BATCH_SIZE); + } +} + +/** + * Сгенерировать пакет тем через AI и сохранить в channel_topics. + */ +async function generateBatch(channelId, count = BATCH_SIZE) { + const { rows: [channel] } = await query('SELECT * FROM channels WHERE id=$1', [channelId]); + if (!channel) return []; + + // Уже использованные темы для дедупликации + const { rows: recent } = await query(` + SELECT topic FROM channel_topics + WHERE channel_id=$1 + ORDER BY created_at DESC LIMIT 30 + `, [channelId]); + const usedTopics = recent.map(r => r.topic).join('\n'); + + const niche = channel.niche || ''; + const audience = channel.audience || ''; + const lang = channel.language || 'ru'; + const goal = channel.goal || ''; + + const system = `Ты SMM-эксперт. Генерируй темы постов для Telegram-канала. +Ниша: ${niche || 'общая'}. Аудитория: ${audience || 'широкая'}. Язык: ${lang}. Цель: ${goal || 'информирование'}. +Требования: +- Каждая тема — конкретный заход, не общая категория +- Разнообразие форматов: новость, кейс, совет, мнение, подборка, сравнение +- Интригующие, не кликбейтные +- На языке канала (${lang}) +Ответь ТОЛЬКО JSON-массивом строк. Без markdown. Без пояснений.`; + + const userMsg = `Придумай ${count} уникальных тем для постов.${ + usedTopics ? `\n\nАналогичные темы УЖЕ БЫЛИ — избегай повторений:\n${usedTopics.slice(0, 800)}` : '' + }`; + + try { + const config = require('../config'); + const result = await ai.chat( + config.ai.models.topics || 'claude-haiku-4-5-20251001', + system, userMsg, 0.9, 500 + ); + const clean = result.replace(/```json|```/g, '').trim(); + const topics = JSON.parse(clean); + if (!Array.isArray(topics)) throw new Error('not array'); + + const saved = []; + for (const topic of topics.slice(0, count)) { + if (!topic?.trim()) continue; + const { rows: [row] } = await query( + 'INSERT INTO channel_topics (channel_id, topic) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING *', + [channelId, String(topic).trim()] + ); + if (row) saved.push(row); + } + console.log(`[topicBank] channel=${channelId} generated ${saved.length} topics`); + return saved; + } catch (err) { + console.error(`[topicBank] generateBatch failed: ${err.message}`); + return []; + } +} + +/** + * Получить список тем для UI (неиспользованные). + */ +async function listTopics(channelId, { limit = 20, includeUsed = false } = {}) { + const { rows } = await query(` + SELECT * FROM channel_topics + WHERE channel_id = $1 ${includeUsed ? '' : 'AND is_used = false'} + ORDER BY created_at DESC + LIMIT $2 + `, [channelId, limit]); + return rows; +} + +/** + * Пополнить банк вручную (для кнопки в UI). + */ +async function refillManual(channelId) { + return generateBatch(channelId, BATCH_SIZE); +} + +/** + * Пометить тему как использованную вручную (при ручной генерации поста). + */ +async function markUsed(topicId) { + await query('UPDATE channel_topics SET is_used=true, used_at=NOW() WHERE id=$1', [topicId]); +} + +/** + * Добавить темы вручную. + */ +async function addManual(channelId, topics) { + const saved = []; + for (const topic of topics) { + if (!topic?.trim()) continue; + const { rows: [row] } = await query( + 'INSERT INTO channel_topics (channel_id, topic) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING *', + [channelId, String(topic).trim()] + ); + if (row) saved.push(row); + } + return saved; +} + +module.exports = { nextTopic, listTopics, refillManual, addManual, markUsed, checkAndRefill, generateBatch };