forked from admin/zeropost-engine
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:
@@ -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 };
|
||||
Reference in New Issue
Block a user