Files
postcast-engine/index.js
T
Aleksei Pavlov 4ec3239dc3 feat(postcast): система категорий + банк тем + гибкая ротация
Новая архитектура автогенерации (перенос и доработка из ZeroPost):

БД (3 новые таблицы + поля в posts):
  channel_categories — категории принадлежат каналу пользователя.
    CRUD по slug (уникален в рамках канала), цвет, иконка, sort_order.
  category_topics — банк тем с жанровыми маркерами:
    [ТУТОРИАЛ][СРАВНЕНИЕ][МНЕНИЕ][ДАЙДЖЕСТ][КЕЙС][НОВОСТЬ]
    genre: detected auto или задан явно.
    Атомарный захват через UPDATE...FOR UPDATE SKIP LOCKED (нет дублей).
  channel_autogen_settings — настройки per-канал:
    posts_per_day: 1-20 (пользователь выбирает сам, 3 по умолчанию)
    run_hour/run_minute, rotation_mode, last_run_at
  best_time_stats — заготовка под аналитику лучшего времени.
  posts: +source_topic, +source_category_id, +genre

Ротация (src/services/autogenNew.js):
  getTodayCategoryIds: скользящее окно размером posts_per_day.
  Если категорий <= posts_per_day — берём все.
  Если больше — сдвиг на 1 каждый день (dayOfYear % total).
  Пример: 8 категорий, 3 поста/день → каждый день другие 3 категории.
  Предпросмотр: GET /api/channels/:id/autogen/rotation?days=7

Фиксы из ZeroPost (не будет тех же ошибок):
  pg_advisory_lock по (channel_id, category_id) — нет параллельных дублей
  Двойная проверка после lock: уже генерировали сегодня?
  Промпт учитывает жанр ([ТУТОРИАЛ] → пошаговый гайд и т.д.)
  generateTopicsForCategory: AI генерит N тем с равномерным распределением жанров

API routes:
  GET/POST/PATCH/DELETE /api/channels/:id/categories
  GET/POST/PATCH/DELETE /api/channels/:id/categories/:catId/topics
  POST /api/channels/:id/categories/:catId/topics/generate (AI, async)
  GET/POST/PATCH        /api/channels/:id/autogen
  POST                  /api/channels/:id/autogen/run
  GET                   /api/channels/:id/autogen/today (черновики за сегодня)
  GET                   /api/channels/:id/autogen/rotation (preview на N дней)
2026-06-24 19:22:36 +03:00

161 lines
7.1 KiB
JavaScript

const express = require('express');
const config = require('./src/config');
const { migrate } = require('./src/config/db');
// Routes
const generateRoutes = require('./src/routes/generate');
const channelsRoutes = require('./src/routes/channels');
const postsRoutes = require('./src/routes/posts');
const articlesRoutes = require('./src/routes/articles');
const statsRoutes = require('./src/routes/stats');
const notesRoutes = require('./src/routes/notes');
const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories');
const autogenRoutes = require('./src/routes/autogen');
const userPostsRoutes = require('./src/routes/userPosts');
const settingsRoutes = require('./src/routes/settings');
const photoSearchRoutes = require('./src/routes/photo-search');
const scheduledPostsRoutes = require('./src/routes/scheduledPosts');
const channelStatsRoutes = require('./src/routes/channelStats');
const calendarRoutes = require('./src/routes/calendar');
const channelCategoriesRoutes = require('./src/routes/channelCategories');
const channelAutogenRoutes = require('./src/routes/channelAutogen');
const metricsRoutes = require('./src/routes/metrics');
const usageRoutes = require('./src/routes/usage');
// Start queue worker
require('./src/workers/generation');
// Metrics collector
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';
require('fs').mkdirSync(UPLOADS_DIR, { recursive: true });
// Public uploads — ДО auth-middleware, без секрета
app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d', immutable: true }));
// Публичные роуты (без auth)
app.get('/api/billing/plans', async (req, res) => {
const { query: q } = require('./src/config/db');
const { rows: plans } = await q('SELECT * FROM plans WHERE is_active=true ORDER BY sort_order');
const { rows: costs } = await q('SELECT * FROM credit_costs ORDER BY operation');
res.json({ plans, costs });
});
// ЮKassa webhook — публичный, без internal secret
app.post('/api/billing/webhook',
express.json({ type: '*/*' }),
require('./src/routes/billing').handle || ((req, res, next) => {
require('./src/services/yukassa').handleWebhook(req.body)
.then(r => res.json({ ok: true, ...r }))
.catch(err => res.status(500).json({ error: err.message }));
})
);
// TG webhook — публичный (TG не шлёт internal secret)
const inboxRoutes = require('./src/routes/inbox');
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
// Simple internal auth middleware
app.use((req, res, next) => {
const secret = req.headers['x-internal-secret'];
if (secret !== config.internalSecret) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
// AI usage context — приклеивает к каждому запросу service + user_id,
// чтобы сервисы (ai.js, covers.js, postImages.js, articleAutoSeries.js)
// логировали расход без явного проброса параметров.
const aiContext = require('./src/lib/aiContext');
app.use((req, res, next) => {
let service = 'zeropost-other';
// Блог-сторона zeropost.ru: статьи, серии, авто-публикация, темы.
if (/^\/api\/(articles|autogen|series|notes|categories|stats|posts|scheduled-posts|generate)/.test(req.path)) {
service = 'zeropost-blog';
// SaaS-сторона app.zeropost.ru: пользовательские посты, каналы, календарь, аналитика.
} else if (/^\/api\/(user-posts|calendar|channels|channel-stats|metrics|photo-search)/.test(req.path)) {
service = 'zeropost-tool';
}
const userIdRaw = req.headers['x-user-id'];
const userId = userIdRaw ? parseInt(userIdRaw, 10) || null : null;
aiContext.run({ service, userId }, () => next());
});
app.use('/api/generate', generateRoutes);
app.use('/api/channels', channelsRoutes);
app.use('/api/posts', postsRoutes);
app.use('/api/articles', articlesRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/notes', notesRoutes);
app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes);
app.use('/api/user-posts', userPostsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/photo-search', photoSearchRoutes);
app.use('/api/scheduled-posts', scheduledPostsRoutes);
app.use('/api/channel-stats', channelStatsRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/metrics', metricsRoutes);
app.use('/api/usage', usageRoutes);
app.use('/api/billing', require('./src/routes/billing'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/channels', require('./src/routes/polls'));
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() });
});
const start = async () => {
await migrate();
await config.reloadAi();
console.log('[Engine] AI config loaded from app_settings: text=' + config.ai.baseUrl + ', images=routerai.ru (' + (config.ai.routeraiModel || 'gpt-5-image-mini') + ')');
// Автоматический ретрай 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}`);
});
};
start().catch(err => {
console.error('[Engine] Failed to start:', err);
process.exit(1);
});