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:
@@ -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';
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: [] };
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user