diff --git a/AGENTS.md b/AGENTS.md index 4668f88..db41dcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` в `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 = 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 diff --git a/README.md b/README.md index 7db96af..a328e0f 100644 --- a/README.md +++ b/README.md @@ -634,9 +634,20 @@ curl -H "Authorization: Bearer wsk_ВАШ_КЛЮЧ" https://ВАШ_ДОМЕН/ap | `GET` | `/api/v1/lesson-reports`, `/lesson-reports/:id` | чтение | | `POST`, `PUT`, `DELETE` | `/api/v1/lesson-reports[/:id]` | запись | | `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`). +Управление ИИ-воркерами: + +- `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`. diff --git a/api-keys.selftest.js b/api-keys.selftest.js index 9d54a87..cf092e1 100644 --- a/api-keys.selftest.js +++ b/api-keys.selftest.js @@ -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'); diff --git a/public/js/audit.js b/public/js/audit.js index 189aa91..f4fb08c 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -76,6 +76,11 @@ const ACTION_LABELS = { 'ai.wake': 'Воркер ИИ разбужен вручную', 'ai.enabled': 'Переключена автопроверка ИИ', '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.update': 'Изменён профиль ИИ', 'ai.profile.delete': 'Удалён профиль ИИ', diff --git a/server.js b/server.js index 6522c36..4dc1289 100644 --- a/server.js +++ b/server.js @@ -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');