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
+51
View File
@@ -118,6 +118,39 @@ async function main() {
}
}
if (g) {
for (const p of ['/ai/wake', '/photo-jobs/wake', '/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
const r = await V1(rawKey, p, { method: 'POST' });
ok('api v1: ключ без scope write -> 403 на ' + p, r.status === 403, { status: r.status, error: r.data && r.data.error });
}
const rc = await V1(rawKey, '/entries/' + g.id + '/ai/recheck', { method: 'POST' });
ok('api v1: ключ без scope write -> 403 на recheck', rc.status === 403, { status: rc.status, error: rc.data && rc.data.error });
for (const p of ['/ai/wake', '/photo-jobs/wake']) {
const r = await V1(writeKey.data.key, p, { method: 'POST' });
ok('api v1: POST ' + p + ' со scope write -> 200', r.status === 200 && r.data.ok === true, { status: r.status, error: r.data && r.data.error });
}
for (const p of ['/ai/requeue-failed', '/photo-jobs/requeue-failed']) {
const r = await V1(writeKey.data.key, p, { method: 'POST' });
ok('api v1: POST ' + p + ' -> 200 с count', r.status === 200 && r.data.ok === true && typeof r.data.count === 'number', { status: r.status, data: r.data });
}
const rc404 = await V1(writeKey.data.key, '/entries/99999999/ai/recheck', { method: 'POST' });
ok('api v1: recheck несуществующей записи -> 404', rc404.status === 404, { status: rc404.status, error: rc404.data && rc404.data.error });
const ce2 = await V1(writeKey.data.key, '/entries', { method: 'POST', body: { student_name: studentName, group_id: g.id, description: 'Для recheck' } });
if (ce2.status === 201) {
const rc2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id + '/ai/recheck', { method: 'POST' });
ok('api v1: POST /entries/:id/ai/recheck -> 200', rc2.status === 200 && rc2.data.ok === true, { status: rc2.status, error: rc2.data && rc2.data.error });
const one2 = await V1(writeKey.data.key, '/entries/' + ce2.data.id);
ok('api v1: запись после recheck в очереди на ИИ-проверку',
one2.status === 200 && ['pending', 'processing'].includes(one2.data.ai_status),
{ status: one2.status, ai_status: one2.data && one2.data.ai_status });
await V1(writeKey.data.key, '/entries/' + ce2.data.id, { method: 'DELETE' });
} else {
ok('api v1: запись для проверки recheck создана', false, { status: ce2.status });
}
}
const rot = await api('/api/api-keys/' + writeKey.data.id + '/rotate', { token, method: 'POST' });
ok('api-keys: ротация выдаёт новый секрет', rot.status === 200 && typeof rot.data.key === 'string' && rot.data.key !== writeKey.data.key, rot.status);
ok('api v1: старый ключ мёртв после ротации', (await api('/api/v1/me', { apiKey: writeKey.data.key })).status === 401, null);
@@ -165,6 +198,24 @@ async function main() {
sc.data.user);
const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } });
ok('api-keys: несуществующий филиал -> 400', bad.status === 400, bad.status);
const adminGroups = await api('/api/groups?limit=500', { token });
const foreign = (adminGroups.data || []).find(x => x.branch_id && x.branch_id !== branches.data[0].id);
if (foreign) {
const wOnly = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { scopes: ['read', 'write'] } });
ok('api v1: ключу с филиалом выдан scope write', wOnly.status === 200, wOnly.status);
const own = await api('/api/groups?limit=1', { apiKey: only.data.key });
const rq = await V1(only.data.key, '/ai/requeue-failed', { method: 'POST' });
ok('api v1: requeue-failed ключом с филиалом -> 200, count число',
rq.status === 200 && typeof rq.data.count === 'number', { status: rq.status, data: rq.data });
ok('api v1: ключ с филиалом не видит чужие группы',
own.status === 200 && (own.data.items || []).every(x => x.branch_id === branches.data[0].id),
{ status: own.status, branchIds: (own.data.items || []).map(x => x.branch_id) });
const rp = await V1(only.data.key, '/photo-jobs/requeue-failed', { method: 'POST' });
ok('api v1: photo requeue-failed ключом с филиалом -> 200, count число',
rp.status === 200 && typeof rp.data.count === 'number', { status: rp.status, data: rp.data });
} else {
ok('api v1: есть группа чужого филиала для проверки', false, 'no foreign group');
}
await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' });
} else {
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');