fix(photo-ai): отдавать health фото-сервиса в статусе заданий

В 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 двумя проверками.
This commit is contained in:
dev
2026-09-28 23:38:32 +03:00
parent 403574fe79
commit 1a25ce7170
2 changed files with 36 additions and 2 deletions
+11
View File
@@ -188,6 +188,17 @@ async function main() {
}); });
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config; const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg); 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, { ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
worker_ai_url: workerCfg && workerCfg.ai_url, worker_ai_url: workerCfg && workerCfg.ai_url,
ai_url: photoStatus.data.ai_url, ai_url: photoStatus.data.ai_url,
+25 -2
View File
@@ -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) => { app.get('/api/ai/status', requireAdmin, async (_, res) => {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status` `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 }; const counts = { pending: 0, processing: 0, done: 0, error: 0 };
rows.forEach(r => { counts[r.status] = r.n; }); 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( pool.query(
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name `SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
FROM photo_jobs j FROM photo_jobs j
@@ -5820,13 +5842,14 @@ app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => {
WHERE j.status = 'error' WHERE j.status = 'error'
ORDER BY j.finished_at DESC NULLS LAST, j.id DESC LIMIT 20` 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 enabled = String(await getSetting('photo_worker_enabled', 'true')) !== 'false';
const service = await aiHealthCheck();
res.json({ res.json({
enabled, enabled,
ai_configured: !!PHOTO_AI_URL, ai_configured: !!PHOTO_AI_URL,
ai_url: PHOTO_AI_URL, ai_url: PHOTO_AI_URL,
service,
worker: photoWorker ? photoWorker.getInfo() : null, worker: photoWorker ? photoWorker.getInfo() : null,
counts, counts,
pending: pending.rows, pending: pending.rows,