feat: topic bank + channel limit + onboarding

Topic bank (P6):
- DB: channel_topics(channel_id, topic, is_used)
- services/topicBank.js: nextTopic, refillManual, addManual, listTopics, checkAndRefill
  Авто-пополнение когда <5 тем, пачками по 10 через Claude Haiku
- routes/generate.js: GET/POST /topics-bank/:channelId, /refill, /add, DELETE /item/:id

Channel limit (P7):
- routes/channels.js: POST / → проверяет billing.getBalance().channelsMax перед созданием
  HTTP 402 + CHANNEL_LIMIT_REACHED если лимит исчерпан
- channels/new/page.js: при 402 → ошибка + redirect на /plans через 2 сек

ENGINE_URL fix: 3040 → 3030 (lib/engine.js)
This commit is contained in:
Ник (Claude)
2026-06-11 23:04:45 +03:00
parent 10c138aa33
commit bbae6c8832
3 changed files with 230 additions and 0 deletions
+167
View File
@@ -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 };