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:
@@ -106,7 +106,8 @@ app.use('/api/metrics', metricsRoutes);
|
|||||||
app.use('/api/usage', usageRoutes);
|
app.use('/api/usage', usageRoutes);
|
||||||
app.use('/api/billing', require('./src/routes/billing'));
|
app.use('/api/billing', require('./src/routes/billing'));
|
||||||
app.use('/api/channels', require('./src/routes/polls'));
|
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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
||||||
@@ -120,6 +121,17 @@ const start = async () => {
|
|||||||
// Автоматический ретрай SVG-заглушек
|
// Автоматический ретрай SVG-заглушек
|
||||||
require('./src/services/coverRetry').start();
|
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, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`[Engine] Running on port ${config.port}`);
|
console.log(`[Engine] Running on port ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -116,7 +116,8 @@ async function updateChannel(channelId, userId, data) {
|
|||||||
if (Object.keys(channelFields).length) {
|
if (Object.keys(channelFields).length) {
|
||||||
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
|
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
|
||||||
'niche', 'audience', 'goal', 'language', 'region', 'is_active',
|
'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);
|
const updates = fields.filter(f => channelFields[f] !== undefined);
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
|
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user