From 4ffadc6baa44ab640742a21c019e5c13e29e2964 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 11:16:58 +0300 Subject: [PATCH] feat(zero): /config endpoints + dynamic scheduler hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/PATCH /api/admin/zero/config — read/write all Zero settings via app_settings - scheduler reads GENERATE_HOUR / APPROVE_HOUR dynamically (no restart needed) - generateDraft uses PUBLISH_HOUR for scheduled_at (was hardcoded 13) - requireAdmin softened — works both with and without users.is_admin column (prod has no is_admin; auth is provided by x-internal-secret + web cookie) --- src/routes/zeroAdmin.js | 67 +++++++++++++++++++++++++++++-- src/services/zeroNotes.js | 10 ++++- src/workers/zeroNotesScheduler.js | 18 +++++---- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/routes/zeroAdmin.js b/src/routes/zeroAdmin.js index 25288a5..303a264 100644 --- a/src/routes/zeroAdmin.js +++ b/src/routes/zeroAdmin.js @@ -14,11 +14,19 @@ const zPrompt = require('../services/zeroPrompt'); function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } async function requireAdmin(req, res) { + // x-internal-secret уже проверен глобальным middleware (см. index.js). + // Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим; + // если колонки нет (минимальный prod), доверяем секрету. const adminId = uid(req); - if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; } - const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); - if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; } - return adminId; + if (!adminId) return 'system'; // нет header — доверяем секрету + try { + const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); + if (u && u.is_admin === false) { res.status(403).json({ error: 'Forbidden' }); return null; } + return adminId; + } catch (err) { + // колонки is_admin нет — это нормально для prod конфига + return adminId; + } } // ─────────────────────────────────────────────────────────────────────────── @@ -206,4 +214,55 @@ router.post('/auto-approve', async (req, res) => { } }); + +// ─────────────────────────────────────────────────────────────────────────── +// CONFIG (app_settings) +// ─────────────────────────────────────────────────────────────────────────── + +const settings = require('../services/settings'); + +const CONFIG_KEYS = [ + { key: 'ZERO_NOTES_CHANNEL_IDS', default: '', description: 'csv int id каналов, для которых работают заметки Зеро' }, + { key: 'ZERO_NOTES_MODEL', default: '', description: 'модель для генерации (пусто = AI_MODEL_POST)' }, + { key: 'ZERO_NOTES_GENERATE_HOUR', default: '13', description: 'час генерации в МСК (0-23)' }, + { key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' }, + { key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' }, + { key: 'ZERO_SITE_URL_BASE', default: '', description: 'для inline-кнопки "Открыть на сайте"' }, +]; + +router.get('/config', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const out = {}; + for (const { key, default: def } of CONFIG_KEYS) { + out[key] = await settings.get(key, def); + } + out._enabled = !!(out.ZERO_NOTES_CHANNEL_IDS && String(out.ZERO_NOTES_CHANNEL_IDS).trim()); + out._keys_meta = CONFIG_KEYS; + res.json({ ok: true, config: out }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +router.patch('/config', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const body = req.body || {}; + const allowed = new Set(CONFIG_KEYS.map(k => k.key)); + const updated = {}; + for (const [k, v] of Object.entries(body)) { + if (!allowed.has(k)) continue; + const value = v == null ? null : String(v); + await query( + `INSERT INTO app_settings (key, value, category, description, updated_at) + VALUES ($1, $2, 'zero_notes', $3, NOW()) + ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=NOW()`, + [k, value, CONFIG_KEYS.find(c => c.key === k)?.description || null] + ); + updated[k] = value; + } + settings.invalidate(); + res.json({ ok: true, updated }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + module.exports = router; diff --git a/src/services/zeroNotes.js b/src/services/zeroNotes.js index 9af421c..356498b 100644 --- a/src/services/zeroNotes.js +++ b/src/services/zeroNotes.js @@ -68,6 +68,10 @@ async function getModel() { return await settings.get('ZERO_NOTES_MODEL', process.env.AI_MODEL_POST || 'claude-haiku-4-5-20251001'); } +async function getPublishHour() { + return parseInt(await settings.get('ZERO_NOTES_PUBLISH_HOUR', '13'), 10); +} + // ─────────────────────────────────────────────────────────────────────────── // ВЫБОРКИ // ─────────────────────────────────────────────────────────────────────────── @@ -177,8 +181,9 @@ async function generateDraft(channelId, opts = {}) { // 7. theme_hash для дедупа const themeHash = zPrompt.normalizeTheme(prompt.themeHint); - // 8. scheduled_at = ближайшее 13:00 МСК (если генерим в 13:00 сегодня → ставим на завтра) - const scheduledAt = nextPublishSlot({ publishHourMsk: 13 }); + // 8. scheduled_at = ближайший слот публикации в МСК (час берётся из настроек) + const publishHour = await getPublishHour(); + const scheduledAt = nextPublishSlot({ publishHourMsk: publishHour }); if (dryRun) { return { @@ -320,6 +325,7 @@ module.exports = { // настройки getActiveChannelIds, getModel, + getPublishHour, // утилиты времени nowMsk, nextPublishSlot, diff --git a/src/workers/zeroNotesScheduler.js b/src/workers/zeroNotesScheduler.js index 12fa1f2..ef4aa59 100644 --- a/src/workers/zeroNotesScheduler.js +++ b/src/workers/zeroNotesScheduler.js @@ -19,15 +19,15 @@ const TICK_MS = 60_000; // Отметка последнего успешного тика по slot'у: { generate: '2026-06-19T13:00', approve: '2026-06-19T07:00' } const lastRun = {}; -const GENERATE_HOUR_MSK = 13; -const APPROVE_HOUR_MSK = 7; +async function generateHourMsk() { return parseInt(await settings.get('ZERO_NOTES_GENERATE_HOUR', '13'), 10); } +async function approveHourMsk() { return parseInt(await settings.get('ZERO_NOTES_APPROVE_HOUR', '7'), 10); } function slotKey(ymd, hour) { return `${ymd}T${String(hour).padStart(2, '0')}:00`; } async function runGeneration(ymd) { - const key = slotKey(ymd, GENERATE_HOUR_MSK); + const key = slotKey(ymd, await generateHourMsk()); if (lastRun.generate === key) return; lastRun.generate = key; @@ -50,7 +50,7 @@ async function runGeneration(ymd) { } async function runAutoApprove(ymd) { - const key = slotKey(ymd, APPROVE_HOUR_MSK); + const key = slotKey(ymd, await approveHourMsk()); if (lastRun.approve === key) return; lastRun.approve = key; @@ -65,8 +65,9 @@ async function runAutoApprove(ymd) { async function tick() { const { hour, ymd } = zeroNotes.nowMsk(); try { - if (hour === GENERATE_HOUR_MSK) await runGeneration(ymd); - if (hour === APPROVE_HOUR_MSK) await runAutoApprove(ymd); + const [genHour, appHour] = [await generateHourMsk(), await approveHourMsk()]; + if (hour === genHour) await runGeneration(ymd); + if (hour === appHour) await runAutoApprove(ymd); // публикация approved-заметок в TG (каждую минуту) const published = await zeroRunner.publishReady({ limit: 3 }); if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`); @@ -85,8 +86,9 @@ function start() { intervalRef = setInterval(tick, TICK_MS); // первый тик через 30 сек после старта (даём engine стабильно подняться) setTimeout(tick, 30_000); - console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS / 1000}s, ` + - `generate=${GENERATE_HOUR_MSK}:00 MSK, auto-approve=${APPROVE_HOUR_MSK}:00 MSK`); + Promise.all([generateHourMsk(), approveHourMsk()]).then(([gh, ah]) => + console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS/1000}s, generate=${gh}:00 MSK, auto-approve=${ah}:00 MSK (dynamic)`) + ).catch(() => {}); } function stop() {