feat(api): управление ИИ-воркерами через внешний API

Внешние системы не могли разбудить воркер, переочередить упавшие
задания или отправить запись на повторную ИИ-проверку: все эти роуты
существовали только во внутреннем API под requireAdmin.

Добавлено на apiV1 (все под apiWrite('write')):
- POST /ai/wake, /photo-jobs/wake — пинок воркеров
- POST /ai/requeue-failed, /photo-jobs/requeue-failed — error -> pending
- POST /entries/:id/ai/recheck — повторная проверка конкретной записи

Филиальная изоляция (главное в этом изменении):
- внутренние requeue-failed делают UPDATE по всей таблице; перенос их
  как есть позволил бы ключу с ограничением по филиалу переочередить
  чужие задания, что ломает правило «ключ не шире выдавшего»
- добавлен хелпер apiBranchClause(user, expr, params): пустая строка
  для admin, AND FALSE при пустом списке филиалов, иначе
  AND <expr> = ANY($N::int[]); применён к обоим массовым UPDATE
- entries фильтруется через groups.branch_id, photo_jobs — через
  photo_jobs -> entries -> groups

Аудит через apiAudit() с префиксом api., метки добавлены в
public/js/audit.js; после мутаций invalidateEntries/invalidateStats
и broadcastEntryChanged.

Воркер отчётов о занятии wake-эндпоинта не получает: он будится сам
из POST/PUT /lesson-reports при ai_check === true.

Документация: таблица эндпоинтов и раздел про воркеров в README.md,
правило apiBranchClause в AGENTS.md 3f.

Проверено: изолированный тест на двух филиалах — requeue-failed
ключом одного филиала вернул count 1 из двух ошибочных заданий,
запись и фото-джоб чужого филиала остались в error, recheck чужой
записи 403; api-keys.selftest.js 61 PASS, api.smoketest.js 76 PASS,
регрессий нет.

Замечание: server.js запечён в образ, compose монтирует только
uploads/, поэтому restart правку не подхватит — нужен
./scripts/deploy.sh или docker compose up -d --build app.
This commit is contained in:
dev
2026-10-05 00:06:38 +03:00
parent cd40260b68
commit 5667198c9b
5 changed files with 138 additions and 0 deletions
+68
View File
@@ -7520,6 +7520,74 @@ apiV1.put('/students/:id', apiWrite('write'), async (req, res) => {
res.json(rows[0]);
});
function apiBranchClause(user, expr, params) {
const s = branchScope(user);
if (s.admin) return '';
if (!s.ids.length) return ' AND FALSE';
params.push(s.ids);
return ` AND ${expr} = ANY($${params.length}::int[])`;
}
apiV1.post('/ai/wake', apiWrite('write'), async (req, res) => {
if (entryAutoChecker) entryAutoChecker.notify();
await apiAudit(req, 'api.ai.wake', {});
res.json({ ok: true });
});
apiV1.post('/ai/requeue-failed', apiWrite('write'), async (req, res) => {
const params = [];
const scope = apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)', params);
const { rowCount } = await pool.query(
`UPDATE entries e SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL
WHERE e.ai_status = 'error' AND e.deleted_at IS NULL${scope}`,
params
);
if (entryAutoChecker) entryAutoChecker.notify();
await apiAudit(req, 'api.ai.requeue-failed', { count: rowCount });
invalidateEntries();
invalidateStats();
res.json({ ok: true, count: rowCount });
});
apiV1.post('/photo-jobs/wake', apiWrite('write'), async (req, res) => {
if (photoWorker) photoWorker.notify();
await apiAudit(req, 'api.photo-jobs.wake', {});
res.json({ ok: true });
});
apiV1.post('/photo-jobs/requeue-failed', apiWrite('write'), async (req, res) => {
const params = [];
const scope = apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g JOIN entries e ON e.id = p.entry_id WHERE g.id = e.group_id)', params);
const { rowCount } = await pool.query(
`UPDATE photo_jobs p SET status = 'pending', error = NULL, finished_at = NULL
WHERE p.status = 'error'${scope}`,
params
);
if (photoWorker) photoWorker.notify();
await apiAudit(req, 'api.photo-jobs.requeue-failed', { count: rowCount });
invalidateEntries();
res.json({ ok: true, count: rowCount });
});
apiV1.post('/entries/:id/ai/recheck', apiWrite('write'), async (req, res) => {
if (req.user.role !== 'admin') {
const acc = await entryAccessible(req.user, req.params.id);
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rowCount } = await pool.query(
`UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1`,
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: 'Запись не найдена' });
if (entryAutoChecker) entryAutoChecker.notify();
await apiAudit(req, 'api.entry.ai.recheck', { id: req.params.id });
invalidateEntries();
invalidateStats();
broadcastEntryChanged();
res.json({ ok: true });
});
// --- Error handlers ---
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');