feat: post drafts system — batch generation + daily auto-drafts

DB: post_drafts(channel_id, topic, text, image_url, status), channels.auto_draft_*
Engine:
  services/draftService.js: generateOneDraft, generateBatch, generateDailyDrafts,
    approveDraft(→scheduled_post), rejectDraft, updateDraft, listDrafts
  routes/drafts.js: GET/PATCH/DELETE /api/drafts/:id, /approve, /reject
    POST /api/channels/:channelId/drafts/generate?count=N (async, returns immediately)
  index.js: cron каждые 30 мин → generateDailyDrafts() для каналов с auto_draft_enabled
  channels.js: updateChannel сохраняет auto_draft_enabled/count/time
This commit is contained in:
Ник (Claude)
2026-06-12 23:47:27 +03:00
parent 5a765d27e1
commit a8ff295faa
4 changed files with 340 additions and 2 deletions
+2 -1
View File
@@ -116,7 +116,8 @@ async function updateChannel(channelId, userId, data) {
if (Object.keys(channelFields).length) {
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
'niche', 'audience', 'goal', 'language', 'region', 'is_active',
'vk_access_token', 'ai_style_prompt', 'image_quality'];
'vk_access_token', 'ai_style_prompt', 'image_quality',
'auto_draft_enabled', 'auto_draft_count', 'auto_draft_time'];
const updates = fields.filter(f => channelFields[f] !== undefined);
if (updates.length) {
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
+224
View File
@@ -0,0 +1,224 @@
/**
* draftService.js — генерация и управление черновиками постов.
*
* Два режима:
* 1. Ручной batch: POST /api/channels/:id/drafts/generate?count=N
* 2. Авто-ежедневный: cron вызывает generateDailyDrafts()
*
* Флоу:
* generateDraft(channel) → text + image → post_drafts (status=pending)
* Пользователь одобряет → scheduled_posts (status=pending)
*/
const { query } = require('../config/db');
const ai = require('./ai');
const topicBank = require('./topicBank');
const billing = require('./billing');
const postImages = require('./postImages');
const config = require('../config');
/**
* Сгенерировать один черновик для канала.
*/
async function generateOneDraft(channel, { topic, userId, withImage = true } = {}) {
// Выбираем тему
const useTopic = topic || await topicBank.nextTopic(channel.id).catch(() => null)
|| 'Интересные тенденции в теме канала';
// Генерируем текст
const postResult = await ai.generatePost(channel, {
topic: useTopic,
useCritique: true,
});
const text = postResult.content;
// Генерируем картинку если включено
let imageUrl = null;
if (withImage && channel.image_enabled) {
try {
const imgResult = await postImages.generatePostImage({
post: text, channel, style: channel.style || {},
});
imageUrl = imgResult?.url || null;
} catch (err) {
console.warn(`[draft] image gen failed for ch=${channel.id}: ${err.message}`);
}
}
// Сохраняем черновик
const { rows: [draft] } = await query(`
INSERT INTO post_drafts (channel_id, user_id, topic, text, image_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [channel.id, userId || null, useTopic, text, imageUrl]);
return draft;
}
/**
* Пакетная генерация N черновиков.
* Запускается параллельно (но не более 3 одновременно).
*/
async function generateBatch(channel, { count = 3, userId, withImage = true } = {}) {
const results = [];
const errors = [];
// Пополняем топик-банк если нужно
await topicBank.checkAndRefill(channel.id).catch(() => {});
// Генерируем по одному (параллелизм ограничен чтобы не перегрузить API)
const CONCURRENCY = 2;
for (let i = 0; i < count; i += CONCURRENCY) {
const batch = [];
for (let j = 0; j < CONCURRENCY && (i + j) < count; j++) {
batch.push(
generateOneDraft(channel, { userId, withImage })
.then(d => results.push(d))
.catch(e => errors.push(e.message))
);
}
await Promise.all(batch);
}
return { generated: results.length, errors, drafts: results };
}
/**
* Cron: генерировать ежедневные черновики для всех каналов с auto_draft_enabled.
* Запускается каждые 30 мин, генерирует только те каналы у которых пришло время.
*/
async function generateDailyDrafts() {
const now = new Date();
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
// Диапазон ±15 минут от текущего времени
const [h, m] = hhmm.split(':').map(Number);
const totalMin = h * 60 + m;
const { rows: channels } = await query(`
SELECT c.*, u.id as owner_id
FROM channels c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.auto_draft_enabled = true
AND c.is_active = true
`);
let processed = 0;
for (const channel of channels) {
try {
const [ch, cm] = (channel.auto_draft_time || '08:00').split(':').map(Number);
const channelMin = ch * 60 + cm;
if (Math.abs(totalMin - channelMin) > 15) continue; // не время
// Проверяем не генерировали ли уже сегодня
const today = new Date(); today.setHours(0,0,0,0);
const { rows: [{ cnt }] } = await query(`
SELECT count(*)::int as cnt FROM post_drafts
WHERE channel_id=$1 AND created_at >= $2
`, [channel.id, today]);
if (cnt >= channel.auto_draft_count) {
console.log(`[drafts] ch=${channel.id} already has ${cnt} drafts today, skip`);
continue;
}
const toGenerate = channel.auto_draft_count - cnt;
console.log(`[drafts] ch=${channel.id} generating ${toGenerate} daily drafts...`);
await generateBatch(channel, {
count: toGenerate,
userId: channel.user_id,
withImage: !!channel.image_enabled,
});
processed++;
} catch (err) {
console.error(`[drafts] daily error ch=${channel.id}: ${err.message}`);
}
}
return processed;
}
/**
* Одобрить черновик — создаёт scheduled_post.
*/
async function approveDraft(draftId, { scheduledAt, userId } = {}) {
const { rows: [draft] } = await query(
'SELECT * FROM post_drafts WHERE id=$1', [draftId]
);
if (!draft) throw new Error('Draft not found');
if (draft.status !== 'pending') throw new Error(`Draft status is ${draft.status}`);
// Если время не указано — ставим на ближайший доступный слот (через 1 час)
const pubAt = scheduledAt
? new Date(scheduledAt)
: new Date(Date.now() + 60 * 60 * 1000);
const { rows: [sp] } = await query(`
INSERT INTO scheduled_posts (channel_id, custom_text, cover_url, scheduled_at, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id
`, [draft.channel_id, draft.text, draft.image_url || draft.image_local, pubAt]);
await query(`
UPDATE post_drafts
SET status='approved', scheduled_at=$1, reviewed_at=NOW()
WHERE id=$2
`, [pubAt, draftId]);
return { scheduled_post_id: sp.id, scheduled_at: pubAt };
}
/**
* Отклонить черновик.
*/
async function rejectDraft(draftId) {
await query(`
UPDATE post_drafts SET status='rejected', reviewed_at=NOW() WHERE id=$1
`, [draftId]);
}
/**
* Обновить текст черновика.
*/
async function updateDraft(draftId, { text, imageUrl }) {
const sets = [];
const vals = [];
if (text !== undefined) { sets.push(`text=$${vals.push(text)}`); }
if (imageUrl !== undefined) { sets.push(`image_url=$${vals.push(imageUrl)}`); }
if (!sets.length) return;
vals.push(draftId);
await query(`UPDATE post_drafts SET ${sets.join(',')} WHERE id=$${vals.length}`, vals);
}
/**
* Список черновиков для канала или пользователя.
*/
async function listDrafts({ channelId, userId, status = 'pending', limit = 30, offset = 0 }) {
const cond = [];
const vals = [];
if (channelId) { cond.push(`d.channel_id=$${vals.push(channelId)}`); }
if (userId) { cond.push(`c.user_id=$${vals.push(userId)}`); }
if (status !== 'all') { cond.push(`d.status=$${vals.push(status)}`); }
const where = cond.length ? 'WHERE ' + cond.join(' AND ') : '';
const { rows } = await query(`
SELECT d.*, c.name as channel_name, c.platform, c.tg_username
FROM post_drafts d
JOIN channels c ON c.id = d.channel_id
${where}
ORDER BY d.created_at DESC
LIMIT $${vals.push(limit)} OFFSET $${vals.push(offset)}
`, vals);
const countVals = vals.slice(0, -2);
const { rows: [{ total }] } = await query(
`SELECT count(*)::int as total FROM post_drafts d JOIN channels c ON c.id=d.channel_id ${where}`,
countVals
);
return { drafts: rows, total };
}
module.exports = {
generateOneDraft, generateBatch, generateDailyDrafts,
approveDraft, rejectDraft, updateDraft, listDrafts,
};