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:
@@ -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/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()`, иначе фронтенд не обновится
|
- **Мутации через API** аудитятся через `apiAudit()` — он добавляет `via_api_key: <id>` в `audit_log.target`, поэтому в аудите видно, каким ключом сделано изменение. После мутаций обязательны `invalidateEntries()` / `invalidateLessonReports()` / `invalidateStudents()` / `invalidateStats()` + `broadcastEntryChanged()`, иначе фронтенд не обновится
|
||||||
- **`DELETE /api/v1/entries/:id` — мягкое удаление** (`deleted_at`), как и во внутреннем API; физическое удаление живёт только в корзине
|
- **`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` — после восстановления все внешние ключи мертвы, их надо выпустить заново
|
- **`api_keys` НЕ входит в бэкап** (как `sessions`), а `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы, их надо выпустить заново
|
||||||
|
|
||||||
### 4. API Patterns
|
### 4. API Patterns
|
||||||
|
|||||||
@@ -634,9 +634,20 @@ curl -H "Authorization: Bearer wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/ap
|
|||||||
| `GET` | `/api/v1/lesson-reports`, `/lesson-reports/:id` | чтение |
|
| `GET` | `/api/v1/lesson-reports`, `/lesson-reports/:id` | чтение |
|
||||||
| `POST`, `PUT`, `DELETE` | `/api/v1/lesson-reports[/:id]` | запись |
|
| `POST`, `PUT`, `DELETE` | `/api/v1/lesson-reports[/:id]` | запись |
|
||||||
| `GET` | `/api/v1/stats` | чтение |
|
| `GET` | `/api/v1/stats` | чтение |
|
||||||
|
| `POST` | `/api/v1/ai/wake`, `/photo-jobs/wake` | запись |
|
||||||
|
| `POST` | `/api/v1/ai/requeue-failed`, `/photo-jobs/requeue-failed` | запись |
|
||||||
|
| `POST` | `/api/v1/entries/:id/ai/recheck` | запись |
|
||||||
|
|
||||||
Списки возвращают единый формат `{ items, total, limit, offset }`; поддерживаются `limit`/`offset` (до 500) и фильтры (`group_id`, `module_id`, `student_name`, `search`, `date_from`, `date_to`).
|
Списки возвращают единый формат `{ items, total, limit, offset }`; поддерживаются `limit`/`offset` (до 500) и фильтры (`group_id`, `module_id`, `student_name`, `search`, `date_from`, `date_to`).
|
||||||
|
|
||||||
|
Управление ИИ-воркерами:
|
||||||
|
|
||||||
|
- `POST /ai/wake` и `POST /photo-jobs/wake` — разбудить воркер проверки текста записей и фото-воркер. Это только пинок: задачи всё равно подхватятся по своему циклу опроса, задержка возможна при недоступном Redis.
|
||||||
|
- `POST /ai/requeue-failed` и `POST /photo-jobs/requeue-failed` — вернуть в очередь задания со статусом `error`; в ответе `{ ok, count }`.
|
||||||
|
- `POST /entries/:id/ai/recheck` — отправить конкретную запись на повторную ИИ-проверку.
|
||||||
|
|
||||||
|
Все четыре требуют скоуп `write`. Массовые операции уважают филиалы ключа: `requeue-failed` переочередит только записи и фото-задания в доступных филиалах, а не во всей системе. Воркер отчётов о занятии отдельного `wake`-эндпоинта не имеет — он будится сам при `POST`/`PUT /lesson-reports` с `ai_check: true` (значение должно быть именно boolean `true`).
|
||||||
|
|
||||||
Меры безопасности:
|
Меры безопасности:
|
||||||
|
|
||||||
- **Права**: у ключа есть скоупы `read` и `write`; без `write` все изменения возвращают `403`.
|
- **Права**: у ключа есть скоупы `read` и `write`; без `write` все изменения возвращают `403`.
|
||||||
|
|||||||
@@ -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' });
|
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-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);
|
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);
|
sc.data.user);
|
||||||
const bad = await api('/api/api-keys/' + only.data.id, { token, method: 'PUT', body: { branch_ids: [999999] } });
|
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);
|
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' });
|
await api('/api/api-keys/' + only.data.id, { token, method: 'DELETE' });
|
||||||
} else {
|
} else {
|
||||||
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');
|
ok('api v1: есть филиал для проверки скоупа', false, 'no branches');
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ const ACTION_LABELS = {
|
|||||||
'ai.wake': 'Воркер ИИ разбужен вручную',
|
'ai.wake': 'Воркер ИИ разбужен вручную',
|
||||||
'ai.enabled': 'Переключена автопроверка ИИ',
|
'ai.enabled': 'Переключена автопроверка ИИ',
|
||||||
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
|
||||||
|
'api.ai.wake': 'Воркер ИИ разбужен через API',
|
||||||
|
'api.ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ через API',
|
||||||
|
'api.photo-jobs.wake': 'Фото-воркер разбужен через API',
|
||||||
|
'api.photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь через API',
|
||||||
|
'api.entry.ai.recheck': 'Запись отправлена на ИИ-проверку через API',
|
||||||
'ai.profile.create': 'Создан профиль ИИ',
|
'ai.profile.create': 'Создан профиль ИИ',
|
||||||
'ai.profile.update': 'Изменён профиль ИИ',
|
'ai.profile.update': 'Изменён профиль ИИ',
|
||||||
'ai.profile.delete': 'Удалён профиль ИИ',
|
'ai.profile.delete': 'Удалён профиль ИИ',
|
||||||
|
|||||||
@@ -7520,6 +7520,74 @@ apiV1.put('/students/:id', apiWrite('write'), async (req, res) => {
|
|||||||
res.json(rows[0]);
|
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 ---
|
// --- Error handlers ---
|
||||||
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user