From a8ff295faaa240e70d1cd6ecbb91aa9cff87800a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Fri, 12 Jun 2026 23:47:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20post=20drafts=20system=20=E2=80=94=20ba?= =?UTF-8?q?tch=20generation=20+=20daily=20auto-drafts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 14 ++- src/routes/drafts.js | 101 ++++++++++++++++ src/services/channels.js | 3 +- src/services/draftService.js | 224 +++++++++++++++++++++++++++++++++++ 4 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 src/routes/drafts.js create mode 100644 src/services/draftService.js diff --git a/index.js b/index.js index 88a7b46..481a88d 100644 --- a/index.js +++ b/index.js @@ -106,7 +106,8 @@ app.use('/api/metrics', metricsRoutes); app.use('/api/usage', usageRoutes); app.use('/api/billing', require('./src/routes/billing')); app.use('/api/channels', require('./src/routes/polls')); -app.use('/api', inboxRoutes); // /inbox/:id, /inbox/:id/reply, /tg-webhook/:channelId +app.use('/api', inboxRoutes); +app.use('/api', require('./src/routes/drafts')); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); @@ -120,6 +121,17 @@ const start = async () => { // Автоматический ретрай SVG-заглушек require('./src/services/coverRetry').start(); + // Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled) + const draftSvc = require('./src/services/draftService'); + setInterval(async () => { + try { + const n = await draftSvc.generateDailyDrafts(); + if (n > 0) console.log(`[Drafts] Daily auto-drafts: generated for ${n} channels`); + } catch (err) { console.error('[Drafts] daily error:', err.message); } + }, 30 * 60 * 1000); + // Первый запуск через 5 мин после старта + setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000); + app.listen(config.port, () => { console.log(`[Engine] Running on port ${config.port}`); }); diff --git a/src/routes/drafts.js b/src/routes/drafts.js new file mode 100644 index 0000000..19ecf29 --- /dev/null +++ b/src/routes/drafts.js @@ -0,0 +1,101 @@ +/** + * drafts.js — API для черновиков постов. + * + * POST /api/channels/:channelId/drafts/generate?count=3 — batch генерация + * GET /api/drafts — все черновики юзера + * GET /api/drafts/:channelId/channel — черновики канала + * PATCH /api/drafts/:id — редактировать текст + * POST /api/drafts/:id/approve — одобрить → scheduled_post + * POST /api/drafts/:id/reject — отклонить + * DELETE /api/drafts/:id — удалить + */ +const express = require('express'); +const router = express.Router(); +const { query } = require('../config/db'); +const channelsSvc = require('../services/channels'); +const draftSvc = require('../services/draftService'); + +function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } + +// POST /api/channels/:channelId/drafts/generate +router.post('/channels/:channelId/drafts/generate', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + + const count = Math.min(parseInt(req.query.count || req.body.count || 3), 10); + const withImage = req.body.withImage !== false; + + try { + const channel = await channelsSvc.getFullChannel(req.params.channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + + // Запускаем асинхронно — отвечаем сразу + res.json({ ok: true, message: `Генерирую ${count} черновиков...`, count }); + + draftSvc.generateBatch(channel, { count, userId, withImage }) + .then(r => console.log(`[drafts] batch done ch=${channel.id}: ${r.generated} ok, ${r.errors.length} err`)) + .catch(e => console.error(`[drafts] batch error: ${e.message}`)); + } catch (err) { + if (!res.headersSent) res.status(500).json({ error: err.message }); + } +}); + +// GET /api/drafts — все черновики текущего пользователя +router.get('/drafts', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + const { status = 'pending', limit = 30, offset = 0 } = req.query; + try { + const result = await draftSvc.listDrafts({ userId, status, limit: parseInt(limit), offset: parseInt(offset) }); + res.json(result); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/drafts/:channelId/channel — черновики конкретного канала +router.get('/drafts/:channelId/channel', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + const { status = 'pending', limit = 30, offset = 0 } = req.query; + try { + const result = await draftSvc.listDrafts({ channelId: req.params.channelId, status, limit: parseInt(limit), offset: parseInt(offset) }); + res.json(result); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// PATCH /api/drafts/:id — редактировать +router.patch('/drafts/:id', async (req, res) => { + try { + await draftSvc.updateDraft(req.params.id, req.body); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/drafts/:id/approve +router.post('/drafts/:id/approve', async (req, res) => { + const userId = uid(req); + try { + const result = await draftSvc.approveDraft(req.params.id, { + scheduledAt: req.body.scheduled_at, + userId, + }); + res.json({ ok: true, ...result }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/drafts/:id/reject +router.post('/drafts/:id/reject', async (req, res) => { + try { + await draftSvc.rejectDraft(req.params.id); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/drafts/:id +router.delete('/drafts/:id', async (req, res) => { + try { + await query('DELETE FROM post_drafts 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/channels.js b/src/services/channels.js index a9f2a04..96b49e7 100644 --- a/src/services/channels.js +++ b/src/services/channels.js @@ -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(', '); diff --git a/src/services/draftService.js b/src/services/draftService.js new file mode 100644 index 0000000..c09e18d --- /dev/null +++ b/src/services/draftService.js @@ -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, +};