forked from admin/zeropost-engine
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/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}`);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
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(', ');
|
||||
|
||||
@@ -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