feat: SMTP, maintenance mode, blog topic bank UI

8. SMTP: emailService.js (nodemailer), templates (welcome/payment/low_credits)
   /api/admin/email/test — тест отправки
   app_settings category=smtp (HOST/PORT/USER/PASS/FROM/ENABLED)
9. Maintenance mode: middleware в index.js, MAINTENANCE_MODE в engine settings
   При true → 503 для всех запросов кроме /uploads и /api/settings
10. Blog topic bank:
   DB: blog_topics(category,topic,is_used,source,priority)
   40 тем мигрированы из хардкода (source=hardcoded)
   autogen.js: getNextTopic берёт из DB, fallback на TOPIC_BANK
   admin API: GET/POST /blog-topics, DELETE /:id, POST /generate (AI +10)
This commit is contained in:
Ник (Claude)
2026-06-13 11:45:23 +03:00
parent 9b40f2cd7a
commit c40ef90ad1
6 changed files with 282 additions and 21 deletions
+14
View File
@@ -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';
+10
View File
@@ -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",
+1
View File
@@ -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"
}
+124
View File
@@ -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: '<p>Если ты видишь это письмо — SMTP настроен правильно!</p>',
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}`); }
});
+22 -21
View File
@@ -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: [] };
+111
View File
@@ -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 <noreply@zeropost.ru>'),
settings.get('SMTP_ENABLED', 'false'),
]);
if (enabled !== 'true') return null;
if (!host || !user) return null;
const hash = `${host}:${port}:${user}:${pass}`;
if (_transporter && hash === _configHash) return _transporter;
_transporter = nodemailer.createTransport({
host, port: parseInt(port),
secure: parseInt(port) === 465,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
_configHash = hash;
return _transporter;
}
/**
* Отправить email.
* @param {string} to — адрес получателя
* @param {string} subject — тема
* @param {string} html — HTML тело
* @param {string} [text] — plain text fallback
*/
async function send({ to, subject, html, text }) {
const transporter = await getTransporter();
if (!transporter) {
console.log(`[Email] SMTP disabled or not configured, skip: ${subject}${to}`);
return { skipped: true };
}
const from = await settings.get('SMTP_FROM', 'ZeroPost <noreply@zeropost.ru>');
try {
const info = await transporter.sendMail({ from, to, subject, html, text });
console.log(`[Email] sent: ${subject}${to} (${info.messageId})`);
return { ok: true, messageId: info.messageId };
} catch (err) {
console.error(`[Email] send error: ${err.message}`);
return { error: err.message };
}
}
/**
* Шаблоны уведомлений
*/
const templates = {
welcome({ email, credits }) {
return {
subject: 'Добро пожаловать в ZeroPost!',
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Привет! 👋</h2>
<p>Рады видеть тебя в ZeroPost.</p>
<p>На твой счёт зачислено <b>${credits} кредитов</b> для начала работы.</p>
<p><a href="https://app.zeropost.ru" style="color:#6366f1">Открыть приложение →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
payment_success({ amount, plan, email }) {
return {
subject: `✅ Оплата ${amount}₽ прошла успешно`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Оплата подтверждена</h2>
<p>Тариф <b>${plan}</b> активирован.</p>
<p>Сумма: <b>${amount}₽</b></p>
<p><a href="https://app.zeropost.ru/billing" style="color:#6366f1">История платежей →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
low_credits({ credits, email }) {
return {
subject: '⚠️ Кредиты заканчиваются',
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>Осталось ${credits} кредитов</h2>
<p>Пополни баланс чтобы продолжить генерацию контента.</p>
<p><a href="https://app.zeropost.ru/plans" style="color:#6366f1">Выбрать тариф →</a></p>
<hr style="margin:24px 0;border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">ZeroPost · Автоматизация контента</p>
</div>
`,
};
},
};
module.exports = { send, templates };