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
+3
View File
@@ -153,6 +153,9 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- **Формат ответов `/api/v1`**: списки возвращают единый конверт `{ items, total, limit, offset }` (`apiList`) — в отличие от внутреннего API, где формы ответа разные (`{entries,total}`, `{modules,total}`, голый массив). Не смешивайте с внутренними хелперами
- **Мутации через API** аудитятся через `apiAudit()` — он добавляет `via_api_key: <id>` в `audit_log.target`, поэтому в аудите видно, каким ключом сделано изменение. После мутаций обязательны `invalidateEntries()` / `invalidateLessonReports()` / `invalidateStudents()` / `invalidateStats()` + `broadcastEntryChanged()`, иначе фронтенд не обновится
- **`DELETE /api/v1/entries/:id` — мягкое удаление** (`deleted_at`), как и во внутреннем API; физическое удаление живёт только в корзине
- **Управление воркерами на `apiV1`** (`POST /ai/wake`, `/photo-jobs/wake`, `/ai/requeue-failed`, `/photo-jobs/requeue-failed`, `/entries/:id/ai/recheck`) — все под `apiWrite('write')`, аудит через `apiAudit()` с префиксом `api.`. `wake` — только пинок `worker.notify()`, он **не гарантирует немедленную обработку**: воркер берёт задачи из БД через `FOR UPDATE SKIP LOCKED` и просыпается по своему backoff-циклу, поэтому `wake` — оптимизация, а не условие работы
- **Массовые операции на `apiV1` обязаны учитывать филиалы ключа.** Внутренние `POST /api/ai/requeue-failed` и `/api/photo-jobs/requeue-failed` делают `UPDATE ... WHERE status='error'` по всей таблице — для `apiV1` такой код ломает правило «ключ не шире выдавшего». Фильтр строится хелпером `apiBranchClause(user, expr, params)` (`server.js`): возвращает пустую строку для admin'а, `AND FALSE` при пустом списке филиалов и `AND <expr> = ANY($N::int[])` иначе. Выражение для записей — `(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)`, для фото-джобов — через `photo_jobs → entries → groups`. Не переносите внутренний `UPDATE` в `apiV1` без этого фильтра
- **Воркер отчётов о занятии не имеет `wake`-эндпоинта ни во внутреннем API, ни в `apiV1`** — он будится неявно через `wakeLessonAiWorker()` из `POST`/`PUT /lesson-reports`, когда `ai_check === true` (строго boolean; строка `"true"` не срабатывает) и `lesson_ai_enabled` ≠ `'false'`
- **`api_keys` НЕ входит в бэкап** (как `sessions`), а `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы, их надо выпустить заново
### 4. API Patterns