diff --git a/index.js b/index.js index 67cd842..bf9c72b 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,20 @@ require('./src/services/metricsCollector').startAutoCollect(); const app = express(); app.use(express.json()); +// ── Maintenance mode middleware ────────────────────────────── +app.use((req, res, next) => { + // Пропускаем статику и admin endpoints (чтобы можно было отключить режим) + if (req.path.startsWith('/uploads') || req.path.startsWith('/api/settings')) return next(); + const settings = require('./src/services/settings'); + settings.get('MAINTENANCE_MODE', 'false').then(val => { + if (val === 'true' && !req.headers['x-internal-secret']) { + settings.get('MAINTENANCE_MESSAGE', 'Ведутся технические работы').then(msg => { + res.status(503).json({ error: msg, code: 'MAINTENANCE' }); + }); + } else next(); + }).catch(() => next()); +}); + // Раздача загруженных файлов (обложки статей и т.п.) const path = require('path'); const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; diff --git a/package-lock.json b/package-lock.json index 8a1cdde..86af9d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "fast-xml-parser": "^4.5.6", "ioredis": "^5.11.0", "node-cron": "^4.2.1", + "nodemailer": "^8.0.11", "pg": "^8.21.0", "sharp": "^0.34.5" } @@ -1661,6 +1662,15 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", diff --git a/package.json b/package.json index 562046c..f50b507 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "fast-xml-parser": "^4.5.6", "ioredis": "^5.11.0", "node-cron": "^4.2.1", + "nodemailer": "^8.0.11", "pg": "^8.21.0", "sharp": "^0.34.5" } diff --git a/src/routes/admin.js b/src/routes/admin.js index 2ede442..3eecbac 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -490,3 +490,127 @@ router.delete('/autogen/queue/:id', async (req, res) => { res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); + +// ── EMAIL ──────────────────────────────────────────────────── + +// POST /api/admin/email/test — тестовая отправка +router.post('/email/test', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { to } = req.body; + if (!to) return res.status(400).json({ error: 'to обязателен' }); + try { + const email = require('../services/emailService'); + const result = await email.send({ + to, + subject: '✅ ZeroPost SMTP тест', + html: '
Если ты видишь это письмо — SMTP настроен правильно!
', + text: 'Если ты видишь это письмо — SMTP настроен правильно!', + }); + if (result.skipped) return res.json({ ok: false, message: 'SMTP отключён или не настроен' }); + if (result.error) return res.status(500).json({ error: result.error }); + res.json({ ok: true, messageId: result.messageId }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// ── BLOG TOPIC BANK ────────────────────────────────────────── + +// GET /api/admin/blog-topics — список тем по категории +router.get('/blog-topics', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { category, includeUsed = 'false', limit = 100 } = req.query; + try { + const where = category ? 'WHERE bt.category=$1' : ''; + const args = category ? [category] : []; + + const { rows } = await query(` + SELECT bt.*, + EXISTS(SELECT 1 FROM articles a WHERE a.source_topic=bt.topic) as is_published + FROM blog_topics bt + ${where} + ${includeUsed !== 'true' ? (where ? 'AND' : 'WHERE') + ' bt.is_used=false' : ''} + ORDER BY bt.priority DESC, bt.created_at ASC + LIMIT ${parseInt(limit)} + `, args); + + // Статистика по категориям + const { rows: stats } = await query(` + SELECT category, + count(*)::int as total, + count(*) FILTER (WHERE is_used=false)::int as unused + FROM blog_topics GROUP BY category + `); + + res.json({ topics: rows, stats }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/admin/blog-topics — добавить тему +router.post('/blog-topics', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { category, topic, tags = [], priority = 5 } = req.body; + if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' }); + try { + const { rows: [row] } = await query( + `INSERT INTO blog_topics (category, topic, tags, priority, source) + VALUES ($1,$2,$3,$4,'manual') ON CONFLICT DO NOTHING RETURNING *`, + [category, topic.trim(), tags, priority] + ); + res.json(row || { error: 'Такая тема уже есть' }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/admin/blog-topics/:id +router.delete('/blog-topics/:id', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + await query('DELETE FROM blog_topics WHERE id=$1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/admin/blog-topics/generate — AI генерация новых тем для категории +router.post('/blog-topics/generate', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { category, count = 10 } = req.body; + if (!category) return res.status(400).json({ error: 'category обязателен' }); + try { + res.json({ ok: true, message: `Генерирую ${count} тем для ${category}...` }); + // Берём уже существующие темы для дедупликации + const { rows: existing } = await query('SELECT topic FROM blog_topics WHERE category=$1', [category]); + const existingTopics = existing.map(r => r.topic).join('\n'); + + const ai = require('../services/ai'); + const config = require('../config'); + const CATEGORY_NAMES = { + 'ai-tools': 'AI инструменты для работы и бизнеса', + 'ai-dev': 'AI разработка и программирование', + 'automation': 'Автоматизация процессов', + 'cybersec': 'Кибербезопасность', + }; + + const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${CATEGORY_NAMES[category] || category}". +Темы должны быть: конкретными, практическими, интересными читателям. +Формат: точные заголовки статей, не категории. +Ответь ТОЛЬКО JSON-массивом строк без markdown.`; + + const userMsg = `Придумай ${count} уникальных тем.${existingTopics ? `\n\nИзбегай повторений:\n${existingTopics.slice(0,800)}` : ''}`; + + const result = await ai.chat( + config.ai.models.topics || 'claude-haiku-4-5-20251001', + system, userMsg, 0.9, 600 + ); + + const topics = JSON.parse(result.replace(/```json|```/g, '').trim()); + let added = 0; + for (const topic of topics.slice(0, count)) { + if (!topic?.trim()) continue; + const { rows: [row] } = await query( + `INSERT INTO blog_topics (category, topic, source) + VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING RETURNING id`, + [category, topic.trim()] + ); + if (row) added++; + } + console.log(`[BlogTopics] AI generated ${added} topics for ${category}`); + } catch (err) { console.error(`[BlogTopics] generate error: ${err.message}`); } +}); diff --git a/src/services/autogen.js b/src/services/autogen.js index 6696a00..71ab274 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -59,7 +59,7 @@ const TOPIC_BANK = { * Берёт следующую тему из очереди или из банка тем. */ async function getNextTopic(category) { - // Сначала из очереди (по приоритету) + // 1. Приоритетная очередь (content_queue) const { rows } = await query( `SELECT * FROM content_queue WHERE category=$1 AND status='pending' @@ -69,31 +69,32 @@ async function getNextTopic(category) { if (rows.length) { return { id: rows[0].id, topic: rows[0].topic, tags: rows[0].tags || [], keywords: rows[0].keywords || [] }; } - // Из банка — темы которые ещё не использовались - const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools']; - // Получаем уже использованные темы по source_topic (точное совпадение) + // 2. DB-банк тем — неиспользованные + const { rows: dbTopics } = await query(` + SELECT bt.id, bt.topic FROM blog_topics bt + WHERE bt.category = $1 + AND bt.is_used = false + AND NOT EXISTS ( + SELECT 1 FROM articles a + WHERE a.source_topic = bt.topic AND a.category = $1 + ) + ORDER BY bt.priority DESC, bt.created_at ASC + LIMIT 1 + `, [category]); + + if (dbTopics.length) { + return { id: null, topic: dbTopics[0].topic, tags: [], keywords: [], blog_topic_id: dbTopics[0].id }; + } + + // 3. Fallback: хардкод если DB пустой + const bank = TOPIC_BANK[category] || TOPIC_BANK['ai-tools']; const { rows: usedTopics } = await query( `SELECT source_topic FROM articles WHERE category=$1 AND source_topic IS NOT NULL`, [category] ); - const usedSet = new Set(usedTopics.map(r => r.source_topic.toLowerCase().trim())); - - // Также проверяем по заголовкам (fallback для старых статей без source_topic) - const { rows: usedTitles } = await query( - `SELECT title FROM articles WHERE category=$1 AND source_topic IS NULL AND status='published'`, - [category] - ); - const titlesLower = usedTitles.map(r => r.title.toLowerCase()); - - const unused = bank.filter(t => { - const tLow = t.toLowerCase().trim(); - if (usedSet.has(tLow)) return false; - // Fallback: проверяем по первым 30 символам заголовка - if (titlesLower.some(title => title.includes(tLow.slice(0, 30)))) return false; - return true; - }); - + const usedSet = new Set(usedTopics.map(r => r.source_topic?.toLowerCase().trim()).filter(Boolean)); + const unused = bank.filter(t => !usedSet.has(t.toLowerCase().trim())); const pool = unused.length > 0 ? unused : bank; const topic = pool[Math.floor(Math.random() * pool.length)]; return { id: null, topic, tags: [], keywords: [] }; diff --git a/src/services/emailService.js b/src/services/emailService.js new file mode 100644 index 0000000..d97b2c9 --- /dev/null +++ b/src/services/emailService.js @@ -0,0 +1,111 @@ +/** + * emailService.js — отправка email уведомлений через SMTP. + * Использует nodemailer. Настройки из app_settings (category=smtp). + */ +const nodemailer = require('nodemailer'); +const settings = require('./settings'); + +let _transporter = null; +let _configHash = null; + +async function getTransporter() { + const [host, port, user, pass, from, enabled] = await Promise.all([ + settings.get('SMTP_HOST', ''), + settings.get('SMTP_PORT', '587'), + settings.get('SMTP_USER', ''), + settings.get('SMTP_PASS', ''), + settings.get('SMTP_FROM', 'ZeroPostРады видеть тебя в ZeroPost.
+На твой счёт зачислено ${credits} кредитов для начала работы.
+ +ZeroPost · Автоматизация контента
+Тариф ${plan} активирован.
+Сумма: ${amount}₽
+ +ZeroPost · Автоматизация контента
+Пополни баланс чтобы продолжить генерацию контента.
+ +ZeroPost · Автоматизация контента
+