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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user