From 1a25ce71702174b4809cdf8006232c50dca72f97 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 28 Sep 2026 23:38:32 +0300 Subject: [PATCH] =?UTF-8?q?fix(photo-ai):=20=D0=BE=D1=82=D0=B4=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20health=20=D1=84=D0=BE=D1=82=D0=BE-=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20=D0=B2=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В GET /api/photo-jobs/status вызывался aiHealthCheck() — это health текстового ИИ, — а результат в ответ не попадал: поле service отсутствовало, и оператор не видел состояние photo-ai. Добавлена photoAiHealth(): GET ${PHOTO_AI_URL}/health с таймаутом 5 с, без исключений; пустой PHOTO_AI_URL -> {configured:false, reachable:false}, обрыв или таймаут -> {configured:true, reachable:false, error}. Вызов уходит в тот же Promise.all, что и запросы к БД, поэтому статус не получает лишние 5 с. Контракт: {configured, reachable, latency_ms, error} + passthrough полей photo-ai; первые четыре совпадают с контрактом текстового ИИ, который уже читает public/js/worker.js. Контракт зафиксирован в api.smoketest.js двумя проверками. --- api.smoketest.js | 11 +++++++++++ server.js | 27 +++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/api.smoketest.js b/api.smoketest.js index aebca0b..c9c4d99 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -188,6 +188,17 @@ async function main() { }); const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config; ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg); + // service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает + // с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен. + const photoSvc = photoStatus.data.service; + ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', { + service: photoSvc, + ai_configured: photoStatus.data.ai_configured, + }); + ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', { + ai_configured: photoStatus.data.ai_configured, + reachable: photoSvc.reachable, + }); ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, { worker_ai_url: workerCfg && workerCfg.ai_url, ai_url: photoStatus.data.ai_url, diff --git a/server.js b/server.js index 13de00f..ec88c14 100644 --- a/server.js +++ b/server.js @@ -5655,6 +5655,28 @@ async function aiHealthCheck() { } } +async function photoAiHealth() { + if (!PHOTO_AI_URL) return { configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }; + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const r = await fetch(`${PHOTO_AI_URL}/health`, { signal: controller.signal }); + const latency_ms = Date.now() - startedAt; + if (!r.ok) return { configured: true, reachable: false, latency_ms, error: `HTTP ${r.status}` }; + let data = null; + try { data = await r.json(); } catch (e) { data = null; } + const payload = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + return { ...payload, configured: true, reachable: true, latency_ms, error: null }; + } catch (e) { + const latency_ms = Date.now() - startedAt; + const error = e && e.name === 'AbortError' ? 'timeout' : (e && e.message ? e.message : 'unreachable'); + return { configured: true, reachable: false, latency_ms, error }; + } finally { + clearTimeout(timer); + } +} + app.get('/api/ai/status', requireAdmin, async (_, res) => { const { rows } = await pool.query( `SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status` @@ -5787,7 +5809,7 @@ app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => { ); const counts = { pending: 0, processing: 0, done: 0, error: 0 }; rows.forEach(r => { counts[r.status] = r.n; }); - const [pending, recentTotal, recent, errors] = await Promise.all([ + const [pending, recentTotal, recent, errors, service] = await Promise.all([ pool.query( `SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name FROM photo_jobs j @@ -5820,13 +5842,14 @@ app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => { WHERE j.status = 'error' ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20` ), + photoAiHealth(), ]); const enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false'; - const service = await aiHealthCheck(); res.json({ enabled, ai_configured: !!PHOTO_AI_URL, ai_url: PHOTO_AI_URL, + service, worker: photoWorker ? photoWorker.getInfo() : null, counts, pending: pending.rows,