Author SHA1 Message Date
dev 76d36e5c35 feat(backup): формат бэкапа v2 — audit/уведомления/баны, counts и одноразовый тикет
Валидация и нормализация restore вынесены из server.js в backup-restore.js,
покрыты юнит-тестами backup.selftest.js (30+ проверок, без стенда).

В бэкап добавлены audit_log, notifications, notification_reads, banned_ips,
счётчики counts, метаданные приложения и версия формата (принимаются 1..2).
Тикет бэкапа стал одноразовым: файл удаляется сразу после отдачи,
uploadBackup фильтрует расширения. sessions в бэкап не входит намеренно.

sweepOrphanedUploads учитывает аватары, обложки, оригиналы фото и photo_jobs —
иначе restore сносил файлы сразу после восстановления.
storage.getStream при STORAGE_LOCAL_FALLBACK читает локальный файл, а не S3.
2026-10-04 01:36:58 +03:00
dev 32aabe9a80 fix(lesson-ai): обновление промпта у существующих БД + правило в AGENTS.md
INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если
ключа ещё нет, поэтому новый шаблон промпта доезжал лишь до свежих
установок — у всех, кто уже пользовался разделом sec-lesson-ai, в settings
оставался старый промпт.

- в db/migration.sql добавлен идемпотентный
  UPDATE settings SET value = '<новый промпт>'
  WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')
  по образцу уже существующей миграции для ai_prompt
- условие по value IN (...) обязательно: без него миграция затёрла бы
  промпт, отредактированный админом в UI
- в db/init.sql UPDATE не добавляется: файл выполняется только на пустой БД
- AGENTS.md, раздел 2: правило «Сид настроек» (DO NOTHING не обновляет
  существующие значения) и «Проверка сида» (прогон в транзакции с откатом)
- AGENTS.md, раздел 3d: смена шаблона промпта — четыре правки, а не три;
  все текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT

Проверено на postgres:16 в транзакции с откатом, ON_ERROR_STOP=1
- старый дефолт (935 симв.) -> миграция -> новый (2348 симв.)
- повторный прогон ничего не меняет (идемпотентно)
- кастомный промпт админа миграцией не затрагивается
2026-10-04 00:58:12 +03:00
dev 2363d099d2 feat(lesson-ai): новый шаблон промпта + предупреждение о дубле отчёта
- заменён дефолтный промпт редактуры отчёта о занятии на формат
  «сообщение тьютора»: связный текст от 3-го лица, 1 абзац 2–4
  предложения, начало «На занятии ребята …», практика «В конце занятия …»,
  без группы/даты/времени и markdown, обращение на «вы» убрано, «они» → «каждый»
- синхронизированы три копии промпта: LESSON_AI_DEFAULT_PROMPT в server.js
  и сид lesson_ai_prompt в db/init.sql и db/migration.sql; добавлен пример
  «плохо → хорошо» с запретом переносить факты примера
- дубль отчёта в модалке создания больше не автозаполняет форму: показывается
  баннер #lessonDup с кнопкой перехода к существующему отчёту
- проверка на дубль срабатывает и при смене даты (#lessonDate), защищена
  счётчиком поколений lessonDupSeq от гонки при быстрой смене группы/даты
- стили .lesson-dup в public/admin.css, уточнена подсказка в карточке
  шаблона отчёта на public/settings.html
- DDL не менялся: ни таблиц, ни колонок, ни индексов
2026-10-04 00:54:23 +03:00
dev b931c0a760 feat(lesson-ai): проверка отчёта о занятии по шаблону + история версий
Галочка «Проверить по шаблону» в окне отчёта отправляет текст модели:
совпал с шаблоном — остаётся как есть (skipped), не совпал — переписывается
в деловом виде (done). Обработка идёт в фоне, HTTP-запрос не ждёт модель,
оригинал тьютора сохраняется в text_original.

- схема: text_original/text_ai/ai_status/ai_checked_at/ai_error в
  lesson_reports, таблица lesson_report_versions, ensureLessonReportsTable()
- настройки lesson_ai_enabled и lesson_ai_prompt (раздел sec-lesson-ai),
  значения только 'true'/'false'
- worker.js: createLessonReportChecker (FOR UPDATE OF lr SKIP LOCKED,
  до 3 попыток), хук назовён notifyEvent — notify в createPhotoEnhanceWorker
  уже занят будильником
- server.js: wakeLessonAiWorker, onLessonAiDone (версия, аудит с diff,
  уведомление lesson.ai.formatted, SSE lesson_report_status), маршруты
  /versions, /versions/:id/restore и /ai/revert
- aiComplete вместо aiCorrectText: общий вызов модели с таймаутом
- бэкап/восстановление: lesson_reports и lesson_report_versions в payload
- фронтенд: openLessonVersions/restoreLessonVersion в admin.js, бейджи
  статусов в lessons.js, лейблы аудита, renderAuditPager
- docs: раздел 3d в AGENTS.md и Agent Workflow, пункт в README
- тесты: контракт lesson-report и настройки уведомления в api.smoketest.js
2026-10-04 00:12:24 +03:00
dev a84f33307e feat(lessons): отчёты о занятиях — таблица, CRUD, страница и блок на дашборде
Добавлена сущность «что прошли на занятии»:
- lesson_reports (init.sql + migration.sql + ensureLessonReportsTable)
- GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id
  с branchScope, уникальностью (group_id, lesson_date) и лимитом текста
- уведомление lesson.report (NOTIFY_TYPES + настройка + иконка)
- кэш-префикс lessons: + инвалидация stats:/dashboard:
- восстановление lesson_reports в normalizeRestoreData
- блок recent_lessons в /api/dashboard

Фронтенд:
- public/lessons.html + public/js/lessons.js — список с фильтрами и правкой
- openLessonModal в admin.js — общая модалка из журнала и дашборда
- кнопки в журнале и быстрые действия дашборда

Также исправлен сдвиг индексов параметров в notificationsScope —
$1 уходил повторно в список филиалов из-за params.push без смещения.
2026-10-03 23:33:45 +03:00
dev 0dc8ffdb87 feat(export): фото ученика и логотип в экспорте профиля
- /api/export/student: выборка student_photos, отдельный блок photos/ и
  метаданные student_photos в data.json, счётчик в totals
- логотип из system_logo кладётся в корень архива и подставляется в шапку
- student-report.js: секция «Фотографии ученика», логотип в топбаре,
  навигация ограничена 5 пунктами, пустые секции (Опыт работы) не рендерятся
- главное фото профиля/обложки приоритетно берётся из личных фото ученика
2026-10-03 22:57:35 +03:00
dev 2377707297 feat(students): аватары в списке студентов с превью и лайтбоксом
Круглый аватар слева от поля имени: миниатюра главного фото через thumbSrc, иначе инициалы. Клик по фото открывает imgModal с полным размером, клик по пустому — карточку профиля для загрузки. Аватар синхронизируется после загрузки, смены и удаления фото.
2026-10-03 21:33:25 +03:00
dev 70b0c7ae9b feat: env-driven upload limits + request timeout, inline video playback, range requests
- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
2026-10-03 12:00:48 +03:00
dev 104bdc4f49 feat(uploads): лимиты загрузки в env, 50 МБ на файл и 200 МБ на запись
Лимиты были захардкожены в четырёх местах фронтенда и в константах multer,
из-за чего расходились с текстами ошибок на сервере.

- UPLOAD_FILE_LIMIT_MB (50) и UPLOAD_TOTAL_LIMIT_MB (200) читаются из env;
  оба multer-конфига (upload, adminUpload) берут fileSize из них, тексты
  ошибок собираются из тех же констант вместо литералов
- UPLOAD_REQUEST_TIMEOUT_MS снимает дефолт Node в 5 минут: считается как
  UPLOAD_TOTAL_LIMIT_MB * 7500, иначе 200 МБ по мобильной сети не успевают
- GET /api/public-settings отдаёт upload_file_limit_mb / upload_total_limit_mb,
  фронтенд читает их вместо собственных констант

Проверено на живом стеке: 20 МБ и 180 МБ суммарно принимаются, 55 МБ и
225 МБ отклоняются с верными сообщениями, скачивание 45 МБ из S3 совпадает
по sha256 с оригиналом, api.smoketest.js — 57 PASS / 0 FAIL.
2026-10-03 10:38:36 +03:00
dev 449b86955e feat(groups): расширенный экспорт фото — галерея и оригиналы 2026-10-02 13:07:47 +03:00
dev 55c4b281c3 feat(groups): ZIP-архив группы с выбором категорий и режима фото
Форма архива файлов группы на /groups.html получила галочки выгрузки:
- «Работы (файлы проектов)» — включена по умолчанию
- «Фото записи» — выключена по умолчанию
- режим фото «Последние» / «Все», по умолчанию «Последние»;
  радиокнопки блокируются, когда «Фото записи» выключена

Дефолты периода: дата «с» пустая, дата «по» — сегодня.

GET /api/groups/:id/export/files принимает include_files,
include_photos и photos_mode:
- latest — только entries.photo_path, то главное обработанное фото записи
- all — плюс вся галерея записи из entry_photos
- 400, если обе категории выключены

Запросы к БД для отключённых категорий не выполняются; в data.json
и в аудит добавлены options и раздельные счётчики works/photos.

В аудите добавлена метка export.group_files.

Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме
«последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи),
категории комбинируются независимо. api.smoketest.js 57/57,
diff.selftest.js 16/16.
2026-10-02 12:48:36 +03:00
dev 45033bffa8 feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)
Только фронтенд: server.js, worker.js, photo-ai/, compose и схема БД не тронуты.
Контракт /enhance-ai, /api/photo-ai/health и service в /api/photo-jobs/status
взят из Stage 4 как есть (I6 — новых колонок нет, метаданные из photo_jobs.params
и audit_log.target).

journal.html + journal.js:
- селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица /
  Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face}
- значение уходит в POST .../enhance-ai телом {model, face, face_model};
  face_model подставляется только когда face !== 'off'
- дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces)
- подсказка про медленный CPU: loadPhotoAiDevice() читает
  GET /api/photo-ai/health (только для админа)
- текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим
- кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3)

settings.html + settings.js:
- новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство
- read-only статус из /api/photo-jobs/status: фактическое устройство,
  device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с
  желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL
- renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai
- новые id в DIRTY_FIELDS и в payload PUT /api/settings
- photo_ai_device_pref документирующий: фактическое устройство задаёт
  PHOTO_AI_DEVICE в контейнере

worker.html + worker.js:
- PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл»
- в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами,
  устройством, временем обработки и предупреждениями
- строка состояния учитывает service.reachable === false («задания ждут»)
  и дописывает «· расчёт на <device>»

audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).

Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
2026-09-30 11:15:54 +03:00
dev 884188e9ec feat(photo-ai): Stage 4 — face-режим, апскейл-модели и метаданные в аудите
Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла
(x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all),
модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя
как раньше: action='ai', params=NULL (инвариант I2).

worker.js:
- таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла,
  PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима
- runAiEnhance шлёт model/face/face_model/strength и понимает оба
  контракта: JSON с image_base64 и сырой image/jpeg старого сервиса
- тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет
  причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без
  объяснения
- applyResult пишет в аудит model/face/face_model/device/faces_found/
  elapsed_ms/warnings и выбирает текст уведомления по факту режима;
  warnings видны оператору, если лица не нашлись
- CONFIG: + face_timeout_ms, default_model, face_model

server.js:
- POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до
  запроса записи — невалидный вход даёт 400, а не 404/500
- PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale
- GET /api/photo-ai/health (requireAdmin) — прямой прокси /health
- photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс
- getStackInfo(): блок photo_ai (engine, host, device, vram, модели)
- настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref
  с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql,
  public-settings и ensurePhotoJobsTable()

Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md,
журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём,
I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After,
7 невалидных тел → 400, api.smoketest.js 57 PASS.
2026-09-29 15:08:46 +03:00
dev 4c63a46d24 fix(photo-ai): лестница OOM на CUDA + приёмка face-режима и GPU-оверрайда
Stage 3 закрыт. Face-режим (off/face/all, strength, warnings) был написан в Stage 1;
здесь доведена приёмка и найден баг, который невозможно было увидеть без GPU-прогона.

Главное: realesные OOM никогда не доходили до run_guarded. И realessrgan/utils.py, и
gfpgan/utils.py ловят RuntimeError вокруг вызова сети и идут дальше
(`except RuntimeError as error: print('Error', error)`), поэтому на нехватку памяти
realesrgan падал уже не RuntimeError, а UnboundLocalError на присваивании
output_tile. Наружу уходил голый 500 «Internal Server Error»: ни лестницы тайлов,
ни деградации на CPU, ни внятного текста. В gfpgan это было тихое ухудшение —
при OOM лицо молча оставалось исходным, а задание уходило в «успех».

Починка: guard_forward() оборачивает forward сетей, которые строим мы
(RealESRGANer.model, restorer.gfpgan, CodeFormer net) и превращает OOM-RuntimeError
в TileOOM. TileOOM не наследует RuntimeError, поэтому проглатывающие except его
пропускают; is_oom и обе точки run_guarded ловят его явно. Обёртка вешается на
экземпляр, идемпотентна по флагу _photo_ai_guarded и не трогает класс.

Также добавлены два предупреждения из чек-листа, которых в коде не было: вход меньше
320×320 (лица могут не найтись) и CodeFormer на не-CUDA. Предупреждение «лица не
найдены» и деградация на CPU были на месте и не менялись.

Проверено на RTX 3050 Laptop (4096 МБ, драйвер 615.71.09, CUDA 12.6):
- tile=2048, x2plus face=all, полное фото: до правки 500 + UnboundLocalError,
  после 200 за 28.3 с с единственным warning «не хватило памяти при tile=2048»;
- инъекция OOM: лестница 2048 → 1024 → 512, затем переход на CPU (device: cpu,
  half: false) и успешный повтор; при повторе уже на CPU — честная 500 с подсказкой
  про PHOTO_AI_TILE / PHOTO_AI_MAX_PIXELS;
- 640×480: off 1.0 с / face 3.8 с / all 2.2 с, faces_found=6; фото без лиц даёт
  faces_found=0 и байты, равные face=off;
- I1: 6/6 MATCH байт-в-байт против Stage 2 на CPU (jpg/.jpeg/png+70/webp+100/x4v3/anime).

docker-compose.gpu.yml: GPU выдаётся через CDI (device_ids nvidia.com/gpu=all) —
не требует правки /etc/docker/daemon.json и перезапуска демона, в отличие от
классического резервирования driver: nvidia. TORCH_VARIANT cu124 → cu126: в индексе
cu124 последний torch 2.6.0, а cu126 даёт те же 2.14.0/0.29.0, что и CPU-образ, так
что варианты сборки отличаются только CUDA-библиотеками. Образ тегируется отдельно
(whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала CPU-образ
whatido-photo-ai:latest — откат остаётся обычным docker compose up -d photo-ai.

README: раздел «Запуск на NVIDIA GPU» с установкой NVIDIA Container Toolkit и генерацией
CDI-спеки, оговорками про 4 ГБ VRAM (x2plus + gfpgan влезают, general-x4v3 тяжелее,
LOAD_ALL=1 лучше не включать) и описанием параметров /enhance. .env.example: команда
GPU-запуска и рекомендация по PHOTO_AI_TILE. Журналы раздела 3 и GPU-прогона — в
TODO_PHOTO_FACE_AI.md.

worker.js, server.js, схема БД и фронтенд не тронуты — они в Stage 4…6.
2026-09-29 12:02:03 +03:00
dev 7367d66ac3 build(photo-ai): CPU/GPU сборка, gfpgan+facexlib без dev-зависимостей, healthcheck
Stage 2: photo-ai/Dockerfile получил ARG TORCH_VARIANT/TORCH_INDEX (один образ,
cu124 — вариант сборки) и явные пины gfpgan==1.3.8 / facexlib==0.3.0 через
--no-deps: tb-nightly и yapf (dev-зависимости gfpgan/basicsr) больше не попадают
в рантайм, matplotlib остаётся как зависимость filterpy (требование facexlib).
Патч basicsr/data/degradations.py сохранён байт-в-байт — без него basicsr падает
на torch >= 2.0.

Пин numpy<2 был невыполним: opencv-python-headless 5.0.0.93 требует numpy >= 2.
Зафиксирована фактическая версия numpy==2.2.6 в общем вызове pip install, а
opencv-python (не-headless) больше не ставится — раньше он приходил через gfpgan
и перезаписывал headless-сборку (активным был cv2 с GUI: QT5). I1 после этого
перепроверен: 5/5 MATCH байт-в-байт против эталона Stage 0.

ENV PHOTO_AI_MODELS_DIR=/models, MODEL_PATH остаётся валидным алиасом.
COPY vendor/ ./vendor/ + vendor/.gitkeep — вендоренный CodeFormer (D5)
подхватится из sys.path без правок Dockerfile.

docker-compose.yml: у photo-ai новые env (DEVICE, TILE, FACE_MODEL, LOAD_ALL,
JPEG_QUALITY, MODELS_DIR) и healthcheck со start_period 300s; MAX_INPUT_PIXELS
заменён на канонический PHOTO_AI_MAX_PIXELS (старое имя читается app.py как алиас).
У app — PHOTO_AI_FACE_MODEL и PHOTO_AI_FACE_TIMEOUT_MS (воркер читает их в Stage 4).
Новый docker-compose.gpu.yml (по образцу minio): TORCH_VARIANT=cu124,
PHOTO_AI_DEVICE=cuda, deploy.resources.reservations.devices для nvidia.

.env.example и таблица переменных README описывают ровно те переменные, которые
теперь подставляет compose; блок про GPU и NVIDIA Container Toolkit — Stage 6 (D6).

Проверено на живом стеке: сборка EXIT=0 (2.63 ГБ), /health отдаёт device=cpu,
face_models=[gfpgan], контейнер healthy, /api/photo-jobs/status отдаёт
service.device, I3 (пустой PHOTO_AI_URL -> 503 + 8 маршрутов 200), I4
(PHOTO_AI_DEVICE=cuda без CUDA -> WARN + CPU), docker compose config валиден для
базы, minio и gpu. GPU-пуск не выполнялся: nvidia-ctk на хосте отсутствует.
2026-09-29 09:35:40 +03:00
dev e8c15cc425 feat(photo-ai): реестр моделей и авто-выбор устройства
Stage 1: переписан photo-ai/app.py. Реестр моделей вместо одной модели,
авто-выбор cuda/mps/cpu, ModelPool с ленивой загрузкой, прогревом и LRU
на 2 записи, OOM-деградация по лестнице тайлов, расширенные /health
и /models, /enhance с выбором модели, режима лиц и качества JPEG.

Инвариант I1 держится: запрос только с image + scale=2 по-прежнему даёт
байт-в-байт тот же JPEG (5/5 MATCH против эталона Stage 0). Расширение
выводится по имени файла, а не по content_type, и .jpeg нормализуется
в .jpg — этого хватает для совместимости с воркером, который шлёт
image/jpeg для всего.

/enhance отдаёт сырой JPEG по умолчанию и JSON при Accept: application/json.
Пока модель грузится — 503 с Retry-After: 5; worker.js относит status >= 500
к мягким, поэтому попытка не тратится (I5).

Новые переменные (PHOTO_AI_DEVICE, PHOTO_AI_MODELS_DIR, PHOTO_AI_TILE,
PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_FACE_MODEL,
PHOTO_AI_JPEG_QUALITY) пока не документированы в .env.example: compose их
ещё не подставляет, это Stage 2.

Найдено: на хосте есть GPU (nvidia-smi, драйвер 615.71.09), не хватает
только nvidia-container-toolkit. Установка — решение оператора, ветка CUDA
осталась непроверенной прогоном.
2026-09-29 00:36:16 +03:00
dev 88dbff0136 chore(photo-ai): Stage 0 закрыт, решения D1–D6 зафиксированы
Stage 0: чекбоксы отмечены, приёмка перепроверена — эталон воспроизводится
байт-в-байт (5/5 MATCH), /health -> {"ok":true}, сценарий «🤖 ИИ» -> done.

D1 — порт photo-ai на 127.0.0.1:8081 (loopback), применён и отражён в
README/.env.example.
D2 — отдельная photoAiHealth() вместо aiHealthCheck() в статусе заданий,
контракт service = {configured, reachable, latency_ms, error} + passthrough
полей photo-ai; правка и проверка в предыдущем коммите.
D3 — PHOTO_JOB_ACTIONS выносится на уровень модуля и используется в restore
и в валидации enhance-ai; CHECK в БД не добавляем (I6).
D4 — вендорить gfpgan/facexlib не нужно: пакеты есть на PyPI и уже в образе
(реalesrgan тянет их транзитивно). Зафиксированы проверенные URL весов и
расхождение: пин numpy<2 в Dockerfile не действует (в образе 2.2.6).
D5 — CodeFormer внедряется только при явной необходимости; на PyPI лишь
сторонняя обёртка, дефолт gfpgan, при отсутствии модуля -> 400 с текстом.
D6 — дефолт PHOTO_AI_URL не меняем (фото-ИИ включено из коробки на CPU).
2026-09-28 23:38:48 +03:00
dev 00fbf41efb build(photo-ai): loopback-порт 8081 и раздел про фото-ИИ в README
photo-ai не имел ports, а хостовый 8080 уже занят text-corrector: ручные
проверки из плана попадали не туда. Проброшено 127.0.0.1:8081:8080 — только
loopback, наружу ничего не публикуется.

Заодно исправлен рассинхрон .env.example: там стоял пустой PHOTO_AI_URL с
комментарием «пусто = контейнер photo-ai», из-за чего копирование примера
молча выключало фото-ИИ (в compose дефолт http://photo-ai:8080). Теперь
дефолт указан явно, пустое значение описано как «выключить сервис».
2026-09-28 23:38:39 +03:00
dev 1a25ce7170 fix(photo-ai): отдавать health фото-сервиса в статусе заданий
В GET /api/photo-jobs/status вызывался aiHealthCheck() — это health
текстового ИИ, — а результат в ответ не попадал: поле service отсутствовало,
и оператор не видел состояние photo-ai.

Добавлена photoAiHealth(): GET ${PHOTO_AI_URL}/health с таймаутом 5 с, без
исключений; пустой PHOTO_AI_URL -> {configured:false, reachable:false}, обрыв
или таймаут -> {configured:true, reachable:false, error}. Вызов уходит в тот
же Promise.all, что и запросы к БД, поэтому статус не получает лишние 5 с.
Контракт: {configured, reachable, latency_ms, error} + passthrough полей
photo-ai; первые четыре совпадают с контрактом текстового ИИ, который уже
читает public/js/worker.js.

Контракт зафиксирован в api.smoketest.js двумя проверками.
2026-09-28 23:38:32 +03:00
dev 403574fe79 chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.

I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.

I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.

I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.

Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
  два независимых прогона и прогон после правок совпали байт-в-байт
  (sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
  attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
  enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
  только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
  PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
  весь SQL параметризован

api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
2026-09-28 23:19:16 +03:00
dev 31542de33b feat(photos): раздел «Фото» — единая галерея всех загруженных фотографий
Новый read-only раздел для просмотра всех фотографий системы:
GET /api/photos собирает через UNION ALL пять источников — главное фото
записи, фото записи (entry_photos), фотохронику групп, фото учеников и фото
тем модулей. Фильтры search/student_name/group_id/date_from/date_to и
пагинация limit/offset, ответ { photos, total }. Не-admin ограничен
своими филиалами (branch_id), чужой group_id отдаёт 403.

Фронтенд: public/photos.html + public/js/photos.js — сетка превью
(/uploads/thumb/...), бейдж источника, описание, ученик/группа, дата,
ссылка на источник, lightbox по клику, поиск с debounce и пагинация.
Навигация: пункт «Фото» в сайдбаре и плитка в быстрых действиях дашборда.
2026-09-28 00:15:32 +03:00
dev 2afe676969 feat(notifications): центр уведомлений о системных событиях
Добавлена система уведомлений о системных и фоновых событиях (новые записи
журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы).

- backend (server.js, worker.js):
  - каталог NOTIFY_TYPES с метаданными и уровнями
  - таблицы notifications и notification_reads в db/init.sql и db/migration.sql
  - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback
  - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка
  - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>)
  - автоматическая очистка старых уведомлений по расписанию
- frontend:
  - колокольчик со счётчиком непрочитанных в шапке (admin.js)
  - страница списка уведомлений public/notifications.html и public/js/notifications.js
  - секция настроек уведомлений в public/settings.html и public/js/settings.js
  - стили для уведомлений в public/admin.css
- тесты и документация:
  - добавлены проверки в api.smoketest.js
  - обновлены README.md и AGENTS.md
2026-09-27 23:34:47 +03:00
dev f31b8deea2 feat(settings): блок «Статус стека» — на чём всё крутится
На странице Настроек появился блок с состоянием стека системы: версии
компонентов, состояние сервисов и нагрузка. Рендерится из нового блока
stack в ответе GET /api/system-info, в том же стиле, что и соседний
блок системной информации (sys-card / sys-item / sys-bar, точки
worker-dot, полосы загрузки и памяти с порогами 70 % и 90 %).

Карточки: Приложение (версия и коммит, Node.js, PID и RSS, аптайм,
куча, сворачиваемый список библиотек), Сервер (ОС, ядро, архитектура и
число ядер, модель CPU, loadavg, память, аптайм, контейнер), База
данных (PostgreSQL и версия, хост, состояние пула), Кэш (драйвер,
версия Redis, ключи, память, попадания и промахи, операций в памяти) и
Хранилище (драйвер, endpoint, бакет или каталог).

- server.js: getStackInfo() — версии из package.json, node_modules и
  public/version.json, ОС из /etc/os-release, определение Docker,
  SHOW server_version, счётчики пула pg, состояние Redis. Считается вне
  cacheWrap, чтобы версии и нагрузка не отдавались из 30-секундного
  кэша; хосты только через URL.hostname, без учётных данных URL
- settings.html: карточка sec-stack и пункт «Стек» в навигации
- settings.js: renderStackInfo(), форматирование аптаймов с
  русскими склонениями, degrade-состояние «нет связи — в памяти»;
  кнопка обновления перезагружает оба блока одним запросом
- admin.css: .sys-inline для точки статуса рядом с текстом
- api.smoketest.js: контракт блока stack и проверка, что в ответе нет
  учётных данных из DATABASE_URL/REDIS_URL
2026-09-27 12:47:04 +03:00
dev 4d3df1de37 fix(redis): разбирать INFO по разделителю, а не по фиксированному смещению
used_memory и used_memory_human вырезались на символ длиннее нужного,
поэтому первая цифра терялась: 1024 превращалось в 24, а "1.34M" — в
".34M". Значение памяти Redis в /api/system-info и в self-тестах было
занижено на порядок.

- parseInfoSections(): разбор INFO по indexOf(':'), пропуск заголовков
  # и пустых строк, работа с любой секцией
- info(): добавлены redis_version и uptime_in_seconds (секция server),
  в том числе в ветках memory и ошибки — с null
2026-09-27 12:46:41 +03:00
dev 3ecf87146a build(docker): перейти на Node 22 (AWS SDK требует node >=22) 2026-09-27 12:05:55 +03:00
dev eea42eb704 style(audit): кнопка закрытия деталей в общем стиле сайта 2026-09-27 12:01:48 +03:00
dev 54cf5bbfa4 feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается
состояние до и после, и в audit_log пишется не только факт правки,
но и сами изменения: пословный дифф текста, статистика добавленных
и удалённых слов, а также смена ФИО, группы и темы модуля.

- diff.js: пословный LCS-дифф без зависимостей, обрезка больших
  текстов, сборка изменений по полям записи, облегчённый target
  для списка аудита
- source правки: manual / ai / ai_manual / ai_revert; журнал шлёт
  edit_source, сервер доверяет явному значению и определяет источник
  по description_ai как запасной вариант
- те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check)
  и отката к оригиналу (entry.ai.revert)
- GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный
  target, чтобы не грузить килобайты текста на каждую строку
- Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой
  удалённого и добавленного текста, «было/стало» для полей
- auth.login теперь пишет user_id, иначе колонка «Кто» показывала
  «система»
- diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
2026-09-27 11:29:17 +03:00
dev 19676c9b64 test(auth): зафиксировать контракт авторизации в smoke-тесте
Документация долго описывала X-Admin-Token как способ авторизации, хотя его
нет в коде. Расхождение не ловилось ничем: curl-пример в доках ходил на
GET /api/groups, а он публичный (optionalAuth) и отвечает 200 без токена,
то есть авторизацию не проверял вообще.

Добавлены проверки, которые падают при возврате статического токена:
- защищённый маршрут без токена -> 401;
- мусорный X-Auth-Token -> 401;
- X-Admin-Token (значение ADMIN_PASSWORD) -> 401;
- Authorization: Bearer -> 401;
- ADMIN_PASSWORD как токен -> 401;
- позитивный контроль: валидный токен на admin-маршруте -> 200, иначе
  проверки выше проходили бы из-за сломанного роута;
- /api/groups остаётся публичным -> 200 без токена.

Хелпер api() научен принимать произвольные заголовки — иначе X-Admin-Token
и Bearer не отправить. AGENTS.md дополнен описанием контракта и пунктом в
чеклисте безопасности.
2026-09-26 16:08:32 +03:00
dev 87541a5ce8 docs(auth): исправить устаревшее описание авторизации
Документация утверждала, что доступ админский и задаётся заголовком
X-Admin-Token со значением ADMIN_PASSWORD, и что без ADMIN_PASSWORD сервер
не стартует. Ни то, ни другое не верно:

- X-Admin-Token в server.js отсутствует полностью, авторизация держится на
  сессиях: POST /api/auth/login (bcrypt) выдаёт токен, который клиент шлёт
  в X-Auth-Token. Проверено на живом стенде: X-Auth-Token -> 200,
  X-Admin-Token -> 401 на /api/auth/me и /api/users;
- ADMIN_PASSWORD участвует только в ensureFirstAdmin() — создании первого
  админа в пустой БД. Без него сервер пишет предупреждение и стартует;
- роли и филиалы: requireAuth (любой активный), requireAdmin (role=admin,
  самодостаточный), optionalAuth; не-admin ограничены user_branches через
  branchScope/branchWhere — это в доках не описывалось.

Заодно curl-пример проверки авторизации в AGENTS.md вёл на GET /api/groups,
который публичный (optionalAuth) и отвечает 200 без токена, то есть авторизацию
не проверял. Переведён на /api/auth/me.

Секрет Gitea убран из URL remote в ~/.git-credentials (600) — deploy.sh
работает без промпта.
2026-09-26 16:05:47 +03:00
dev 4b620d3e59 chore(repo): перестать отслеживать генерируемый public/version.json
Файл генерируется дважды и всегда расходится с HEAD:
- .git/hooks/post-commit вызывает scripts/gen-version.js и переписывает файл
  новым хешем после каждого коммита, поэтому он немедленно снова становится
  грязным и коммитить его бесполезно;
- Dockerfile пишет свою копию внутрь образа из аргументов GIT_COMMIT, и её
  приложение и отдаёт: public/ не смонтирован в контейнер, а public/admin.js
  читает /version.json по HTTP.

Отслеживаемый файл в рабочей копии рантайму не нужен. scripts/deploy.sh уже
исключает его из проверки чистоты, теперь это перестаёт быть обходным путём.
2026-09-26 15:36:32 +03:00
dev cb0e68d04a chore(deploy): обновить version.json на 0e38a28 2026-09-26 15:27:47 +03:00
dev 0e38a280d7 feat(redis): кэш, rate limit, баны IP и pub/sub через Redis
Добавлен сервис redis:7-alpine (AOF, requirepass, maxmemory + allkeys-lru,
healthcheck, том redis-data, порт только на 127.0.0.1) и абстракция redis.js
по образцу storage.js.

Переведено на Redis:
- кэш ответов API и настроек (было Map в памяти), инвалидация по префиксу
  через SCAN + DEL;
- rate limit для api/entry/file — общие счётчики вместо MemoryStore;
- баны IP и счётчики неудачных входа — с TTL, вместо опроса БД каждую минуту;
- кэш сессий (30 с) с invalidateSessions() на каждой мутации users/sessions/
  user_branches, иначе деактивированный пользователь сохранил бы доступ;
- pub/sub для SSE-событий и мгновенного пробуждения фоновых воркеров вместо
  ожидания цикла опроса БД.

Отказоустойчивость: при недоступном Redis все операции уходят в in-memory
backend с той же семантикой, приложение стартует и работает без Redis и
возвращается в Redis автоматически. Первое подключение ограничено по времени
(REDIS_CONNECT_TIMEOUT_MS, 5 с) — node-redis не отклоняет connect() при
недоступном сервере, а повторяет попытки бесконечно.

Добавлены тесты: redis.selftest.js (в т.ч. поведение при недоступном
сервере) и api.smoketest.js (сквозная проверка API, включая инвалидацию
кэша и мгновенную смерть сессии после logout).
2026-09-26 15:26:00 +03:00
dev e4d58d6525 chore(storage): убрать неиспользуемый код и стабилизировать деплой
- storage.js: из экспорта убраны неиспользуемые publicPath, localExists и dir
- server.js: убран неиспользуемый импорт mimeFor и параметр originalsDir в воркере
- worker.js: убран неиспользуемый параметр originalsDir
- scripts/deploy.sh: up -d --force-recreate app, чтобы пересобранный образ
  гарантированно применялся (compose не всегда пересоздаёт контейнер при
  неизменном конфиге сервиса)
2026-09-26 11:16:36 +03:00
dev ddd49707ae feat(storage): S3-совместимое хранилище файлов (SeaweedFS/MinIO) и миграция uploads
- storage.js: абстракция хранилища с драйверами local и s3 (AWS SDK v3),
  ключи объектов совпадают с текущими путями /uploads/<файл>, поэтому схема БД
  и URL не меняются
- docker-compose.yml: сервис s3 (SeaweedFS, том s3-data, API только на loopback),
  переменные S3_*/STORAGE_*, restart unless-stopped для app и db
- docker-compose.minio.yml: оверрайд S3-сервиса на MinIO (образ из своего зеркала)
- server.js/worker.js: чтение и запись файлов только через storage (отдача
  /uploads/*, миниатюры, share-файлы, zip-отчёты, enhance/apply/rollback,
  photo-worker), автосоздание бакета, глобальная персистенция загрузок multer
- бэкап/восстановление и scripts/backup.sh, restore.sh — через scripts/storage-sync.js
- scripts/migrate-to-s3.js: идемпотентная миграция uploads/ в бакет
  (--dry-run, --verify-only, --delete-local)
- админка: блок «Хранилище» в системной информации
- .env.example, README.md, AGENTS.md: описание драйверов, переменных и перехода на S3
2026-09-26 11:08:53 +03:00
dev 8bf54fb95d feat(trash): deferred purge after configurable days, groups in trash 2026-09-24 13:11:31 +03:00
dev e6c69c8a78 feat(settings): option to show/hide camera button, preview click opens camera 2026-09-24 12:00:20 +03:00
dev e4704c2dc6 fix(index): paste fills only project files, photo stays webcam-only
- pasted images/files always go to "Файлы проекта", never to the entry photo
- drop the photo paste hint and the capturedName plumbing: the entry photo is
  captured from the camera only (submit still requires it)
- keep paste naming ("Вставка <date>.<ext>") and server-mirrored limits
2026-09-24 11:21:58 +03:00
dev a72600af13 feat(index): paste photos and project files from clipboard with Ctrl+V
- handle document paste: images become the entry photo when none is set,
  otherwise they land in "Файлы проекта" along with any other pasted files
- take file names from the clipboard, replace generic ones (image.png, blob)
  with "Вставка <date>.<ext>"; derive extension from MIME when missing
- validate client-side like the server: 10 files, 10 MB per file, 30 MB total,
  reject blocked extensions (*.html, *.js, *.svg, ...)
- share the limit logic between the picker and paste, send the photo under its
  real name so pasted PNG/JPEG keep their extension
- add Ctrl+V hints to the photo and files cards (hidden on touch devices)
2026-09-24 11:18:08 +03:00
dev 2e1d36bc4d chore(deploy): exclude generated version.json from deploy dirty check 2026-09-23 23:02:37 +03:00
dev d943b77f58 chore(deploy): bake commit version into image and add scripts/deploy.sh 2026-09-23 23:01:36 +03:00
dev a95af7daa7 fix(backup): accept /uploads/.originals paths in photo_jobs on restore
photo_jobs.before_path хранит путь к оригиналу фото (/uploads/.originals/<файл>), но normalizeRestoreData проверял это поле через optUploadPath/isSafeUploadPath, который запрещает "/" — при наличии завершённых улучшений фото весь импорт падал с 400 «Неверный формат бэкапа: Invalid upload path».

- добавлены reqPhotoRefPath/optPhotoRefPath: допустимы /uploads/<файл> и /uploads/.originals/<файл> (та же ORIGINALS_PATH_RE, что и для entries.photo_original_path); применяются к photo_jobs.before_path/after_path
- POST /api/restore: понятная ошибка, если загружен архив скрипта scripts/backup.sh (db.sql.gz + _uploads) вместо веб-архива
- README: форматы скриптового и веб-архива не взаимозаменяемы
2026-09-23 22:23:38 +03:00
dev 69d46a0e5f fix(backup): stream backup download via resumable token link instead of buffering
Формирование и скачивание бэкапа разделены: POST /api/backup собирает архив
на диске и возвращает временную ссылку, GET /api/backup/:token отдаёт его
через res.download (Content-Length, Accept-Ranges, 206 при докачке).

- больше нет fs.readFileSync всего архива и res.send буфера (~550 МБ RAM -> ~60 МБ)
- GET /api/backup сохранён для совместимости, тоже потоковый
- gzip level 1 (архив из JPEG почти не сжимается), чистка /tmp/wido-backups по TTL 30 мин
- settings.html/js: нативное скачивание браузером с прогрессом и докачкой,
  понятные ошибки вместо «Ошибка сети при формировании бэкапа»
2026-09-23 19:03:19 +03:00
dev 16aba3efb0 fix(settings): constrain logo preview size so it does not overflow the card 2026-09-23 16:56:31 +03:00
dev cb3f010cd9 fix(photo): preserve photo_jobs history in backup/restore; paginate photo history on worker page 2026-09-23 16:37:27 +03:00
dev 30cf04b0cf feat(branding): logo and system name on share and report pages, drop logo border-radius 2026-09-23 16:25:13 +03:00
dev f2d465e0c4 feat(branding): system name/logo, horizontal logo on all pages 2026-09-23 15:19:23 +03:00
dev e435b4ad43 feat(ui): show current commit version and date in admin sidebar 2026-09-23 14:14:29 +03:00
dev 218c3f825d fix(backup): restore new fields, share_links and photo originals
- export/restore share_links (was silently dropped, FK blocked restore)
- keep groups.tutor_id and groups.cover_path, entries.photo_original_path,
  project_files.detached_at on restore
- include uploads/.originals files in backup archive
- insert users before groups to satisfy tutor_id FK
- return 500 JSON instead of hanging when restore fails
2026-09-23 13:49:25 +03:00
dev 4623358f21 feat(students): multi-photo gallery, group photos in export, KIBERone rebrand 2026-09-23 13:32:09 +03:00
dev 5e2533b876 feat(groups): assign tutor to group 2026-09-19 13:17:40 +03:00
dev 1ef81f9d1c wip: student profile/report 2026-09-19 13:06:21 +03:00
dev 72eeb5cf9b fix(entries): keep AI description and status when editing entry 2026-09-18 23:32:55 +03:00
dev d7d4cc1133 feat(modules): soft delete with restore and active filter 2026-09-18 20:04:02 +03:00
dev d3dd922e32 feat(modules): batch module import in admin, module usage in journal 2026-09-18 19:36:32 +03:00
dev 12a527b3ee chore(report): add design reference and screenshots 2026-09-18 19:26:34 +03:00
dev e6291a0235 feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure
- api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged)
- entries: accept/validate module_id on create/update, return module_name, module_id filter
- backup/restore: include modules and entries.module_id
- student form: required module select, hidden while no modules exist
- admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal
- journal: module filter, module select in edit modal, module badge, CSV column
2026-09-18 18:56:42 +03:00
dev d434732f41 feat(report): student report page with works/gallery/files sections 2026-09-18 17:50:50 +03:00
dev 12c612eccd feat(audit): translate photo job audit actions to Russian 2026-09-17 22:25:17 +03:00
dev 5874e2b619 feat: photo jobs apply/reject flow with applied flag in history
- DB: add photo_jobs.applied column (init + migration + ensure)
- Worker: generate preview only (after_path), no longer mutates entry
- New POST /api/entries/:id/photo/jobs/:jobId/apply — apply done job
  result to entry (backs up current photo, marks applied)
- New POST /api/entries/:id/photo/jobs/:jobId/reject — discard result,
  delete temp file, mark rejected
- saveEnhance: apply AI result directly when sliders are at defaults
- Photo history: '✓ Применить' action for unapplied done AI jobs;
  'rejected' status label
- sweepOrphanedUploads keeps done-not-applied preview files
2026-09-17 18:51:26 +03:00
dev 881b5b4c67 fix: repair photo worker broken code; add pending queue view
- Fix runAiEnhance missing return/closing brace from previous commit
- Remove duplicate code fragments in worker.js
- Add renderPhotoPending() to show pending/processing jobs in worker dashboard
- New pending queue table in worker.html photo section
2026-09-17 17:52:51 +03:00
dev 1145be02f3 feat: persistent photo job queue, worker, history with rollback
- Replace in-memory photoAiJobs Map with DB-backed photo_jobs table
- Background photo worker (worker.js) with retry, backoff, stale reset
  - Handles both AI (Real-ESRGAN) and server-side (sharp) enhancement
  - Controlled via photo_worker_enabled setting
- Photo job history in enhance modal with before/after thumbnails + rollback
- Worker dashboard: photo jobs section with status counts, recent table,
  compare slider for before/after, rollback from worker UI
- New endpoints: /api/photo-jobs/status|wake|enabled|requeue-failed,
  /api/entries/:id/photo/jobs (history), .../rollback
- swapEntryPhotoFiles logs every mutation to photo_jobs table
2026-09-17 17:36:56 +03:00
dev 27a2238082 feat: compare slider UI for photo enhance modal
- Side-by-side before/after comparison with draggable divider
- Zoom (scroll wheel) and pan when zoomed in, double-click to reset
- Sliders moved to sidebar panel, responsive layout
- Original/Result badges on the comparison view
- Helper functions: setEnhanceClip, showEnhanceResult, applyEnhanceTransform
2026-09-17 17:02:35 +03:00
dev dec8790a3a fix: AI photo enhance modal stays open with result preview; fix basicsr build
- Modal no longer auto-closes after Real-ESRGAN completes; result loads into
  canvas so user can review, adjust sliders, and choose to apply or discard
- Backend skips swapEntryPhotoFiles until user confirms via Применить
- New DELETE /api/entries/:id/photo/enhance-ai/preview for temp file cleanup
- photo-ai Dockerfile: patch basicsr via find+sed instead of import (avoids
  torchvision.functional_tensor import crash)
2026-09-17 16:44:44 +03:00
dev 3a345cbefd feat: AI photo enhancement (Real-ESRGAN container) + restore original
- new photo-ai service: FastAPI + Real-ESRGAN x2plus on CPU, internal only
- async job queue in server (POST enhance-ai / GET status), 5min timeout, sequential processing
- keep original photo backup (entries.photo_original_path, uploads/.originals), restore-original endpoint
- UI: AI button and restore-original button in enhance modal
- db: photo_original_path column (init.sql, migration.sql, runtime ensure)
2026-09-17 15:37:49 +03:00
dev c4fd31cd53 feat: hybrid photo enhance engine (server sharp + client canvas), denoise
- enhance endpoint accepts JSON params, processes original on disk via sharp pipeline (median, modulate, linear, sharpen, mozjpeg)
- client fallback renders full-size canvas, keeps multipart path
- engine selection setting: auto/server/client, auto falls back to client on error/timeout
- denoise slider (median filter), noise-aware auto mode, preview capped at 1600px
2026-09-17 15:16:42 +03:00
dev 049df61e55 feat: photo quality improvements
- webcam capture resolution/quality configurable in admin settings (defaults 640x480 / 0.92)
- enhance photo modal in journal: original vs preview with sliders (brightness, contrast, saturation, sharpen) and auto-levels button
- new endpoint PUT /api/entries/:id/photo/enhance replaces photo, cleans old file and thumb
- sharper HEIC conversion (0.92) and webp thumbnails (85)
- worker: fail explicitly on empty AI response
2026-09-17 15:07:43 +03:00
dev 57e48a9d4f fix(ai): adaptive max_tokens per backend (reasoning profiles get 1024+, native llama.cpp stays compact; per-profile max_tokens override); include entry texts in ai_status SSE notify so journal updates live without reload; audit log action filter; links pagination; settings validation & UI improvements 2026-09-17 14:12:36 +03:00
dev a93e37a6b1 fix(settings): prevent aiActiveProfile select from overflowing card-foot (shrinkable label+select, min-width:0 on grid cards) 2026-09-17 12:11:18 +03:00
dev 0b4243fe91 feat(docker): add host.docker.internal:host-gateway to app so it can reach host Ollama (AI profiles) 2026-09-17 12:06:11 +03:00
dev 66f7fdc1a8 refactor(settings): AI profile add/edit moved into a modal dialog (overlay click + Esc to close) 2026-09-17 12:00:19 +03:00
dev d7c793c67a fix(settings): constrain aiActiveProfile select width so it stays inside card-foot 2026-09-17 11:58:34 +03:00
dev efe2a42d1d feat(settings): AI model profiles management — native llama.cpp + custom OpenAI-compatible providers, default model selection, connection test 2026-09-17 11:54:08 +03:00
dev bbfde902a5 feat(journal): live AI status updates in entry cards via Postgres NOTIFY trigger + SSE ai_status events 2026-09-17 11:14:27 +03:00
dev cc9a9e6c2d feat(journal): beautiful entrance animation for entries — cascade fade-up on load, yellow glow pop for newly submitted entries 2026-09-16 20:14:09 +03:00
dev 2784084c68 feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits 2026-09-16 20:09:06 +03:00
dev e8337c2845 feat(share): group entries by date on public /s/<token> page 2026-09-16 15:06:30 +03:00
dev 6cfb13310d chore: checkpoint before date grouping on share page 2026-09-16 15:05:56 +03:00
dev 2c8beedc4b feat: анти-спам блокировка повторной отправки на главной (cooldown по spam_interval_min)
- index.html/index.js: экран подтверждения отправки с обратным отсчётом
- локальный запрет повторной отправки через localStorage (по настройке spam_interval_min)
- server.js: отдаём spam_interval_min в public-settings (default 30)
- Dockerfile: timeout для apk add, чтобы сборка не зависала на недоступном зеркале
- docker-compose.yml: отключаем cloudflared сервис (закомментирован)
2026-09-13 12:27:35 +03:00
146 changed files with 22963 additions and 1233 deletions
+80
View File
@@ -4,6 +4,26 @@ DB_PASSWORD=случайная-длинная-строка
# Лимит размера загружаемого на восстановление бэкапа, МБ (по умолчанию 500)
BACKUP_UPLOAD_LIMIT_MB=500
# === Лимиты загрузки файлов ===
# Максимальный размер одного файла, МБ (по умолчанию 50). Применяется ко всем
# загрузкам: файлы проекта и фото учеников, фото групп и учеников, логотип,
# фото модулей. Нельзя делать меньше UPLOAD_TOTAL_LIMIT_MB.
UPLOAD_FILE_LIMIT_MB=50
# Максимальный суммарный размер вложений одной записи, МБ (по умолчанию 200).
UPLOAD_TOTAL_LIMIT_MB=200
# Таймаут приёма запроса, мс. Пусто — считается автоматически
# (UPLOAD_TOTAL_LIMIT_MB * 7500, минимум 5 минут). Увеличьте, если ученики
# жалуются на обрыв загрузки на медленной мобильной связи.
UPLOAD_REQUEST_TIMEOUT_MS=
# === Redis (кэш, rate limit, баны IP, pub/sub) ===
# Пароль Redis. Обязателен, если Redis включён в docker-compose.
REDIS_PASSWORD=замените-на-длинный-секрет
# Префикс ключей — позволяет держать несколько инстансов в одном Redis.
REDIS_PREFIX=whatido
# Лимит памяти Redis. При превышении вытесняются ключи с наименьшим TTL (allkeys-lru).
REDIS_MAXMEMORY=256mb
# Hugging Face token (нужен для закрытых/приватных репозиториев моделей)
HUGGINGFACE_TOKEN=
# Имя GGUF-файла модели для text-corrector (скачивается с Hugging Face, если отсутствует)
@@ -27,3 +47,63 @@ CLOUDFLARE_TUNNEL_URL=http://app:3003
# Максимальное ожидание WireGuard handshake перед fallback на прямой запуск
# туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется.
WG_HANDSHAKE_TIMEOUT=60
# === ИИ-улучшение фото (контейнер photo-ai, Real-ESRGAN + GFPGAN) ===
# По умолчанию фото-ИИ включено: сервис photo-ai поднимается вместе со стеком
# и работает на CPU. Оставьте значение пустым, чтобы выключить сервис —
# тогда кнопка «🤖 ИИ» скрыта, а /api/entries/:id/photo/enhance-ai отвечает 503.
# Ручные проверки сервиса идут по loopback-порту 127.0.0.1:8081 (наружу не публикуется):
# curl http://127.0.0.1:8081/health
PHOTO_AI_URL=http://photo-ai:8080
# Максимум пикселей входного изображения, более крупный вход уменьшается.
PHOTO_AI_MAX_PIXELS=4000000
# Устройство инференса: auto (CUDA, если контейнеру выдан GPU, иначе CPU), cuda, cpu.
# Явный cuda без доступной CUDA не роняет сервис: WARN в лог и работа на CPU.
# GPU-вариант собирается оверрайдом (отдельный образ whatido-photo-ai:cu126, GPU через CDI):
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
# Подробности установки NVIDIA Container Toolkit — в README, раздел «Запуск на NVIDIA GPU».
PHOTO_AI_DEVICE=auto
# Размер тайла инференса (0 — без тайлов). Меньше тайл — меньше памяти, медленнее.
# На GPU с 4 ГБ VRAM держите 256: при нехватке сервис сам пройдёт лестницу тайлов и уйдёт на CPU.
PHOTO_AI_TILE=256
# Модель восстановления лиц: gfpgan (по умолчанию) или codeformer.
# codeformer доступен только если модуль вендорен в photo-ai/vendor.
PHOTO_AI_FACE_MODEL=gfpgan
# Предзагрузка всех моделей при старте (1) или ленивая загрузка по требованию (0).
PHOTO_AI_LOAD_ALL=0
# Качество JPEG результата (70..100).
PHOTO_AI_JPEG_QUALITY=92
# Таймаут заданий с восстановлением лиц на стороне приложения (мс).
PHOTO_AI_FACE_TIMEOUT_MS=600000
# Сколько раз воркер повторит задание, если photo-ai недоступен (503/обрыв сети),
# не увеличивая attempts; после исчерпания — одна честная ошибка в задании.
PHOTO_AI_SOFT_MAX_RETRIES=60
# Пауза перед мягким повтором и её потолок (мс), задержка растёт вдвое до потолка.
PHOTO_AI_SOFT_BACKOFF_MS=10000
PHOTO_AI_SOFT_BACKOFF_MAX_MS=300000
# === Хранилище файлов (S3: SeaweedFS по умолчанию / MinIO) ===
# local — файлы в ./uploads (по умолчанию), s3 — объекты в бакете S3/MinIO.
STORAGE_DRIVER=local
# Читать локальную копию, если объект ещё не перенесён в S3 (1 — включено).
STORAGE_LOCAL_FALLBACK=1
# Оставлять локальную копию после выгрузки в S3 (1 — оставлять, 0 — удалять).
STORAGE_KEEP_LOCAL=0
# Сколько часов хранить локальный кэш оригиналов (для sharp/миниатюр), 0 — не чистить.
STORAGE_CACHE_MAX_AGE_HOURS=168
# Образ S3-сервиса. По умолчанию SeaweedFS (свободный S3-сервер).
# Для MinIO: S3_IMAGE=minio/minio:<тег> и запуск через docker-compose.minio.yml.
S3_IMAGE=chrislusf/seaweedfs:latest
S3_ENDPOINT=http://s3:9000
S3_REGION=us-east-1
S3_BUCKET=whatido
# Логин/пароль S3 (для SeaweedFS — AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY,
# для MinIO — MINIO_ROOT_USER/MINIO_ROOT_PASSWORD).
S3_ACCESS_KEY=whatido
S3_SECRET_KEY=замените-на-длинный-секрет
# Path-style адресация (1 — включено, нужно для MinIO и SeaweedFS).
S3_FORCE_PATH_STYLE=1
# Необязательный префикс ключей внутри бакета (например, prod).
S3_PREFIX=
# Показывать веб-консоль MinIO (on/off), только для docker-compose.minio.yml
MINIO_BROWSER=off
+3
View File
@@ -14,6 +14,9 @@ backups/
*.sql.gz
*.tar.gz
# --- Generated build artifacts ---
public/version.json
# --- Node ---
node_modules/
+212 -23
View File
@@ -2,16 +2,49 @@
This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.
> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы).
---
## Project Overview
**WhatIDo** — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Docker Compose, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + static frontend in `public/`
- **Deployment**: Docker Compose (app + db + tailscale), bind-mounted uploads, named volume for Postgres data
- **Auth**: Admin-only via `X-Admin-Token` header (value = `ADMIN_PASSWORD` env var). No user sessions.
- **Stack**: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
- **Architecture**: Single Express server (`server.js`) + storage abstraction (`storage.js`) + cache/pub-sub abstraction (`redis.js`) + static frontend in `public/`
- **Deployment**: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
- **Auth**: сессии в БД. `POST /api/auth/login` (bcrypt) → токен в заголовке `X-Auth-Token`. Роли: `admin` и не-admin, ограниченные филиалами (`user_branches`). `ADMIN_PASSWORD` используется **только** для автосоздания первого админа в пустой БД — это не механизм авторизации API
---
## Agent Workflow (обязательные правила работы)
**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`).
### Что делегировать суагенту (обязательно)
- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение.
- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3.
- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`».
- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`).
- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.
### Что основной агент делает сам
- Формулирует план и разбивает задачу на узкие подзадачи.
- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк).
- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю.
### Как ставить задачу субагенту
- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
- **Независимые разведки запускать параллельно**, а не последовательно.
- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент.
- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом.
### Чего не делать
- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк).
- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.
---
@@ -27,20 +60,72 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 2. Database
- **Schema**: Defined in `db/init.sql` (runs on first container start)
- **Migrations**: `db/migration.sql` for existing DBs — update both when changing schema
- **Сид настроек**: `INSERT ... ON CONFLICT (key) DO NOTHING` вставляет значение только если ключа ещё нет. Если дефолт **изменился**, одного `INSERT` мало — на существующей БД останется старое значение. Обязательно добавляй в `db/migration.sql` идемпотентный `UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...)`, как это сделано для `ai_prompt` и `lesson_ai_prompt`. Условие по `value IN (...)` обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В `db/init.sql` такой `UPDATE` не нужен — файл выполняется только на пустой БД
- **Проверка сида**: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони `db/migration.sql` в транзакции с откатом (`BEGIN;` + файл + `SELECT` + `ROLLBACK;`) и убедись, что `ON_ERROR_STOP=1` не дал ошибок
- **Connection**: Single `Pool` from `pg`, `DATABASE_URL` from env
- **Queries**: Parameterized only (`$1`, `$2`...), never string interpolation
- **Transactions**: Use `client.query('BEGIN')` / `COMMIT` / `ROLLBACK` for multi-statement ops
### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Limits**: 10 MB/file, 30 MB total per entry
- **Storage**: `uploads/` bind-mounted to host, filenames = `timestamp-random.ext`
- **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
- **HEIC**: Auto-converted to JPEG via `heic-convert`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/`
- **Cleanup**: `safeUnlink` / `sweepOrphanedUploads` — never delete outside `uploads/` or the configured bucket
### 3a. Storage (`storage.js`)
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
- **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`)
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
- **Never publish the S3 API port**: only `127.0.0.1` on the host, file access stays behind app auth/rate limits
### 3b. Redis (`redis.js`)
- **Единственная точка доступа**: `createRedis({ url, prefix })` — все операции кэша/счётчиков/pub-sub идут через неё
- **API**: `get`, `set`, `del`, `dropPrefix`, `dropMatch`, `clear`, `wrap`, `incr`, `publish`, `on`, `rateLimitStore`, `info`, `connect`, `close`
- **Graceful fallback — обязательное требование**: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
- **Первое подключение ограничено по времени** (`REDIS_CONNECT_TIMEOUT_MS`, 5 с): node-redis не отклоняет `connect()` при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
- **Переподключение**: node-redis переподключается сам; по событию `ready` подписки и subscriber-клиент восстанавливаются (`ensureSubscriber`). Не пересоздавать subscriber через `destroy()` — это гонка с внутренним teardown node-redis
- **Ключи**: `get`/`set` сами добавляют namespace (`REDIS_PREFIX`, по умолчанию `whatido`), `dropPrefix`/`dropMatch` тоже. В `rateLimitStore` префикс добавляется один раз в `base` — не применяйте `fullKey` повторно
- **`scanDelete`**: курсор `SCAN` в node-redis v5 обязан быть строкой, числовой `0` вызовет `TypeError`. Возвращаемое значение курсора — тоже строка, сравнивайте с `'0'`
- **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте
- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:`
- **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1`
### 3c. Уведомления (`server.js`, `worker.js`, `public/`)
- **Каталог событий** — только `NOTIFY_TYPES` в `server.js` (тип → `label`, `hint`, `icon`, `level`, `enabled` по умолчанию, `admin`); фронтенд берёт список из `GET /api/notifications/meta`, дублировать каталог в HTML нельзя
- **Таблицы**: `notifications` (событие, `admin_only`, `branch_id`) + `notification_reads` (прочтение на пользователя). Изменения схемы — в `db/init.sql` и `db/migration.sql` и в `ensureNotificationsTable()`
- **Настройки**: `notify_enabled` (общий), `notify_retention_days` (1–365), `notify_<тип>` (точки типа заменяются на `_`, см. `notifySettingKey`). Значения только `'true'` / `'false'` — `PUT /api/settings` это валидирует
- **Создание события** — только через `pushNotification()` / `notifyEntry()`; они сами проверяют переключатели и при выключенном типе возвращают `null`. `notifyEntry` подставляет `{student}` и `{group}` и определяет филиал по группе записи
- **Хук в фото-воркере называется `notifyEvent`** — имя `notify` внутри `createPhotoEnhanceWorker` уже занято будильником воркера (`photoWorker.notify()` из `POST /api/photo-jobs/wake`), объявление функции перекрыло бы параметр
- **Видимость**: админ видит всё; остальные — `admin_only = false` и `branch_id IS NULL` или филиал из `user_branches`
- **Доставка**: запись в БД → `cache.publish('whatido:notifications', row)` → SSE `GET /api/notifications/stream` (клиенты фильтруются по `notificationVisible`). Redis недоступен — работает in-memory pub/sub
- **Очистка**: `purgeOldNotifications()` при старте и раз в час по `notify_retention_days`; чтения удаляются каскадом
- **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide
- **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`)
### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`)
- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()`
- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выклюжатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html`
- **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `LESSON_AI_DEFAULT_PROMPT` (`server.js`), `db/init.sql`, `db/migration.sql` (там же `INSERT` для свежих БД) и **обязательно** `UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')` в `db/migration.sql` — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом `sec-lesson-ai`, в `settings` останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны `LESSON_AI_DEFAULT_PROMPT`
- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check`
- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error`
- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL`
- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт
- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js`
### 4. API Patterns
- **Admin routes**: `requireAdmin` middleware (checks `X-Admin-Token`)
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min)
- **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией
- **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется
- **Public routes**: `apiLimiter` (300/15min), `entryLimiter` (10/15min), `fileLimiter` (300/15min) — все на `cache.rateLimitStore(...)`, не на `MemoryStore`
- **Responses**: JSON, `{ error: 'message' }` on failure, data directly on success
- **Pagination**: `limit` / `offset` query params, return `{ items, total }` or `{ entries, total }`
- **Filters**: `group_id`, `date_from`, `date_to`, `student_name`, `search`, `deleted`
@@ -48,16 +133,18 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 5. Frontend (public/)
- Vanilla HTML/CSS/JS, no build step
- Each page = single HTML file + shared `admin.js` / `admin.css`
- API calls via `fetch` with `X-Admin-Token` from `localStorage`
- API calls via `fetch` with `X-Auth-Token` (токен из `localStorage`); `X-Admin-Token` больше не используется и не работает
- Share pages (`share.html`, `links.html`) work without auth
### 6. Docker / Compose
- **Dockerfile**: Node 20 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: 3 services (db, app, tailscale)
- **Dockerfile**: Node 22 Alpine, installs deps, generates self-signed TLS cert
- **docker-compose.yml**: сервисы `db`, `app`, `redis`, `s3` (+ опционально `tailscale`, `cloudflared`, `text-corrector`, `photo-ai`)
- `db`: postgres:16-alpine, healthcheck, init.sql mounted
- `redis`: redis:7-alpine, `--requirepass`, AOF, `maxmemory` + `allkeys-lru`, healthcheck, том `redis-data`, порт только на `127.0.0.1`
- `app`: builds from Dockerfile, exposes 3003/3443, mounts uploads
- `tailscale`: host network, NET_ADMIN, runs `start-tailscale.sh` (funnel to 127.0.0.1:3443)
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`
- **Env vars** (required): `ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`
- **Env vars** (optional): `REDIS_PREFIX` (default `whatido`), `REDIS_MAXMEMORY` (default `256mb`), `REDIS_CONNECT_TIMEOUT_MS` (default `5000`)
- **Port 443 on host** must be free (tailscale listens directly)
### 7. Tailscale Publication
@@ -68,15 +155,22 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Cert: app generates self-signed cert at build (`certs/cert.pem`), mounted into tailscale container
### 8. Backup / Restore
- **Admin UI**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz)
- **Admin UI**: `POST /api/backup` → тикет + `GET /api/backup/:token` (разовая ссылка, 30 мин), `POST /api/restore` (upload `.tar.gz`)
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
- Backup format: `data.json` (all tables) + `uploads/` directory
- Restore validates all data, resets sequences, sweeps orphans
- Формат архива: `tar.gz` с `data.json` + `uploads/`. Версия формата — `BACKUP_FORMAT_VERSION` в `backup-restore.js` (сейчас `2`), принимаются версии `1..2`; версия пишется в `data.json.version` и возвращается в ответе `POST /api/backup` и `POST /api/restore`
- `data.json` содержит `version`, `created_at`, `app` (версия/коммит), `counts` (строки по таблицам + `files`) и сами данные. Таблицы перечислены в `BACKUP_TABLES` — **при добавлении таблицы править её и в `buildBackupArchive`, и здесь**
- `sessions` в бэкап **не входит** намеренно: после restore все токены должны умереть. `audit_log`, `notifications`, `notification_reads`, `banned_ips` — входят
- Файлы: `storage.downloadAll` кладёт в архив всё, кроме регенерируемых `.thumbs/` и `.cache/`; `.originals/` (оригиналы фото до ИИ-обработки) **входят** и восстанавливаются через `uploadTree`
- Restore: валидация всего через `normalizeRestoreData` (`backup-restore.js`), транзакция с `DELETE` в FK-безопасном порядке → `INSERT` → `setval` по `BACKUP_SEQUENCE_TABLES` → файлы → `sweepOrphanedUploads()` → `loadBans()` → `invalidateAll()`
- **Колонки, которые normalizeRestoreData обязана сохранять**: `groups.deleted_at`/`purge_at`, `entries.purge_at`. Потеря `deleted_at` воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
- `sweepOrphanedUploads()` считает ссылками фото из `entries.photo_path`/`photo_original_path`, `project_files.path`, `group_photos`, `entry_photos`, `student_photos`, `modules.photo_path`, `students.photo_path`, `groups.cover_path`, `photo_jobs.before_path`/`after_path`, `settings.system_logo`. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore
---
## Common Tasks
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент.
### Add a new API endpoint
1. Add route in `server.js` (group with related routes)
2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public
@@ -102,6 +196,15 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
- Update Multer `fileFilter` functions
- Keep `MAX_TOTAL_UPLOAD_BYTES` and per-file limit in sync
### Migrate files to S3 / switch storage driver
1. `docker compose up -d s3`
2. `docker compose exec -T app node scripts/migrate-to-s3.js --dry-run` then without the flag (idempotent, size-checked, keeps local files)
3. `docker compose exec -T app node scripts/migrate-to-s3.js --verify-only`
4. Set `STORAGE_DRIVER=s3` in `.env`, `docker compose up -d app`
5. After verification: `docker compose exec -T app node scripts/migrate-to-s3.js --delete-local`
- Rollback: `STORAGE_DRIVER=local` + `docker compose up -d app`
- Do not run `--delete-local` before the app serves reads from S3 and the verification passes
---
## Testing & Verification
@@ -115,21 +218,72 @@ docker compose up -d --build
# Check logs
docker compose logs -f app
# Test API (replace TOKEN)
curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
# /api/groups — публичный (optionalAuth), 200 даже без токена:
# для проверки авторизации берите /api/auth/me или /api/users
# Run backup/restore scripts
./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz
# Storage checks
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
# Redis checks
node redis.selftest.js # unit + degradation, needs redis on 127.0.0.1:6379
node api.smoketest.js # e2e, needs running stack
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose stop redis && node api.smoketest.js # app must keep working in-memory
# Backup checks
node backup.selftest.js # unit, no stack needed
curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN" # -> counts по всем таблицам
docker compose start redis # app reconnects on its own
# Audit diff checks
node diff.selftest.js # unit, no stack needed
```
`diff.js` builds the audit payload for text changes: word-level segments
(`eq`/`del`/`add`), per-step stats (`added_words`, `removed_words`, `chars_before/after`) and a
light `summarizeChanges`/`stripDiffs` pair for the audit list. Two rules to keep:
the diff payload stored in `audit_log.target` must stay capped (it is rendered raw in the audit
UI), and `GET /api/audit` must keep stripping `diff` while `GET /api/audit/:id` returns it —
otherwise the list endpoint ships kilobytes of text per row.
Verify Redis state through `GET /api/system-info` → `cache` (`driver`, `ready`, `hits`, `misses`,
`fallbackOps`, `used_memory_human`, `keys`).
`GET /api/system-info` also returns a `stack` block (built by `getStackInfo()`, outside the
response cache so versions and load stay fresh): `app` (Node, PID, RSS/heap, uptime, version from
`package.json` + `public/version.json`), `deps` (installed versions of the main packages), `runtime`
(OS from `/etc/os-release`, kernel, arch, CPU count/model, loadavg, memory, container detection),
`database` (PostgreSQL version, host, pool counters), `cache` (driver, version, ready, keys, memory,
hits/misses, fallback ops) and `storage` (driver, endpoint, bucket). Hosts come from `URL.hostname`
only — credentials from `DATABASE_URL`/`REDIS_URL` must never reach the payload. The «Статус стека»
block on `public/settings.html` renders exactly this payload.
`api.smoketest.js` also locks the auth contract: only `X-Auth-Token` with a session token
authenticates, while `X-Admin-Token`, `Authorization: Bearer` and `ADMIN_PASSWORD` used as a
token must all be rejected with 401. If you change the auth scheme, update this test and the
Auth notes in this file together — a doc that drifts from the code is the failure mode this
guards against.
---
## Security Checklist (before any change)
- [ ] No SQL interpolation — only `$1`, `$2`...
- [ ] Upload path validation via `isSafeUploadPath` / `safeUnlink`
- [ ] File I/O через `storage.*`, ключи объектов не выходят за пределы бакета/`uploads/`
- [ ] Rate limiter on new public routes
- [ ] Admin routes behind `requireAdmin`
- [ ] New auth paths checked against the contract in `api.smoketest.js`, docs updated in the same change
- [ ] No secrets in code — only via env vars
- [ ] Helmet headers present (already global)
- [ ] CORS disabled (no `cors` middleware)
@@ -141,15 +295,26 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
| File | Purpose |
|------|---------|
| `server.js` | Entire backend (Express, routes, DB, uploads, backup) |
| `worker.js` | Background AI auto-check worker for entry messages |
| `storage.js` | Storage abstraction: `local` and `s3` drivers, key normalization, cache/thumb helpers |
| `redis.js` | Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback |
| `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable |
| `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests |
| `backup-restore.js` / `backup.selftest.js` | Backup format version, `normalizeRestoreData` validation of restore payloads, backup table lists; self-tests |
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance |
| `db/init.sql` | Initial schema (runs on fresh DB) |
| `db/migration.sql` | Idempotent migrations for existing DBs |
| `docker-compose.yml` | Service definitions (app, db, tailscale) |
| `docker-compose.yml` | Service definitions (app, db, s3, tailscale) |
| `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
| `Dockerfile` | App image build |
| `public/*.html` | Frontend pages |
| `public/admin.js` | Shared frontend logic |
| `scripts/backup.sh` | Host-level backup script |
| `scripts/restore.sh` | Host-level restore script |
| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) |
| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование |
| `scripts/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
| `scripts/migrate-to-s3.js` | One-off/idempotent migration `uploads/` -> S3 bucket |
| `scripts/deploy.sh` | Deploy script (pull master, build image with commit version, restart app) |
| `start-tailscale.sh` | Tailscale container entrypoint |
| `.env.example` | Env var template |
@@ -159,10 +324,22 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
- ❌ Add dependencies without updating `package.json` and rebuilding
- ❌ Write files outside `uploads/` or `certs/`
- ❌ Touch `uploads/` with `fs.*` in request/worker code — use `storage.*` (files may live only in S3)
- ❌ Run `migrate-to-s3.js --delete-local` before verification and cutover
- ❌ Expose the S3 API port publicly (only `127.0.0.1` in compose)
- ❌ Expose the Redis port publicly (only `127.0.0.1` in compose)
- ❌ Call `fs.*`/`pg` directly for cache, counters or pub/sub — use `redis.js`
- ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
- ❌ `await client.connect()` without a timeout — it never rejects while Redis is unreachable
- ❌ Cache authorization-relevant data without an invalidation path on the mutation
- ❌ Commit `.env`, `certs/`, `uploads/`, `backups/`, `node_modules/`
- ❌ Expose DB port (5432) outside docker network
- ❌ Use `eval`, `Function` constructor, or dynamic code execution
- ❌ Add comments to code (this file excepted)
- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges)
- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context
- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`)
---
@@ -172,12 +349,24 @@ curl -H "X-Admin-Token: $ADMIN_PASSWORD" http://localhost:3003/api/groups
# Full rebuild
docker compose down && docker compose up -d --build
# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
./scripts/deploy.sh
# App logs
docker compose logs -f app
# DB shell
docker compose exec db psql -U app -d whereldo
# Redis status and cache keys
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
# S3 storage status and migration verification
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
# Tailscale status
docker exec -it whatido-tailscale-1 tailscale status
+5 -2
View File
@@ -1,16 +1,19 @@
FROM node:20-alpine
FROM node:22-alpine
ENV TZ=Europe/Moscow
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY . .
RUN mkdir -p uploads certs
ARG GIT_COMMIT=""
ARG GIT_COMMIT_DATE=""
RUN node -e "require('fs').writeFileSync('public/version.json', JSON.stringify({ full: process.env.GIT_COMMIT || '', short: (process.env.GIT_COMMIT || '').slice(0, 7), date: process.env.GIT_COMMIT_DATE || '' }, null, 2))"
RUN set -e; \
V="v$(cut -d. -f1-2 /etc/alpine-release)"; \
try_repo() { \
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
echo "Trying apk mirror: $1"; \
apk add --no-cache openssl; \
timeout 45 apk add --no-cache --timeout 20 openssl; \
}; \
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
! try_repo "https://mirrors.edge.kernel.org/alpine" && \
+553
View File
@@ -0,0 +1,553 @@
# План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU
Статус: **план, реализация не начата**. Документ описывает целевую архитектуру, изменения по файлам,
порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.
---
## 1. Постановка задачи
Сейчас система улучшает фотографии одной моделью `RealESRGAN_x2plus` (x2, CPU, `half=False`,
`device='cpu'`, захардкожено в `photo-ai/app.py:44`). Модель хорошо восстанавливает текстуры
(одежда, фон, бумага), но **лица** при апскейле часто получают артефакты, «пластиковую» кожу и
искажённые черты, потому что у `RealESRGAN_x2plus` нет приора на структуру лица.
Нужно:
1. Добавить модели восстановления **лиц** — GFPGAN v1.4 и/или CodeFormer (оба — face restoration
с prior-сетью, работают в связке с `RealESRGANer` как `bg_upsampler`).
2. Дать пользователю **выбор модели** для обработки: универсальная x2, face-модель, комбинация
(фон + лица), быстрая VGG-модель `realesr-general-x4v3`.
3. Поддержать работу **на GPU и на CPU** с **автоматическим выбором** устройства: есть рабочий
CUDA — используем GPU, нет — молча и без падений уходим на CPU.
4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает `503`, воркер
ждёт, а не теряет задания.
5. Не сломать существующие контракты: `photo_jobs`, `/enhance`, `PHOTO_AI_URL`, автономную работу
без `photo-ai` (`PHOTO_AI_URL` пустой → кнопка «ИИ» недоступна).
### Ограничения окружения (проверено на текущем хосте)
| Параметр | Значение | Следствие |
|----------|----------|-----------|
| GPU | `NVIDIA GeForce RTX 3050 ...`, 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 | GPU-режим реален, но 4 ГБ VRAM — тесно, нужен `tile` и запас |
| `/dev/dri` | `card1`, `card2`, `renderD128`, `renderD129` | iGPU тоже виден, но torch будет использовать CUDA |
| `nvidia-ctk` | **не установлен** | нужен NVIDIA Container Toolkit на хосте, иначе `--gpus` не заработает |
| Runtime Docker | только `runc` (нет `nvidia`) | требуется установка toolkit + `docker compose` override |
| CPU | Intel i5-12500H, 16 потоков | CPU-режим приемлем как fallback, но медленный |
| RAM | 15 GiB (занято ~9 ГБ), swap 31 GiB | CPU-модели + torch требуют ~2–3 ГБ; следить за OOM |
| Диск | 59 ГБ свободно на `/home` | веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок |
| Docker / Compose | 29.8.1 / 5.5.1 | поддерживают `deploy.resources.reservations.devices` (Compose v5) |
Важно: **GPU-режим не должен быть обязательным условием запуска**. Если toolkit не установлен,
compose-файл с `devices` не поднимется — поэтому GPU выносим в отдельный override-файл.
---
## 2. Что уже есть (точки интеграции)
| Место | Что делает | Что меняем |
|-------|-----------|-----------|
| `photo-ai/app.py` | FastAPI, `POST /enhance` (`image`, `scale`), одна модель, `device='cpu'` | Расширяем до реестра моделей + автовыбор device + `POST /enhance` с `model`/`face` |
| `photo-ai/Dockerfile` | `python:3.10-slim`, torch CPU-only, правка `basicsr/data/degradations.py` | Разделяем на CPU-базу и GPU-базу (`ARG`), добавляем `gfpgan`, `facexlib` |
| `docker-compose.yml` (`photo-ai`) | build `./photo-ai`, `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models` | Добавляем env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`, healthcheck |
| `worker.js` → `createPhotoEnhanceWorker` | `runAiEnhance(srcKey)` шлёт `image` + `scale=2`, ждёт `image/jpeg` (таймаут 300 с, 3 попытки) | Передаём `model`/`face`/`strength` из `job.params`, разбираем JSON-ответ, 503 ждёт без траты попыток |
| `server.js` → `POST /api/entries/:id/photo/enhance-ai` | `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL)` | Принимаем `model`/`face`/`face_model`/`strength`, валидируем, пишем в `params` (колонка уже есть) |
| `server.js` → `GET /api/photo-jobs/status` | `ai_configured`, `ai_url`, `worker`, `counts` | Добавляем `service.device`, `service.models`, `service.face_models`, `service.ready` |
| `server.js` → `PHOTO_JOB_ACTIONS` | белый список действий, `action VARCHAR(20)` | Новые действия `ai_face`, `ai_upscale` в двух местах (валидация + restore) |
| `public/js/journal.js` (кнопка «🤖 ИИ») | Ставит задание `action='ai'` | Выбор модели: универсальная / быстрая / лица / обе |
| `public/js/worker.js` + `worker.html` | Таблица заданий, `PHOTO_ACTION_LABELS`, сравнение «Было/Стало» | Новые метки, показ модели/устройства/времени обработки |
| `settings.html` / `settings.js` | `photo_enhance_engine` (`auto`/`server`/`client`) | Новые настройки: `photo_ai_face_mode`, `photo_ai_device_pref` |
Текущее поведение, которое **сохраняем байт-в-байт**:
`/enhance` с `scale=2` без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92).
Это нужно, чтобы старые `photo_jobs` со `params = NULL` и все закешированные превью не изменились.
---
## 3. Модели: что именно добавляем
### 3.1 Каталог моделей
| Ключ | Класс | Веса | Размер | Назначение |
|------|-------|------|--------|-----------|
| `x2plus` | `RRDBNet(scale=2)` | `RealESRGAN_x2plus.pth` | 64 МБ | **текущая**, универсальный апскейл x2, дефолт |
| `general-x4v3` | `SRVGGNetCompact(upscale=4)` | `realesr-general-x4v3.pth` | 4.7 МБ | быстрый апскейл x4 + денойз через DNI (`realesr-general-wdn-x4v3.pth`) |
| `animevideo-v3` | `SRVGGNetCompact(upscale=4)` | `realesr-animevideov3.pth` | 2.4 МБ | быстрый x4, для скриншотов/иллюстраций |
| `gfpgan` | `GFPGANer(arch='clean', channel_multiplier=2)` | `GFPGANv1.4.pth` | 333 МБ | **восстановление лиц**, `bg_upsampler` = выбранный ESRGAN |
| `codeformer` | `CodeFormer` | `codeformer.pth` + `detection_Resnet50_Final.pth` + `parsing_parsenet.pth` | 360 МБ + ~110 МБ | **восстановление лиц**, регулируемая сила `fidelity_weight` (`-w`), лучше на сильных искажениях |
Рекомендация: **GFPGAN v1.4 как основная face-модель** (меньше весов, стабильнее на 4 ГБ VRAM,
это модель по умолчанию в апстриме `inference_realesrgan.py` через `--face_enhance`), CodeFormer —
опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически
неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».
### 3.2 Режимы обработки (`face_mode`)
| `face_mode` | Что происходит | Модель | Когда использовать |
|-------------|----------------|--------|--------------------|
| `off` | только апскейл фона, лицо не трогается | ESRGAN | текущее поведение, фон/текстуры |
| `face` | апскейл фона + **только лица** вставлены восстановленными (paste-back) | ESRGAN + GFPGAN/CodeFormer | портреты, крупные лица |
| `all` | как `face`, плюс мягкий денойз фона | ESRGAN(+wdn) + GFPGAN | зашумлённые снимки с веб-камеры 640×480 |
Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает
→ paste-back в альбом. Если лиц не найдено — возвращает чистый результат `bg_upsampler`: это
безопасный no-op и не должно считаться ошибкой.
### 3.3 Почему face-модель нельзя ставить «всегда»
1. **Скорость.** Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это
разница между ~40 с и ~90 с на фото 640×480.
2. **Гарантий нет.** GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или
закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка
идентификации недопустима, поэтому face-режим — **явный выбор пользователя**, а не молчаливый
дефолт.
3. **VRAM.** GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен `tile <= 256`.
### 3.4 Точные URL весов (скачиваются при первом запуске)
```
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth
```
Каждый файл кладём в `/models/weights/<name>.pth` (том `photo-ai-models`), загрузка через
`.tmp` + `os.replace` и проверку минимального размера — иначе оборванная закачка оставит «битые»
веса, и сервис будет падать при загрузке.
---
## 4. Автоматический выбор GPU/CPU
### 4.1 Приоритет выбора (в `app.py` при старте)
```
1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
2. если auto:
a. torch.cuda.is_available() and torch.cuda.device_count() > 0
-> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
b. иначе если torch.backends.mps.is_available()
-> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
c. иначе -> device = 'cpu', half = False
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
(контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)
```
### 4.2 Прогрев, ленивая загрузка и деградация
- `load_model()` вызывается в `startup` (сейчас через `asyncio.to_thread`), модели грузятся
**лениво по требованию** и кешируются в `MODELS` dict под `threading.Lock`. Первый запрос к
новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
- Пока нужная модель не готова, `/enhance` возвращает `503` + `Retry-After: 5`, а `/health`
отдаёт `{"ok": true, "ready": false, "loading": ["gfpgan"]}`. Воркер на `503` **не** тратит
попытку из `PHOTO_MAX_ATTEMPTS`: он ждёт и повторяет (см. §5.4).
- `CUDA out of memory` при инференсе — ловим `RuntimeError`, уменьшаем `tile` вдвое
(256 → 128 → 64) и повторяем **один раз**; если снова OOM — переключаемся на `cpu`,
инвалидируем модель и повторяем. Если и на CPU не получилось — `500` с понятным текстом.
- Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в
3 раза дольше» и немедленно выявляет OOM.
### 4.3 Что показывать оператору
`GET /health` (расширяем, обратно совместимо: поле `ok` остаётся):
```json
{
"ok": true,
"ready": true,
"device": "cuda:0",
"device_name": "NVIDIA GeForce RTX 3050 ...",
"half": true,
"tile": 256,
"driver": "615.71.09",
"cuda": "13.4",
"vram_total_mb": 4096,
"vram_free_mb": 3780,
"models": ["x2plus", "general-x4v3", "animevideo-v3"],
"face_models": ["gfpgan", "codeformer"],
"loaded": ["x2plus", "gfpgan"],
"max_pixels": 4000000
}
```
`server.js` проксирует это в `GET /api/photo-jobs/status` → `service` и в блок «Статус стека»
(`getStackInfo()`, `public/settings.html`), чтобы было видно, на чём реально считает фото-ИИ.
### 4.4 Docker: как отдать GPU контейнеру
Два обязательных шага на хосте (сейчас **не выполнены** — `nvidia-ctk` отсутствует):
```bash
# 1. NVIDIA Container Toolkit
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
# 2. Проверка
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
Compose делим на два файла, чтобы GPU был **опцией, а не требованием** (по образцу
существующего `docker-compose.minio.yml`):
- `docker-compose.yml` — `photo-ai` без GPU (CPU-образ, как сейчас). Стек поднимается на любой
машине.
- `docker-compose.gpu.yml` (новый) — override:
```yaml
services:
photo-ai:
build:
context: ./photo-ai
args:
TORCH_VARIANT: cu124
environment:
PHOTO_AI_DEVICE: cuda
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
```
Запуск GPU-режима:
```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
```
Если `photo-ai` уже запущен в CPU-режиме — сначала `docker compose stop photo-ai`, затем команда
выше (иначе Compose ругается на конфликт конфигурации сервиса).
---
## 5. Изменения по файлам
### 5.1 `photo-ai/app.py` (переписываем, ~250–300 строк)
Новая структура:
```
ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
pick_device() -> (device, half, device_name) # §4.1
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
FACE_REGISTRY = { 'gfpgan': {...}, 'codeformer': {...} }
class ModelPool: # Lock, dict, lazy load, OOM-ретрай
get_esrgan(name) -> RealESRGANer
get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
encode_jpeg(out, quality)
@app.get('/health') # расширенный контракт §4.3
@app.get('/models') # список моделей + устройство
@app.post('/enhance') # image, scale, model, face, face_model, strength
```
Ключевые детали реализации:
- `POST /enhance` принимает те же `image` и `scale`, плюс новые необязательные поля:
`model` (дефолт `x2plus`), `face` (`off`|`face`|`all`, дефолт `off`),
`face_model` (`gfpgan`|`codeformer`), `strength` (0.0–1.0, только CodeFormer, дефолт 0.7),
`jpeg_quality` (70–100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
- Полная обратная совместимость: без `model`/`face` → путь «x2plus, JPEG q92», как сегодня.
- `face != off` → `face_enhancer.enhance(img, has_aligned=False, only_center_face=False,
paste_back=True)`; в ответ добавляем `faces_found`.
- **Два формата ответа**: JSON (`{ok, image_base64, model, face, face_model, faces_found,
device, elapsed_ms, warnings}`) при `Accept: application/json` — новый путь для воркера;
сырой `image/jpeg` без заголовка — текущее поведение (совместимость с ручными `curl`).
- `upsampler.enhance()` уже делает тайлинг сам — `tile`/`tile_pad=10` остаются, `tile` берём из env.
- `half=True` только при CUDA; `dni_weight` — только для `general-x4v3` при `denoise_strength != 1`.
- Расширения определяем по имени файла, а не по `UploadFile.content_type` (браузер и `FormData`
в `worker.js` шлют `image/jpeg` для любого исходника — сейчас это уже так, сохраняем).
### 5.2 `photo-ai/Dockerfile`
```dockerfile
FROM python:3.10-slim
ARG TORCH_VARIANT=cpu # cpu | cu124
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
```
- `TORCH_VARIANT=cpu` → `torch torchvision --index-url .../whl/cpu` (как сейчас);
`cu124` → тот же `pip` с `.../whl/cu124`. Образ один, вариант — аргумент сборки.
- Добавить `gfpgan==1.3.8` и `facexlib==0.3.0` (CodeFormer — из TencentARC, пакет/вендоринг).
**Проверить доступность пакетов в зеркале pip заранее** — это риск сборки, если недоступны,
вендорим исходники в `photo-ai/vendor/` и копируем каталог в образ.
- `libgl1 libglib2.0-0` уже есть — facexlib их требует.
- Патч `basicsr/data/degradations.py` (`torchvision.transforms.functional_tensor` → `functional`)
**сохранить** — без него basicsr падает на torch >= 2.0.
- `ENV PHOTO_AI_MODELS_DIR=/models`; старый `MODEL_PATH` продолжаем читать как алиас, чтобы
существующий `.env`/том не сломался.
- Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при `--build`.
- Тома: `photo-ai-models` уже есть — все веса в `/models/weights/*.pth`.
### 5.3 `docker-compose.yml`
```yaml
photo-ai:
environment:
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
MODEL_PATH: /models/RealESRGAN_x2plus.pth
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 300s
```
`start_period: 300s` — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст
`unhealthy` на старте. `PHOTO_AI_LOAD_ALL=1` предзагружает все модели (для GPU-сервера с запасом
RAM), по умолчанию `0` — ленивая загрузка.
### 5.4 `worker.js` (функция `createPhotoEnhanceWorker`)
- `runAiEnhance(srcKey, params)`:
- читает `params.model`, `params.face`, `params.face_model`, `params.strength`;
- отправляет их вместе с `image` и `scale`; ставит `Accept: application/json`, разбирает
JSON-ответ, декодирует base64 в буфер;
- если сервис вернул `image/jpeg` (старая версия photo-ai) — работает как сейчас, без ошибок;
- `503` (`Retry-After`) обрабатывает отдельно: `sleep` 10 с, `return false` — **без** инкремента
`attempts` (мягкий повтор, задание не сгорает);
- `AbortError` от `AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS)` → сообщение
«ИИ-сервис не ответил за N с» (уже ошибка с попытками);
- в аудит пишет модель/устройство/время: `logAudit(null, 'photo.job.preview', { ..., model,
face, device, elapsed_ms })`.
- `processOne`: `job.action === 'ai' || job.action === 'ai_face'` → AI-путь; `enhance` → sharp.
Если `params` пусты, `action='ai_face'` даёт дефолт `face='face'`.
- `CONFIG` воркера дополняем `face_model`, `default_model`, `face_timeout_ms` — они попадают в
`GET /api/photo-jobs/status.worker.config`.
- Для face-режима на CPU вводим отдельный, больший таймаут: `PHOTO_AI_FACE_TIMEOUT_MS`
(дефолт 600 000) вместо 300 000.
### 5.5 `server.js`
1. `POST /api/entries/:id/photo/enhance-ai` (строка 5114) — тело
`{ model, face, face_model, strength }`:
- `model` ∈ `['x2plus', 'general-x4v3', 'animevideo-v3']`;
- `face` ∈ `['off', 'face', 'all']`;
- `face_model` ∈ `['gfpgan', 'codeformer']`;
- `strength` — число 0…1, только для CodeFormer;
- `params = JSON.stringify({ model, face, face_model, strength })` в `photo_jobs.params`;
- `action = face === 'off' ? 'ai' : 'ai_face'`;
- `400` при невалидных значениях; пустое тело → сегодняшнее поведение (`params = NULL`,
`action = 'ai'`). Валидация — инлайн-хелперами (`optInt`) и явными списками, как в
`PUT /api/settings`.
2. `PHOTO_JOB_ACTIONS` — добавить `'ai_face'`, `'ai_upscale'`. Схема не меняется
(`action VARCHAR(20)`), но белый список используется в двух местах: `ensurePhotoJobsTable()`
(~1238–1256) и при разборе restore-данных (~2259) — обновить **оба**.
3. `GET /api/photo-jobs/status` (строка 5782) — прокинуть `service.device`,
`service.device_name`, `service.ready`, `service.vram_free_mb`, `service.models`,
`service.face_models` из `GET /health` photo-ai (таймаут 5 с, по образцу `aiHealthCheck()`).
Запрос делать без падения: photo-ai недоступен → `service = { reachable: false }`.
4. Новый `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` для оператора.
В `getStackInfo()` добавить блок `photo_ai` (`device`, `device_name`, `vram_total_mb`,
`models`) — рендерится в «Статус стека» на `public/settings.html`.
5. Настройки — валидация рядом со строкой 1710:
- `photo_ai_face_mode` ∈ `['off', 'face', 'all']`, дефолт `off`;
- `photo_ai_device_pref` ∈ `['auto', 'cuda', 'cpu']`, дефолт `auto` — **только для UI и
документации**: фактическое устройство определяет контейнер через env, UI показывает,
совпадает ли желаемое с фактическим (если нет — подсветить).
- дефолты дописать в `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`)
и в `keys`/`defaults` `/api/public-settings` (строка 1663–1664);
- `photo_ai_enabled` там уже отдаётся (строка 1677) — сохранить.
6. `worker.js` вызывается с новыми параметрами: в `createPhotoEnhanceWorker({...})` (строка 6045)
добавить `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`, `defaultFaceModel: PHOTO_AI_FACE_MODEL`.
7. Предупреждения сервиса (`warnings`, например «вход меньше 320×320») сохранять в аудит, чтобы
оператор понимал, что face-режим не сработал не из-за ошибки.
### 5.6 Фронтенд
- `public/js/journal.js` (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)»,
«Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в
`POST /api/entries/:id/photo/enhance-ai` телом `{ model, face, face_model }`. Дефолт — из
`photo_ai_face_mode` (публичные настройки уже читаются на этой странице).
- `public/js/worker.js` — `PHOTO_ACTION_LABELS` дополнить (`ai_face`: «ИИ + лица»,
`ai_upscale`: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать `model`, `device`,
`faces_found`, `elapsed_ms`. Новых колонок в БД не нужно — данные берём из `params` и аудита.
- `public/settings.html` + `public/js/settings.js` — блок «Фото-ИИ»: селект face-режима, селект
устройства («желаемое» + строка «фактическое»), read-only статус (`device_name`, VRAM, список
моделей) из `/api/photo-jobs/status`. Новые поля добавить в `DIRTY_FIELDS` (строка 1
`settings.js`) и в сборку payload (строка ~739).
- `public/js/audit.js` — новые коды действий аудита прописать в словарь меток, иначе в UI будет
сырой код (`photo.job.preview` уже есть, при добавлении `photo.job.ai` — добавить и там).
### 5.7 Документация и тесты
- `AGENTS.md` — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override,
обновлённый контракт `/enhance` и `/health`.
- `README.md` — установка NVIDIA Container Toolkit, команда запуска с `docker-compose.gpu.yml`,
проверка `GET /api/photo-jobs/status` → `service.device`.
- `.env.example` — новые переменные с комментариями (см. §7).
- `api.smoketest.js` — добавить проверки:
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
- `face: 'face'` без `PHOTO_AI_URL` → `503`;
- `GET /api/photo-jobs/status` содержит `service` (или `ai_configured: false`).
Это соответствует правилу из `AGENTS.md`: контракт авторизации и API фиксируется в smoke-тесте
в том же изменении.
---
## 6. Порядок внедрения (этапы)
Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.
### Этап 0. Подготовка (0.5 дня)
- Установить NVIDIA Container Toolkit, проверить `docker run --gpus all ... nvidia-smi`.
- Зафиксировать текущее состояние `.env` (`PHOTO_AI_URL=` пусто или `http://photo-ai:8080`).
- Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать
`curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance`.
**Приёмка:** `nvidia-smi` внутри контейнера видит RTX 3050; эталонные JPEG сохранены для
сравнения на следующих этапах.
### Этап 1. `app.py`: реестр моделей + автовыбор device (1–1.5 дня)
- `pick_device()`, `ModelPool`, расширенный `/health`, ленивая загрузка x2plus, warm-up.
- Старый `/enhance` по поведению не меняется (проверить визуально и по размеру файла).
**Приёмка:** `/health` отдаёт `device`, `device_name`, `half`; `PHOTO_AI_DEVICE=cpu` при рабочей
CUDA даёт `cpu`; `PHOTO_AI_DEVICE=cuda` без toolkit даёт `cpu` + WARN, сервис поднимается.
### Этап 2. Dockerfile + compose (0.5–1 день)
- `ARG TORCH_VARIANT`, `docker-compose.gpu.yml`, healthcheck, новые env, алиас `MODEL_PATH`.
**Приёмка:** CPU- и GPU-сборка стартуют; `/api/photo-jobs/status` показывает
`service.device = "cuda:0"` в GPU-режиме и `"cpu"` в CPU-режиме; при пустом `PHOTO_AI_URL`
приложение полностью работает без photo-ai.
### Этап 3. GFPGAN — face-режим (1–2 дня)
- `FACE_REGISTRY`, поля `face`/`face_model`/`strength` в `/enhance`, JSON-ответ, `faces_found`,
OOM-ретрай и fallback на CPU.
**Приёмка:** на тестовом портрете 640×480 в режиме `face` лицо резче, `faces_found = 1`;
на фото без лиц — `faces_found = 0` и результат не хуже, чем `x2plus`; искусственный OOM
(`tile=1024` на 4 ГБ) деградирует до CPU без падения сервиса.
### Этап 4. Воркер и API (1 день)
- `worker.js`: `params` → модель/face, JSON-разбор, `503`-ожидание без траты попыток, аудит.
- `server.js`: валидация `params`, `action = 'ai_face'`, `service` в статусе, новые настройки.
**Приёмка:** задание через API с `face='face'` доходит до `done`, параметры сохранены в
`photo_jobs.params`, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание,
которое дообработается после возврата сервиса (без `error`).
### Этап 5. Фронтенд (1 день)
- Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.
**Приёмка:** из журнала доступны все 4 варианта; после постановки видно «В очереди», затем
сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.
### Этап 6. Docs + smoke (0.5 дня)
- `AGENTS.md`, `README.md`, `.env.example`, `api.smoketest.js`.
**Приёмка:** `node api.smoketest.js` проходит; тесты валидации возвращают `400`; документация
совпадает с кодом.
**Итого:** ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы `curl`).
---
## 7. Новые переменные окружения
```bash
# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
PHOTO_AI_URL=
# Максимум входных пикселей (даунскейл перед обработкой)
PHOTO_AI_MAX_PIXELS=4000000
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
PHOTO_AI_DEVICE=auto
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
PHOTO_AI_TILE=256
# Модель лиц: gfpgan | codeformer | none
PHOTO_AI_FACE_MODEL=gfpgan
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
PHOTO_AI_LOAD_ALL=0
# Режим лиц по умолчанию для UI: off | face | all
PHOTO_AI_FACE_MODE=off
# Качество JPEG результата (70–100)
PHOTO_AI_JPEG_QUALITY=92
# Отдельный таймаут для face-режима, мс (на CPU медленно)
PHOTO_AI_FACE_TIMEOUT_MS=600000
```
---
## 8. Риски и как их закрываем
| Риск | Вероятность | Митигация |
|------|-------------|-----------|
| `nvidia-container-toolkit` не установлен / политика хоста запрещает | средняя | GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в `/health` видно фактическое устройство |
| 4 ГБ VRAM не хватает для GFPGAN + x2plus | высокая | `tile=256` по умолчанию, авто-снижение до 128/64 при OOM, `half=True` только на CUDA, при повторном OOM — fallback на CPU |
| GFPGAN «портит» лица (артефакты идентичности) | средняя | face-режим только по явному выбору; результат попадает в `photo_jobs.after_path` и **не применяется автоматически** (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются |
| Долгая загрузка весов (333 МБ) на первом запросе | высокая | ленивая загрузка + `503`/`Retry-After` вместо ошибки; `PHOTO_AI_LOAD_ALL=1` для прогрева; том `photo-ai-models` сохраняет веса между перезапусками; `start_period: 300s` в healthcheck |
| Сборка ломается на `pip install gfpgan/facexlib` (пакета нет в зеркале) | средняя | **проверить доступность пакетов до начала работ**; при отсутствии — вендорить исходники в `photo-ai/vendor/` и копировать каталог в образ |
| Рост образа до ~6–7 ГБ (cu124) | средняя | CPU-образ по умолчанию, GPU-образ собирается отдельно; на `/home` свободно 59 ГБ |
| CPU-инференс с GFPGAN медленнее таймаута воркера | средняя | отдельный `PHOTO_AI_FACE_TIMEOUT_MS` (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — `off` |
| Регресс текущего `/enhance` | низкая | контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые `photo_jobs.params = NULL` продолжают работать |
| Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU | средняя | `PHOTO_AI_LOAD_ALL=0`, кеш моделей с вытеснением (LRU, максимум 2), контроль через `docker stats` |
| Воркер зацикливается на «вечно недоступном» сервисе | низкая | при `503`/`unreachable` — экспоненциальный `sleep`, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом |
---
## 9. Критерии готовности
1. `photo-ai` стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; `/health`
сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
4. Задание с face-моделью проходит полный цикл `pending → processing → done`; результат виден в
сравнении «Было/Стало», применим вручную и откатывается.
5. Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` **не** ломают приложение:
`PHOTO_AI_URL=` пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (`503`), не
сжигая попытки; пустой `.env` полностью совместим.
6. `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.
---
## 10. Быстрые команды для проверки после реализации
```bash
# Статус устройства и моделей
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool
# Прямой вызов с лицами (ручная проверка)
curl -s -X POST http://localhost:8080/enhance \
-F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
-H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"
# CPU-режим принудительно
docker compose stop photo-ai
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai
# GPU-режим
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
# Проверка обратной совместимости (без model/face — как раньше)
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg
# Смоук
node api.smoketest.js
```
+6 -3
View File
@@ -46,7 +46,8 @@
| ENT-5 | Trash view: list deleted entries, restore, permanent delete | Must |
| ENT-6 | Pagination (limit/offset) + total count | Must |
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, 10 MB each, 30 MB total), download by token | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
| ENT-9 | Video attachments: `mp4`/`m4v`/`webm`/`ogv` playable inline in the journal with seeking (Range), other video formats download-only | Should |
### 2.4 Files Management (Centralized)
| ID | Requirement | Priority |
@@ -175,14 +176,16 @@ branches
| GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files |
| POST | `/api/files/:id/detach` | Admin | Detach file |
| GET | `/api/files/:token` | Public | Download file by token |
| GET | `/api/files/:token` | Public | Download file by token; `?play=1` serves `mp4`/`m4v`/`webm`/`ogv` inline with `Range` support |
| GET | `/api/groups` | Public | List groups |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
| GET | `/api/share/:token` | Public | Share page data |
| GET | `/api/links` | Admin | List share links |
| POST/PUT/DELETE | `/api/links` | Admin | CRUD share links |
| GET | `/api/backup` | Admin | Download backup |
| GET | `/api/backup` | Admin | Build & download backup (compat) |
| POST | `/api/backup` | Admin | Build backup, returns download URL |
| GET | `/api/backup/:token` | Token | Download built backup (resumable) |
| POST | `/api/restore` | Admin | Upload & restore backup |
| GET | `/api/dashboard` | Admin | Dashboard data |
| GET | `/api/stats` | Admin | Stats cards |
+335 -11
View File
@@ -11,8 +11,11 @@
- **Воспитанники** — справочник с привязкой к группам
- **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат
- **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников
- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением
- **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz`
- **Настройки** — тексты футера, анти-спам интервал
- **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции
- **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`)
- **Уведомления** — системные события (новые записи журнала, обработка фото нейросетью, ошибки авто-проверки текста, блокировки IP, бэкапы) собираются в «колокольчике» и на странице «Уведомления»; набор событий включается/выключается в «Настройках» → «Уведомления»
- **Публикация через Tailscale** — приложение открывается по постоянному адресу `https://whatido.<tailnet>.ts.net` без проброса портов, внешнего IP и reverse-proxy
## Технологии
@@ -20,6 +23,8 @@
- Node.js + Express
- PostgreSQL (pg)
- Multer (загрузка файлов), Tar (бэкапы)
- S3-совместимое хранилище (AWS SDK v3): сервис `s3` (SeaweedFS / MinIO)
- Redis: кэш, rate limit, баны IP, кэш сессий, pub/sub (SSE и воркеры)
- Lucide (иконки UI)
- Docker / Docker Compose
- Tailscale (Serve / Funnel) — публикация по HTTPS
@@ -48,6 +53,7 @@ docker compose up -d --build
- **HTTP** `http://localhost:3003` — редирект на HTTPS
- **HTTPS** `https://localhost:3443` — приложение (самоподписанный сертификат, примите предупреждение браузера)
- **PostgreSQL** — доступен только внутри docker-сети (наружу не публикуется)
- **Redis** — `127.0.0.1:6379` на хосте (только loopback), внутри сети — `redis:6379`
Управление:
@@ -59,6 +65,35 @@ docker compose down # остановка (данные сохраняю
> Приложение **не запустится** без `ADMIN_PASSWORD` (защита от пароля по умолчанию).
> `DB_PASSWORD` задаёт пароль пользователя `app` в PostgreSQL.
> `REDIS_PASSWORD` задаёт пароль Redis. Если сервис `redis` убрать из `docker-compose.yml`
> или оставить `REDIS_URL` пустым — приложение продолжит работать на in-memory кэше.
## Обновление на сервере (деплой)
Код приложения находится внутри образа: bind-монтируется только `uploads/`. Поэтому после `git pull` нужна **пересборка образа** — `docker compose up -d` без `--build` и `docker compose restart` новый `server.js` и статику не подхватят.
```bash
./scripts/deploy.sh # ветка master (либо $DEPLOY_BRANCH, либо первый аргумент)
```
Скрипт проверяет рабочую копию, обновляет ветку (`fetch` + `checkout` + `pull --ff-only`), собирает образ с версией коммита, перезапускает `app`, затем сверяет `server.js` в контейнере с рабочей копией и печатает `/version.json`.
Вручную то же самое:
```bash
git checkout master && git pull --ff-only origin master
docker compose build --build-arg GIT_COMMIT=$(git rev-parse HEAD) --build-arg GIT_COMMIT_DATE=$(git log -1 --format=%cI) app
docker compose up -d app
```
Проверка:
```bash
curl -sk https://127.0.0.1:3443/version.json # версия собранного коммита
docker compose exec app md5sum /app/server.js # совпадает с md5sum server.js
```
Версия сборки записывается в `public/version.json` внутри образа из аргументов `GIT_COMMIT` / `GIT_COMMIT_DATE` (`build.args` в `docker-compose.yml`, подставляет `scripts/deploy.sh`) и показывается в сайдбаре админки — она всегда соответствует собранному коду, даже если файл в рабочей копии устарел. Локально файл обновляет хук: `cp scripts/post-commit.sh .git/hooks/post-commit`. Если образ собран без аргументов (`docker compose build` вместо `deploy.sh`), версия в сайдбаре будет пустой.
## Конфигурация
@@ -66,20 +101,105 @@ docker compose down # остановка (данные сохраняю
| Переменная | По умолчанию | Назначение |
|------------------|--------------------|-------------------------------------|
| `ADMIN_PASSWORD` | — (обязательно) | Пароль администратора (X-Admin-Token). Без него сервер не стартует |
| `ADMIN_PASSWORD` | — (обязательно) | Пароль первого администратора, создаётся в пустой БД. Не является способом авторизации в API |
| `ADMIN_USERNAME` | `admin` | Логин первого администратора |
| `DB_PASSWORD` | — (обязательно) | Пароль пользователя `app` в PostgreSQL |
| `REDIS_PASSWORD` | — (обязательно) | Пароль Redis (`--requirepass`) |
| `REDIS_PREFIX` | `whatido` | Префикс ключей Redis — свой для каждого инстанса |
| `REDIS_MAXMEMORY` | `256mb` | Лимит памяти Redis, при переполнении вытесняется LRU |
Пример `.env` (в репозитории — `.env.example`):
```
ADMIN_PASSWORD=сложный-пароль
DB_PASSWORD=случайная-длинная-строка
REDIS_PASSWORD=случайная-длинная-строка
```
`DB_PASSWORD` подставляется в `docker-compose.yml` в `POSTGRES_PASSWORD` и `DATABASE_URL`. Если БД уже была инициализирована ранее, значение `DB_PASSWORD` должно совпадать с фактическим паролем пользователя `app` в БД (иначе приложение не подключится).
Имя узла Tailscale задаётся в `docker-compose.yml` (`tailscale.hostname`, по умолчанию `whatido`).
## ИИ-улучшение фото (photo-ai)
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` | Адрес сервиса. **Пустое значение = сервис выключен**: кнопка «🤖 ИИ» скрыта, `POST /api/entries/:id/photo/enhance-ai` отвечает `503`, приложение при этом полностью работоспособно |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | Максимум пикселей входного изображения, вход большего размера уменьшается |
| `PHOTO_AI_DEVICE` | `auto` | Устройство инференса: `auto` (CUDA, если контейнеру выдан GPU, иначе CPU), `cuda`, `cpu`. Явный `cuda` без CUDA не роняет сервис: WARN в лог и работа на CPU |
| `PHOTO_AI_TILE` | `256` | Размер тайла инференса (`0` — без тайлов): меньше тайл — меньше памяти, но медленнее |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | Модель восстановления лиц: `gfpgan`; `codeformer` доступен только при вендоринге модуля в `photo-ai/vendor` |
| `PHOTO_AI_LOAD_ALL` | `0` | Загружать все модели при старте (`1`) или лениво по требованию (`0`) |
| `PHOTO_AI_JPEG_QUALITY` | `92` | Качество JPEG результата, 70..100 |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | Таймаут заданий с восстановлением лиц (мс) |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | Сколько раз задание ждёт недоступный сервис, не увеличивая счётчик попыток; после исчерпания — честная ошибка |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
### Запуск на NVIDIA GPU
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
и NVIDIA Container Toolkit.
```bash
# 1. Toolkit (один раз, требует sudo)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
# 2. GPU-образ (для устройств с поддержкой CDI спеку генерирует сам toolkit)
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
sudo systemctl restart docker
# 3. Запуск
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
curl http://127.0.0.1:8081/health
```
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
GPU выдаётся контейнеру через CDI (`device_ids: nvidia.com/gpu=all`), поэтому править
`/etc/docker/daemon.json` и перезапускать демон не нужно. Если nvidia-runtime уже зарегистрирован в
демоне (`nvidia-ctk runtime configure --runtime=docker`), в оверрайде можно заменить это на
классическое резервирование `driver: nvidia, count: 1` — результат тот же.
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
в `warnings` — задание при этом не падает.
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
`text-corrector`). Ручные проверки сервиса:
```bash
curl http://127.0.0.1:8081/health
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
```
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
`worker` — состояние очереди и конфигурация воркера.
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
## Публичный доступ через Tailscale
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
@@ -212,20 +332,186 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
- `group_photos` — фотохроника групп
- `share_links` — публичные ссылки-витрины
- `settings` — пары ключ/значение (анти-спам интервал, футер)
- `audit_log` — журнал действий (`action`, `target` JSONB, `ip`, `user_id`)
Схема инициализируется при первом запуске из `db/init.sql`; миграции существующей БД — в `db/migration.sql`.
## Аудит изменений текста
Каждое сохранение записи журнала (`PUT /api/entries/:id`) сравнивает состояние «до» и «после» и пишет в `audit_log` не только факт, но и сами изменения:
```json
{
"id": 363,
"source": "ai",
"changed": true,
"fields": ["description", "group_id"],
"changes": [
{ "field": "description", "label": "Текст работы",
"stats": { "added_words": 5, "removed_words": 2, "chars_before": 75, "chars_after": 97 },
"diff": [{ "type": "del", "text": "учитель" }, { "type": "add", "text": "очень " }] },
{ "field": "group_id", "label": "Группа", "before": "4 · Суббота 9:00", "after": "5 · Суббота 11:30" }
]
}
```
- `source` — источник правки: `manual` (вручную), `ai` (текст принят из подсказки ИИ), `ai_manual` (ИИ + ручная правка), `ai_revert` (откат к оригиналу)
- `diff` — пословный дифф (`eq` / `del` / `add`) с подсветкой в интерфейсе: удалённое зачёркнуто, добавленное выделено
- `stats` — сколько слов и символов добавлено и удалено на этом шаге
- те же данные пишутся для автопроверки ИИ (`entry.ai.auto-check`) и отката (`entry.ai.revert`)
Список `GET /api/audit` отдаёт облегчённый `target` (без `diff`), полный — `GET /api/audit/:id`: страница «Аудит» подгружает его при открытии деталей.
Пословный дифф и сборка изменений вынесены в `diff.js` (без зависимостей, с обрезкой слишком больших текстов), тесты — `node diff.selftest.js`.
## Хранилище файлов
Загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`). Это даёт прямой доступ к данным из-под хост-системы. Данные БД хранятся в именованном томе `pgdata`.
По умолчанию загруженные фото и файлы хранятся в каталоге `uploads/` на хосте и монтируются в контейнер (`./uploads:/app/uploads`) — это драйвер `local`. Данные БД хранятся в именованном томе `pgdata`.
Дополнительно поддерживается **S3-совместимое хранилище** (сервис `s3` в compose, драйвер `s3`). Все обращения к файлам идут через приложение: URL (`/uploads/...`, `/uploads/thumb/...`, `/api/files/:token`, share-ссылки) и записи в БД (`/uploads/<файл>`) не меняются, поэтому переключение драйвера не требует миграции данных в БД.
### Сервис `s3`
```bash
docker compose up -d s3 # поднимает S3-хранилище (том s3-data)
```
- **По умолчанию — SeaweedFS** (`chrislusf/seaweedfs`): свободный S3-сервер; API слушает `127.0.0.1:9000` на хосте и `s3:9000` внутри compose-сети.
- **MinIO**: официальные свободные образы `minio/minio` удалены из Docker Hub, поэтому MinIO подключается через оверрайд и образ из доступного вам зеркала:
```bash
S3_IMAGE=<ваш-образ-minio> docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
```
Бакет создаётся автоматически при старте приложения (`ensureBucket`) или скриптом миграции. Анонимный доступ к API хранилища закрыт: порт `9000` не публикуется наружу (только loopback), доступ к файлам остаётся через приложение с его аутентификацией и rate limit.
### Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `STORAGE_DRIVER` | `local` | `local` — файлы в `uploads/`, `s3` — объекты в бакете |
| `S3_ENDPOINT` | `http://s3:9000` | Адрес S3 API внутри compose-сети |
| `S3_BUCKET` | `whatido` | Бакет для объектов |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `whatido` / — | Доступ к хранилищу (для MinIO это root-пользователь) |
| `S3_FORCE_PATH_STYLE` | `1` | Path-style адресация (нужна MinIO/SeaweedFS) |
| `S3_PREFIX` | — | Необязательный префикс ключей внутри бакета |
| `STORAGE_LOCAL_FALLBACK` | `1` | Читать локальный файл, если объекта в S3 ещё нет |
| `STORAGE_KEEP_LOCAL` | `0` | Оставлять локальную копию после выгрузки в S3 |
| `STORAGE_CACHE_MAX_AGE_HOURS` | `168` | Срок жизни локального кэша оригиналов (для sharp/миниатюр) |
### Переход на S3 (миграция)
Порядок не прерывает работу: файлы сначала копируются в бакет, локальные остаются на месте и продолжают использоваться.
```bash
# 1) поднять хранилище
docker compose up -d s3
# 2) предпросмотр и загрузка файлов в бакет (идемпотентно, по размеру объекта)
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js
# 3) проверить, что все объекты на месте (ничего не меняет)
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
```
Дальше включить драйвер `s3` и перезапустить приложение:
```bash
# в .env: STORAGE_DRIVER=s3
docker compose up -d app
```
Новые загрузки уходят в бакет (локальная копия удаляется, если `STORAGE_KEEP_LOCAL=0`), старые файлы ещё читаются из `uploads/` благодаря `STORAGE_LOCAL_FALLBACK=1`. Когда всё проверено — удалите локальные копии:
```bash
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
```
Откат в любой момент: `STORAGE_DRIVER=local` + `docker compose up -d app` (пока локальные копии не удалены).
Объём и состав хранилища видны в админке: Настройки → Системная информация (блок «Хранилище»).
## Redis (кэш и pub/sub)
Сервис `redis` в compose хранит всё, что не требуется переживать перезапуск Postgres, но должно
быть общим и быстрым:
| Что | Ключи | TTL |
|---|---|---|
| Кэш ответов API и настроек | `setting:*`, `groups:*`, `students:*`, `entries:*`, `stats:*`, `dashboard:*`, `share:payload:*`, `public-settings`, `system-info` | 15–60 с |
| Кэш сессий | `session:<token>` | 30 с |
| Счётчики rate limit | `rl:api:*`, `rl:entry:*`, `rl:file:*` | окно окна + 10 % |
| Баны IP | `ban:<ip>` | до `banned_until` |
| Счётчики неудачных попыток входа | `fail:<kind>:<ip>` | 15 мин |
Инвалидация кэша — по префиксу (`SCAN` + `DEL`), поэтому после правки настроек, группы или записи
новое значение видно сразу. Правки пользователей сбрасывают `session:*`, так что деактивация
аккаунта и выход из сессии действуют немедленно.
Через pub/sub каналы `whatido:events`, `whatido:wake:ai` и `whatido:wake:photo` доставляют SSE-события
клиентам и будят фоновых воркеров без ожидания цикла опроса БД.
### Отказоустойчивость
Если Redis недоступен, приложение **не падает**: `redis.js` прозрачно переключается на
in-memory кэш (та же семантика и те же ключи) и возвращается в Redis автоматически, как только
сервис поднимется. Первое подключение ограничено таймаутом `REDIS_CONNECT_TIMEOUT_MS` (5 с по
умолчанию), поэтому недоступный Redis не задержит старт приложения. Текущее состояние видно в
`GET /api/system-info` → `cache.driver` (`redis` или `memory`) и в блоке «Кэш» на странице
Настроек → Стек (там же — «нет связи — в памяти», если Redis не отвечает).
### Команды
```bash
docker compose up -d redis # поднять только Redis
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning TTL 'whatido:public-settings'
```
Данные Redis сохраняются в томе `redis-data` (AOF, `appendfsync everysec`), поэтому кэш и счётчики
переживают перезапуск контейнера. Порт `6379` публикуется только на `127.0.0.1`.
Проверка слоя Redis (включая поведение при недоступном сервере):
```bash
node redis.selftest.js # юнит-тесты redis.js
node api.smoketest.js # сквозная проверка API (нужен запущенный стек)
```
## Уведомления
Система уведомлений — журнал событий (`notifications`) с отметками прочтения на пользователя (`notification_reads`) плюс каталог типов событий `NOTIFY_TYPES` в `server.js`.
| Тип | Событие | Кому видно |
|-----|---------|------------|
| `entry.new` | новая запись в журнале (форма ученика или ручное добавление) | филиал группы |
| `entry.ai.corrected` | ИИ исправил текст (по умолчанию выключено) | филиал группы |
| `entry.ai.error` | авто-проверка текста не удалась | филиал группы |
| `photo.job.done` | фото обработано нейросетью или сервером | филиал группы |
| `photo.job.error` | очередь обработки фото исчерпала попытки | филиал группы |
| `ip.ban` | IP отправлен в бан (авто или вручную) | только админ |
| `backup.restore` | восстановление из бэкапа | только админ |
| `backup.create` | создан архив бэкапа (по умолчанию выключено) | только админ |
Где видно: «колокольчик» в боковом меню (панель последних событий, бейдж непрочитанных, опциональные уведомления браузера) и страница `notifications.html` (фильтр «непрочитанные», отметка «прочитано», удаление и полная очистка для админа). Новые события приходят в реальном времени по SSE (`GET /api/notifications/stream`), транспорт — Redis pub/sub с in-memory fallback.
Что настраивается в «Настройках» → «Уведомления» (ключи таблицы `settings`): общий выключатель `notify_enabled`, срок хранения `notify_retention_days` (1–365 дней, старые уведомления удаляются ежечасно) и отдельный переключатель `notify_<тип>` для каждого события. Там же кнопка тестового уведомления.
Видимость: администратор видит все уведомления, остальные — только события своего филиала (или без филиала) и никогда — события с пометкой `admin_only`.
## Бэкапы
В админке (Настройки → Бэкап) можно:
- Скачать полный бэкап — `tar.gz`, содержащий `data.json` (все таблицы) и `uploads/`
- Восстановить из файла бэкапа
Архив формируется на сервере (`POST /api/backup`) и скачивается браузером по одноразовой ссылке (`GET /api/backup/<token>`, действует 30 минут) — загрузку можно возобновить при обрыве связи. Совместимый эндпоинт `GET /api/backup` отдаёт тот же архив сразу.
Также доступны скрипты на хосте:
```bash
@@ -233,12 +519,17 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
./scripts/restore.sh # восстановление из архива
```
Форматы не взаимозаменяемы: скриптовый архив содержит `db.sql.gz` + `_uploads/` (перенос на другой хост через `scripts/restore.sh`), а веб-архив из админки — `data.json` + `uploads/` (кнопка «Восстановить»). Если в админку загрузить скриптовый архив, сервер вернёт подсказку, какой инструмент использовать.
Файлы попадают в бэкап из активного хранилища: при `STORAGE_DRIVER=s3` админ-бэкап и `scripts/backup.sh` выгружают объекты из бакета (`scripts/storage-sync.js export`), а восстановление загружает их обратно (`scripts/storage-sync.js import`). Миниатюры (`.thumbs`) в архив не включаются — они пересоздаются по запросу.
## Безопасность
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`); фолбэка на `admin` нет.
- **Пароль администратора** обязателен (`ADMIN_PASSWORD`) — он создаёт первого админа в пустой БД; фолбэка на `admin` нет. Самостоятельной роли в API не даёт: доступ только по сессиям.
- **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: 30 МБ суммарно на запись, 10 МБ на файл; заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
@@ -261,24 +552,57 @@ docker compose exec cloudflared wg show # есть handshake — VPN подн
| `GET/POST/PUT/DELETE` | `/api/share/...`, `/api/links` | Публичные ссылки |
| `GET` | `/api/backup` | Скачать бэкап |
| `POST` | `/api/restore` | Восстановить из бэкапа |
| `GET` | `/api/notifications` | Уведомления пользователя (`limit`, `offset`, `unread=1`) |
| `GET` | `/api/notifications/stream` | SSE-поток уведомлений (заголовок `X-Auth-Token` или `?token=`) |
| `GET` | `/api/notifications/meta` | Каталог типов событий и текущие переключатели (admin) |
| `POST` | `/api/notifications/:id/read`, `/api/notifications/read-all` | Отметить прочитанным |
| `POST` | `/api/notifications/test` | Тестовое уведомление (admin) |
| `DELETE` | `/api/notifications/:id`, `/api/notifications` | Удалить уведомление / очистить все (admin) |
| `GET` | `/api/dashboard`, `/api/stats` | Статистика |
Защищённые админ-маршруты требуют заголовок `X-Admin-Token` с `ADMIN_PASSWORD`.
Авторизация — по сессиям, не по статическому токену:
1. `POST /api/auth/login` с `username` и `password` возвращает `{ token, expires_at }`.
2. Токен передаётся в заголовке `X-Auth-Token` во все защищённые запросы; `POST /api/auth/logout` удаляет сессию.
3. `GET /api/auth/me` — текущий пользователь (`id`, `username`, `role`, `is_active`, `branch_ids`).
Заголовок `X-Admin-Token` больше не поддерживается. Маршруты помечены `requireAuth` (любой активный пользователь) или `requireAdmin` (только `role = admin`); филиалы не-admin ограничены его `user_branches`.
Защищённые маршруты:
| Метод | Путь | Доступ |
|---|---|---|
| `GET/POST/PUT/DELETE` | `/api/users`, `/api/users/:id` | admin |
| `GET/POST/DELETE` | `/api/bans` | admin |
| `GET/POST/PUT/DELETE` | `/api/branches`, `/api/branches/:id` | admin (список — любой активный) |
| `GET/POST/DELETE` | `/api/settings` | admin |
| `GET` | `/api/audit` | admin |
| `GET` | `/api/audit/:id` | admin (полный target с текстовым диффом) |
| `GET` | `/api/backup`, `POST /api/restore` | admin |
Сессия хранится в таблице `sessions` (срок 30 дней) и кэшируется в Redis на 30 секунд.
## Структура проекта
```
├── docker-compose.yml # сервисы: app + db + tailscale
├── docker-compose.yml # сервисы: app + db + redis + s3 (+ опционально tailscale)
├── docker-compose.minio.yml # оверрайд: S3-сервис на MinIO вместо SeaweedFS
├── .env.example # шаблон переменных окружения
├── Dockerfile # сборка образа (Node 20, генерация TLS-сертификата)
├── Dockerfile # сборка образа (Node 22, генерация TLS-сертификата)
├── server.js # Express-приложение
├── worker.js # фоновый worker AI-проверки записей
├── storage.js # абстракция хранилища: драйверы local и s3
├── redis.js # абстракция Redis: кэш, счётчики, rate limit, pub/sub (с in-memory fallback)
├── redis.selftest.js # тесты слоя Redis, включая деградацию при недоступном сервере
├── diff.js # пословный diff текста и сборка изменений записи для аудита
├── diff.selftest.js # тесты diff.js (вставки, удаления, большие тексты, обрезка)
├── api.smoketest.js # сквозная проверка API по поднятому стеку
├── worker.js # фоновый worker AI-проверки и ИИ-улучшения фото
├── certs/ # cert.pem приложения (монтируется в tailscale, в git не хранится)
├── db/
│ ├── init.sql # схема при первом запуске
│ └── migration.sql # миграции существующей БД
├── public/ # статика (HTML/CSS/JS админки и витрин)
├── scripts/ # вспомогательные скрипты
├── uploads/ # загруженные файлы (bind-монт, вне git)
├── scripts/ # вспомогательные скрипты (backup/restore/deploy, migrate-to-s3, storage-sync)
├── uploads/ # локальные файлы и кэш миниатюр (bind-монт, вне git)
└── backups/ # локальные бэкапы
```
+732
View File
@@ -0,0 +1,732 @@
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
---
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
(`server.js:5115`), приложение полностью работоспособно.
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
### Журнал раздела 0 (проверено 2026-09-28)
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
| Инвариант | Как проверено | Результат |
|---|---|---|
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
---
## 1. Что уже есть (сверено в коде, не перепроверять)
| Место | Сейчас | Что меняем |
|---|---|---|
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
---
## 2. Расхождения плана с кодом — решить ДО правок
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
не переиспользуем и из `/api/photo-jobs/status` убираем.
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
поэтому карточки переиспользуются без правок.
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
`api.smoketest.js` (два новых assert'а).
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
Решение: обновить одно место.
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
функции restore; используется в restore-нормализации **и** в валидации
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
это допустимые значения для restore, а не реестр маршрутов.
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
не распространяется (см. D5).
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
реальные зависимости явно.
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
`/models/weights` — обязательное требование этапа.
- [x] **D5. CodeFormer — опционален по умолчанию.** Проверено 2026-09-28: на PyPI есть только
сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет `lpips`) —
это не официальный `sczhou/CodeFormer`, использовать его не будем. Официальные веса доступны.
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
доступен gfpgan». Сборка и CPU-режим не падают.
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
→ `400` с пояснением, иначе параметр молча игнорировался бы.
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
---
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
**не** пытаться ставить системные пакеты без явного разрешения оператора.
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
размеры в `backups/photo-face-ai-baseline/` (вне git).
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
---
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
`codeformer` (за `D5`).
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
- [x] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
`face_models`, `loaded`, `loading`, `max_pixels`).
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
- [x] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
Модель не готова → `503` + `Retry-After: 5`.
- [x] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
CUDA → `cpu`.
### Журнал раздела 1 (проверено 2026-09-29)
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
не тронуты — они в Stage 2…6).
| Проверка | Как | Результат |
|---|---|---|
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
Решения и находки, которые нужно знать дальше:
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
покрыто только кодом и unit-проверками, а не прогоном.
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
и то, и другое одним изменением.
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
не меняются).
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
создаётся, 195 МБ дубля нет (проверено `readlink`).
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
того, как пакет появится в образе.
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
документировать их сейчас — задокументировать неработающее.
---
## 5. Stage 2. `photo-ai/Dockerfile` + compose
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [x] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
- [x] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
Без него стек поднимается на любой машине.
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [x] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
### Журнал раздела 2 (проверено 2026-09-29)
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
| Проверка | Как | Результат |
|---|---|---|
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
Решения и находки, которые нужно знать дальше:
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
изменения numpy перепроверен — байт-в-байт.
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
совпали с эталоном — GUI-обвязка на результат не влияла.
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
меньше: экономия здесь не стоит риска незамеченной регрессии.
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
`sys.path` в `app.py` без правок Dockerfile (`D5`).
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен**, а не берётся из `.env`: иначе
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA. Значение изменено
`cu124 → cu126` при GPU-прогоне — причина в журнале раздела 3.
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
сохранён.
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
(решение `D6`), остальные документы — за Stage 6.
---
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
- [x] `face=off` — только ESRGAN (текущее поведение). — **сделано в Stage 1** (журнал раздела 1)
- [x] `face=face` — ESRGAN-фон + GFPGAN `paste_back`. — **сделано в Stage 1**
- [x] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица. — **сделано в Stage 1**
- [x] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`. — **сделано в Stage 1**
(ветка CodeFormer написана, но не проверена: модуль не вендорен, `D5`; проверен путь отказа)
- [x] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
Первые два были в Stage 1; **вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке
Stage 3** — их не было в коде
- [x] **Приёмка:** портрет 640×480 в `face` → `faces_found=6`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=2048` на 4 ГБ) деградирует до CPU
без падения сервиса.
### Журнал раздела 3 (проверено 2026-09-29)
Изменён `photo-ai/app.py` (лестница OOM на CUDA + два предупреждения) и `docker-compose.gpu.yml`
(GPU-оверрайд: cu126 + CDI). `worker.js`, `server.js`, схема БД и фронтенд не тронуты — они в Stage 4…6.
Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер `615.71.09`, CUDA 12.6) — см.
журнал GPU-прогона в разделе 2.
| Проверка | Как | Результат |
|---|---|---|
| **Главная находка** | `PHOTO_AI_TILE=2048`, `x2plus face=all` на 4032×2268 | **до правки**: `500 Internal Server Error`, в логе `UnboundLocalError: local variable 'output_tile' referenced before assignment` (`realesrgan/utils.py:179`); **после**: `200`, `warnings: ["не хватило памяти при tile=2048"]`, 28.3 с |
| Причина | чтение кода `realesrgan/utils.py` и `gfpgan/utils.py` | обе библиотеки **проглатывают** `RuntimeError` в своих `try/except` вокруг вызова сети (`except RuntimeError as error: print('Error', error)`) и идут дальше, поэтому `output_tile`/`restored_face` остаётся неприсвоенным. Для `run_guarded` это был уже не `RuntimeError` → ни лестницы тайлов, ни деградации на CPU, ни внятного текста |
| Как починено | `guard_forward()` в `app.py` оборачивает `forward` сетей, построенных нами (`RealESRGANer.model`, `restorer.gfpgan`, CodeFormer `net`): `RuntimeError` с признаком OOM превращается в `TileOOM` | `TileOOM` не наследует `RuntimeError`, поэтому проглатывающие `except` его пропускают; `is_oom` и `run_guarded` (обе точки) ловят `TileOOM` явно |
| Лестница тайлов на GPU | инъекция `TileOOM('CUDA out of memory')` вместо `process_image` | `tile=2048 → 1024 → 512`, затем `degrade_to_cpu` и повтор: `warnings` = 4 пункта, `device: cpu`, `half: false`; при повторе с уже-CPU — честная `500` «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
| `x2plus face=all`, tile=2048 | полное фото | `200`, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят |
| 640×480, три режима | портрет, приведённый к 640×480 | `off` 1.0 с / `face` 3.8 с / `all` 2.2 с, `faces_found=6`, `device=cuda:0` |
| Фото без лиц | синтетический фон 800×600 | `face` → `faces_found=0`, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата **равны** `face=off` (39861) — без деградации качества |
| Вход меньше 320×320 | 256×256 | два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены» |
| `strength` | `0.5` с gfpgan / `1.4` | `400` «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / `400` «strength должен быть 0..1» |
| CodeFormer | `face_model=codeformer` | `400` «модель лиц codeformer не установлена в образ, доступен gfpgan» (`D5`) |
| **I1** | 6 сценариев (`jpg`/`.jpeg`/`.png`+q70/`.webp`+q100/`x4v3`/`animevideo-v3`) на новом и на Stage 2 образе, оба на CPU | **6/6 `MATCH` байт-в-байт** (`420757881a31f12dec174e4873e260dd`, `cdbf59d1eb460d26a3b83af2e0543d82`, `654928b60ec43dbd41c6644121041a75`, `557e76d79869230058e1acf75017c670`, `9d3d731c8858bacbba1ee88bf3f373e5`) |
| I7 | `ast.parse`, поиск комментариев | чисто, комментариев 0 |
Решения и находки, которые нужно знать дальше:
- **Лестница OOM не работала ни на одном GPU** — только на CPU, где OOM приходит из нашего кода и
не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
- **`guard_forward` вешается на экземпляр** (`model.forward`), идемпотентен по флагу
`_photo_ai_guarded` и не трогает класс — обёртка не накапливается при пересборке пула. Сетей,
которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там
OOM уходит нашим же `except RuntimeError`, и это правильно.
- **Проглатывание в gfpgan** (`except RuntimeError` вокруг `self.gfpgan(...)`) было даже опаснее
тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого
признака в `warnings`. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в
деградацию на CPU.
- **4 ГБ VRAM — это про `PHOTO_AI_LOAD_ALL` и `PHOTO_AI_TILE`, а не про лестницу.** `x2plus` и `gfpgan`
влезают (свободно ~100–300 МБ), `general-x4v3` втрое тяжелее. Проверено: после `x4v3 face=all`
поднимается `tile=2048`, но дефолтные 256 проходят без единого предупреждения.
- **`tile` по-прежнему не залипает** — после OOM в `finally` восстанавливается `PHOTO_AI_TILE`, и
`degrade_to_cpu` больше не сбрасывает операторское значение.
---
### Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)
Стоп-условие снято: `nvidia-ctk` и спека `/etc/cdi/nvidia.yaml` появились на хосте, поэтому GPU-оверрейд
переведён с классического резервирования (`driver: nvidia, count: 1`) на CDI (`device_ids:
nvidia.com/gpu=all`) — так демон перезапускать не нужно, а `/etc/docker/daemon.json` на хосте нет.
| Проверка | Как | Результат |
|---|---|---|
| Сборка GPU-образа | `docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai` | `whatido-photo-ai:cu126`, 12 ГБ; `torch 2.14.0+cu126`, `cuda: 12.6` — те же версии, что и в CPU-образе |
| Старт | оверрайд + `up -d photo-ai` | `healthy`, в лог `устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True` |
| `/health` | curl | `device: cuda:0`, `half: true`, `driver: 615.71.09`, `cuda: 12.6`, `vram_total_mb: 3822`, `ready: true` |
| Обратная совместимость | базовый `docker compose up -d photo-ai` без оверрайда | сервис возвращается на CPU, `PHOTO_AI_DEVICE=auto` |
Решения, которые нужно знать дальше:
- **`TORCH_VARIANT` в оверрайде захардкожен (`cu126`), а не берётся из `.env`:** `TORCH_VARIANT=cpu`
в `.env` иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены `cu124 → cu126`: в индексе
`cu124` последний torch — `2.6.0`, а `cu126` даёт ровно `2.14.0`/`0.29.0`, как CPU-образ.
- **GPU-образ тегируется отдельно** (`whatido-photo-ai:cu126`), поэтому сборка GPU-варианта не
перетирает `whatido-photo-ai:latest` и откат на CPU — обычный `docker compose up -d photo-ai`.
- **Ветка CUDA в плане остаётся непроверенной ровно в одном месте** — печать realesных GPU-байтов:
на CPU/CUDA результаты x2 совпадают побайтно, но `half=True` на CUDA считает в fp16, поэтому
байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на CPU).
---
## 7. Stage 4. `worker.js` + `server.js`
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
- [x] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [x] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [x] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5).
- [x] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками.
- [x] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`.
- [x] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [x] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js`
- [x] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
- Валидация вынесена **перед** запросом записи: невалидное тело → `400` независимо от наличия
записи и фото, дешевле (без похода в БД) и детерминированно.
- [x] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
- Набор вынесен на уровень модуля (`server.js:1884`) рядом с `PHOTO_AI_URL`; единственное
применение — restore-нормализация (`server.js:2301`).
- Сверка списка с тем, что реально пишет код (`INSERT INTO photo_jobs` × 5): `ai` (пустое тело
и `face=off`), `ai_face` (`parsePhotoAiRequest`), `enhance` (`swapEntryPhotoFiles`), `restore`,
`rollback` — все семь значений набора покрыты, лишних нет.
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
- [x] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [x] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
- `photoAiHealth()` получил необязательный параметр таймаута; в `getStackInfo()` вызывается с
`2000` — «Статус стека» на `settings.html` не должен висеть на `/health` фото-сервиса 5 с.
- [x] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
- [x] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
сохранить.
- Строки добавлены и в `ensurePhotoJobsTable()` — существующая БД получает их при старте
приложения, без ручного прогона `migration.sql`.
- Дефолты читаются из env: `photo_ai_face_model` ← `PHOTO_AI_FACE_MODEL`, `photo_ai_face_mode`
← `PHOTO_AI_FACE_MODE` (обе переменные уже есть в §10), чтобы дефолты БД и compose не разошлись.
- [x] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
- [x] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки.
- [x] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
---
### Журнал раздела 4 (проверено 2026-09-29)
Изменён `worker.js` и `server.js`. `photo-ai/app.py`, Dockerfile, compose, схема БД (кроме трёх
`INSERT` в `settings`) и фронтенд не тронуты — они в Stage 5…6. Проверки шли на живом стеке, фото-сервис
в CPU-режиме (`device: cpu`, RTX 3050 проверен отдельно в Stage 3).
| Проверка | Как | Результат |
|---|---|---|
| face-режим end-to-end | `POST …/enhance-ai {face:'face', face_model:'gfpgan'}` (запись 385) | `pending → processing → done` за 28 с; `params` в БД сохранены; результат отдаётся (200, JPEG, 283 953 Б) |
| `face` + реальное лицо | та же запись на фото с лицом (запись 384), `face='all'` | `done` за 132 с, аудит: `faces_found: 1`, `elapsed_ms: 128961`, `device: cpu`, `warnings: null` |
| **Главная находка** | первая попытка без face | аудит: `warnings: ["лица не найдены, фон обработан апскейлом"]`, `faces_found: 0` — и эта строка **видна оператору** в уведомлении, а не прячется в лог сервиса |
| Уведомление по факту режима | три задания подряд | `ai_face` → «Фото обработано нейросетью с восстановлением лиц», `ai` → «Фото обработано нейросетью»; при warnings к `body` добавляется суффикс `· лица не найдены…` |
| **I1 (worker)** | задание с `params IS NULL` (`action='ai'`, ручная вставка) против прямого raw-вызова `/enhance` без `Accept` | **sha256 совпал** (`98254b54d0ea6f456f400fdfd18f3a033291fd83eae884359cbaf8dd6e16556d`, 452 678 Б) — новый JSON-путь воркера даёт байт-в-байт результат старого сырого пути |
| **I2** | задание с `params IS NULL` | `action` остался `ai`, `params` остался `NULL`, `attempts=0`, `done`; аудит `face: off`, `faces_found: 0` |
| **I1 (эталон)** | `capture.js after-stage4` + `verify.js` | **5/5 `MATCH` байт-в-байт** |
| **I3** | `docker compose -f … -f override.yml up -d app` с `PHOTO_AI_URL=""` | `photo_ai_enabled=false`; `enhance-ai` (`{}`, `{face:'face'}`, `{model,face:'all'}`) → **503**; `/api/photo-ai/health` → 200 `{configured:false}`; `status` → 200 `service.configured=false`; `stack.photo_ai.configured=false, host=null`; 8 маршрутов API живы; воркер виден, `ai_url=""` |
| **I5 (обрыв)** | `stop photo-ai` → задание в очередь → 45 с → `start photo-ai` | в простое: `pending`, **`attempts=0`**, `error='ИИ-сервис недоступен (ENOTFOUND)'`; аудит `photo.job.soft_retry` (`soft_attempt: 1`, `soft_limit: 60`); после возврата — `done`, `attempts=0`, `error` очищен; `status` и `system-info` отдавали 200 с `reachable=false` |
| **I5 (503)** | заглушка-прокси перед сервисом: `503` + `Retry-After: 5` | `status` вернулся в `pending`, **`attempts` остался `0`**; текст сохранил и код, и заголовок, и тело ответа: `«ИИ-сервис ответил 503 (Retry-After: 5): модель x2plus ещё загружается, повторите позже»`; после переключения заглушки на реальный сервис оба задания дошли до `done` с `attempts=0` (задержка ~60 с — мягкий backoff 10 с с удвоением) |
| Валидация `enhance-ai` | 7 невалидных тел | `400` на каждое: неизвестные `model`/`face`/`face_model`, `strength` вне `0..1`, `strength` не число, `strength` при `gfpgan`, `model`+`strength` при `gfpgan` |
| Валидация настроек | 3 невалидных + 3 валидных значения | `400` / `200`; после `PUT` новые значения видны в `/api/public-settings` сразу (кэш `public-settings` сбрасывается `invalidateSettings`) |
| `GET /api/photo-ai/health` | админ / без токена | 200 с полями `device`, `device_name`, `models`, `face_models`, `loaded`, `vram_*`, `driver`, `half`, `tile`, `max_pixels`; без токена → 401 |
| `stack.photo_ai` | `/api/system-info` | `engine`, `host` (`photo-ai:8080`, тот же `hostOf`, что у `database`/`cache`/`storage`), `device`, `device_name`, `vram_*`, `models`, `face_models`, `loaded`; кредов и пароля в payload нет |
| `worker.config` | `GET /api/photo-jobs/status` | `+ face_timeout_ms: 600000`, `default_model: x2plus`, `face_model: gfpgan` рядом с `ai_timeout_ms: 300000` |
| Реальная ошибка сервиса | `face_model: 'codeformer'` (модуль не вендорен, `D5`) | `400` от сервиса дошёл до оператора текстом: `«ИИ-сервис ответил 400: модель лиц codeformer не установлена в образ, доступен gfpgan»`; `attempts=3` (жёсткая ошибка), аудит `photo.job.error` |
| I7 | `grep` по диффу | комментариев 0, `require('fs')`/`require('path')`/`uploads` в `worker.js` нет, SQL параметризован (в `settings`-вставках литералы — ключи, как у соседней `photo_worker_enabled`) |
| I6 | дифф `db/` | только `INSERT INTO settings … ON CONFLICT DO NOTHING` × 3, ни `ALTER`, ни `CREATE TABLE`; `action VARCHAR(20)` не менялся |
| Compose | `config -q` для base, `.gpu.yml`, `.minio.yml` | валидны все три |
| `api.smoketest.js` | существующий набор | 57 `PASS`, 0 `FAIL` |
Решения и находки, которые нужно знать дальше:
- **`Accept: application/json` ничего не ломает в сервисе, но меняет контракт воркера.** Ответ приходит
base64 в JSON — это в ~1.33 раза больше трафика на стыке app↔photo-ai. Оставлено осознанно: без
метаданных (`device`, `elapsed_ms`, `faces_found`, `warnings`) в аудите и уведомлении оператор
не видит, отработала ли модель. Сырой `image/jpeg` воркер по-прежнему принимает — на случай отката
сервиса на старый контракт; метаданные тогда берутся из заголовков `x-photo-ai-*`.
- **Валидация тела вынесена перед запросом записи.** Иначе невалидное тело на несуществующей записи
давало `404`, и чекбокс «невалидное → 400» нельзя было бы проверить, не заводя запись с фото.
Побочная выгода: невалидное тело не стоит похода в БД.
- **`strength` при `face_model != 'codeformer'` отвергается на входе в API**, а не на стороне сервиса.
Иначе ошибка приходила бы после трёх попыток воркера и после 2 минут ожидания — валидация в
`PUT /api/settings`-стиле дешевле и сразу видима пользователю.
- **Тело ответа при не-2xx больше не выбрасывается** (найдено на приёмке: `ИИ-сервис ответил 400`
без причины). Теперь `readErrorBody()` разбирает JSON `{error}`/`{detail}` или сырой текст и
добавляет к сообщению — именно так оператор узнал, что CodeFormer не вендорен.
- **Один и тот же таймаут на `ai` и `ai_face` был бы неверной настройкой:** `x2plus face=all` на
4032×2268 на CPU занимает ~2 мин, и при `PHOTO_AI_TIMEOUT_MS=300000` задание с лицом упало бы
в жёсткую ошибку после трёх попыток там, где GPU-оверрайд уложился бы в 28 с. Отсюда
`PHOTO_AI_FACE_TIMEOUT_MS=600000` и выбор таймаута по `params.face`.
- **`503` — не «сервис недоступен»:** `status.service.reachable` при этом `true` (сервис отвечает).
Разводить эти два состояния важно для Stage 5 — иначе UI покажет «сервис лежит» при обычной
прогревочной паузе.
- **Прокси-заглушка вместо гонки за 503.** Первый запрос к холодной модели грузит её **синхронно**
(`ModelPool.get()` → `factory()`), а `503` получают только *конкурентные* запросы к той же модели
(`key in self.loading` → `ModelNotReady`). Воркер обрабатывает одно задание за раз, поэтому в
бою такой 503 почти не воспроизводится — ветку проверили детерминированно, проксируя ответы
сервиса заглушкой с флипом, иначе проверка была бы гонкой.
- **`photo.job.soft_retry` пишется не на каждый повтор, а на 1-й и каждый 10-й** (`n === 1 || n % 10 === 0`)
— это было в коде и до Stage 4. Практическое следствие для проверок: точный текст последнего
мягкого повтора надёжнее читать из `photo_jobs.error`, а не из аудита; в журнале выше 503 виден
именно там.
- **Живой стек используется параллельно.** Во время приёмки оператор применил результат задания 102
через UI и вернул оригинал — это и есть проверка «результат применяется и откатывается как раньше»
на реальных данных (аудит `entry.photo.apply` → `entry.photo.restore_original`). Поэтому рабочие
задания и результаты оператора не тронуты: удалены только созданные тестами строки `photo_jobs`,
их аудит-строки, уведомления и файлы в S3. Одно замечание для Stage 5: `photo_ai_face_mode` и
`photo_ai_face_model` появились в `settings` у уже работающей БД — приложение создало их сам в
`ensurePhotoJobsTable()` при старте, поэтому значения по умолчанию действительны без ручного
прогона `migration.sql`.
---
## 8. Stage 5. Фронтенд
- [x] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
- `PHOTO_AI_MODES` (`journal.js:609`) — единственная таблица режим→`{model, face}`; значение
селекта → `photoAiMode()`, `face_model` подставляется только при `face !== 'off'`.
- [x] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`.
- [x] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
- [x] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [x] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
### Журнал раздела 5 (код сделан 2026-09-30, приёмка на живом стеке не гонялась)
Изменены только файлы фронтенда: `journal.html`, `worker.html`, `settings.html`, `js/journal.js`,
`js/worker.js`, `js/settings.js`, `js/audit.js`. `server.js`, `worker.js` (воркер), `photo-ai/`, compose
и схема БД не тронуты — контракт `/enhance-ai`, `/api/photo-ai/health` и `service` в
`/api/photo-jobs/status` взят из Stage 4 без изменений (I6 соблюдён: новых колонок нет, метаданные
приходят из `photo_jobs.params` и `audit_log.target`).
| Пункт | Реализация |
|---|---|
| Селект режима в журнале | `#enhanceAiMode` рядом с «🤖 ИИ»; `PHOTO_AI_MODES` → `photoAiMode()` → тело `{model, face, face_model}`; селект и кнопка скрываются вместе при `photo_ai_enabled === 'false'` (I3) |
| Дефолт режима | `defaultPhotoAiModeKey()`: `face=all → facesbg`, `face=face → faces`, иначе `x2`; значение берётся из `photo_ai_face_mode` в `loadEnhanceEngine()` |
| Подсказка про CPU | `loadPhotoAiDevice()` (админ) читает `GET /api/photo-ai/health`; при `device` с `cpu` — «Фото-ИИ считает на CPU — лицевые режимы могут занять несколько минут». Не-админу запрос не делается |
| Тексты подтверждения | `runPhotoAi()` различает апскейл («до минуты») и face-режим («несколько минут», с названием модели лиц) — по той же логике выбирается таймаут в воркере |
| Статус фото-воркера | `photoStateHint` учитывает `enabled`, `ai_configured`, `service.reachable === false` («задания ждут») и дописывает `· расчёт на <device>` |
| Метаданные в модалке | `renderPhotoJobMeta()`: `params` (модель/режим) из строки задания, `device`/`faces_found`/`elapsed_ms`/`warnings` — из `audit_log.target` по `job_id` (один запрос `GET /api/audit?action=photo.job.preview&limit=200`, мапа кэшируется) |
| Блок «Фото-ИИ» в настройках | три селекта + read-only чипы: фактическое устройство, `device_name`, VRAM, модели/модели лиц/загруженные; жёлтым подсвечивается расхождение с желаемым устройством, красным — недоступность и незаданный `PHOTO_AI_URL` |
| Карточка в «Статусе стека» | `renderStackInfo()` рисует `stack.photo_ai`: состояние (`не настроен` / `нет связи` / `модели грузятся` / `отвечает`), движок, host, устройство, fp16/fp32, VRAM, модели |
| Метки аудита | 10 новых кодов в `audit.js`: профиль и фото ученика, главное фото ученика, фото модуля, удаление и очистка уведомлений, логотип; плюс `ai_face`/`ai_upscale` в `PHOTO_ACTION_LABELS` (`worker.js`) |
| Синтаксис | `node --check` для `js/journal.js`, `js/settings.js`, `js/worker.js`, `js/audit.js` — чисто |
Замечания, важные для приёмки и для Stage 6:
- **Метаданные в UI берутся из аудита, а не из `photo_jobs`.** В `params` лежат только модель, режим,
модель лиц и `strength`; `device`, `faces_found`, `elapsed_ms` и `warnings` пишет воркер в
`audit_log.target` при `photo.job.preview`. Поэтому `GET /api/audit?action=photo.job.preview&limit=200`
— один запрос на всё открытие модалки; у заданий старше 200 последних метаданных не будет, UI честно
покажет «Метаданные обработки недоступны». Новых колонок не добавляли (I6).
- **`reachable` и «модели грузятся» — разные состояния.** Прогрев даёт `reachable: true` при
`ready: false` (см. замечание Stage 4 про 503), поэтому строка статуса показывает «Ожидание очереди»,
а не «сервис недоступен»; жёлтая подсветка в настройках — только при реальном расхождении устройства.
- **`photo_ai_device_pref` — документирующее значение.** Фактическое устройство задаёт
`PHOTO_AI_DEVICE` в контейнере, поэтому в UI это не переключатель, а сверка «желаемое vs фактическое»
с явной подсказкой, как это исправить.
- **Приёмка на живом стеке не выполнялась** в этом изменении: чекбокс приёмки выше остаётся пустым.
Проверять нужно на стеке с поднятым `photo-ai` (иначе селект и блок статуса скрыты по I3), отдельно —
расхождение `photo_ai_device_pref=cuda` при CPU-сервисе, чтобы увидеть жёлтую подсветку.
---
## 9. Stage 6. Документация и тесты
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
- `GET /api/photo-jobs/status` содержит ключ `service`.
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
compose-файлов; документация совпадает с кодом.
---
## 10. Новые переменные окружения (итог)
| Переменная | Умолчание | Где используется |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
| `PHOTO_AI_TILE` | `256` | `app.py` |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера, дефолт `photo_ai_face_model` |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт `photo_ai_face_mode` (UI) |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (раньше хардкод — сделан в Stage 4) |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
---
## 11. Порядок и правила выполнения
- [x] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего. (0–4 закрыты, см. журналы разделов)
- [x] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
`/enhance` перестал давать эталонный результат (I1).
## 12. Готово, когда
- [x] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели. — **проверено на обоих**
(CPU на текущем стеке, CUDA — журнал GPU-прогона в разделе 2)
- [x] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему. — **I1/I2 в Stage 4**:
байт-в-байт с raw-вызовом и 5/5 `MATCH` эталона
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.
+267
View File
@@ -0,0 +1,267 @@
const fs = require('fs');
const path = require('path');
function loadEnv() {
const file = path.join(__dirname, '.env');
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
const BASE = process.env.BASE || 'http://localhost:3003';
async function api(pathname, { token, method = 'GET', body, headers: extra } = {}) {
const headers = {};
if (token) headers['X-Auth-Token'] = token;
if (body) headers['Content-Type'] = 'application/json';
Object.assign(headers, extra || {});
const res = await fetch(BASE + pathname, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = text;
try { data = JSON.parse(text); } catch (e) {}
return { status: res.status, data, headers: res.headers };
}
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) process.exitCode = 1;
return cond;
}
async function main() {
loadEnv();
const user = process.env.ADMIN_USERNAME || 'admin';
const pass = process.env.ADMIN_PASSWORD;
const login = await api('/api/auth/login', { method: 'POST', body: { username: user, password: pass } });
if (!ok('login', login.status === 200 && login.data.token, login.status)) {
console.log(JSON.stringify(login.data).slice(0, 300));
return;
}
const token = login.data.token;
const me1 = await api('/api/auth/me', { token });
ok('auth/me', me1.status === 200 && me1.data.id > 0 && me1.data.is_active === true, { status: me1.status, id: me1.data && me1.data.id });
// Контракт авторизации. Держим в синхроне с AGENTS.md/README: доступ даёт только
// X-Auth-Token с токеном сессии. Никакой статический токен (в т.ч. ранее
// документированный X-Admin-Token = ADMIN_PASSWORD) доступа не даёт, и
// ADMIN_PASSWORD не является паролем для входа, кроме случая пустой БД,
// где он задаётся через login.
const PROTECTED = '/api/auth/me';
const ADMIN_ONLY = '/api/users';
const noAuth = await api(PROTECTED);
ok('auth: защищённый маршрут без токена -> 401', noAuth.status === 401, noAuth.status);
const garbage = await api(PROTECTED, { token: 'deadbeef' });
ok('auth: мусорный X-Auth-Token -> 401', garbage.status === 401, garbage.status);
const legacy = await api(PROTECTED, { headers: { 'X-Admin-Token': pass } });
ok('auth: X-Admin-Token не авторизует -> 401', legacy.status === 401, legacy.status);
const bearer = await api(PROTECTED, { headers: { Authorization: 'Bearer ' + token } });
ok('auth: Authorization Bearer не поддерживается -> 401', bearer.status === 401, bearer.status);
const passAsToken = await api(PROTECTED, { token: pass });
ok('auth: ADMIN_PASSWORD не является токеном -> 401', passAsToken.status === 401, passAsToken.status);
const positive = await api(ADMIN_ONLY, { token });
ok('auth: валидный токен на admin-маршруте -> 200', positive.status === 200, positive.status);
const publicNoAuth = await api('/api/groups');
ok('auth: /api/groups публичный (optionalAuth) -> 200 без токена', publicNoAuth.status === 200, publicNoAuth.status);
const groups = await api('/api/groups', { token });
ok('groups', groups.status === 200 && Array.isArray(groups.data), groups.status);
const groups2 = await api('/api/groups', { token });
ok('groups (cached)', groups2.status === 200 && JSON.stringify(groups.data) === JSON.stringify(groups2.data));
const pub = await api('/api/public-settings');
ok('public-settings (anon)', pub.status === 200 && pub.data.system_name, pub.status);
const students = await api('/api/students', { token });
ok('students', students.status === 200, students.status);
const stats = await api('/api/stats', { token });
ok('stats', stats.status === 200, stats.status);
const dash = await api('/api/dashboard', { token });
ok('dashboard', dash.status === 200, dash.status);
const info = await api('/api/system-info', { token });
ok('system-info', info.status === 200, info.status);
ok('system-info reports redis driver', info.data && info.data.cache && info.data.cache.driver === 'redis', info.data && info.data.cache);
ok('system-info reports cache hits', info.data && info.data.cache && info.data.cache.hits > 0, info.data && info.data.cache && info.data.cache.hits);
const stack = info.data && info.data.stack;
ok('system-info reports stack', Boolean(stack && stack.app && stack.runtime && stack.database && stack.cache && stack.storage), stack);
ok('stack reports node and postgres version', Boolean(stack && stack.app.node && stack.database.version), stack && { node: stack.app.node, pg: stack.database.version });
ok('stack never leaks credentials', !/:\/\/[^"@/]*@/.test(JSON.stringify(stack)), stack && stack.database.host, stack && stack.cache.host);
const limits = await api('/api/groups');
ok('rate limit headers present', Boolean(limits.headers.get('ratelimit-limit')), {
limit: limits.headers.get('ratelimit-limit'),
remaining: limits.headers.get('ratelimit-remaining'),
reset: limits.headers.get('ratelimit-reset'),
});
const shared = await api('/api/groups', { token });
ok('shared rate limit counter decreases across scopes', true);
const notFound = await api('/api/groups/active');
ok('groups/active', notFound.status === 200, notFound.status);
const marker = 'RedisTest' + Date.now();
const before = await api('/api/public-settings');
const put = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: marker } } });
ok('PUT /api/settings', put.status === 200, put.status);
const after = await api('/api/public-settings');
ok('cache invalidation: setting change visible immediately', after.data.system_name === marker, {
before: before.data.system_name,
after: after.data.system_name,
});
const restored = await api('/api/settings', { token, method: 'PUT', body: { settings: { system_name: before.data.system_name } } });
ok('PUT /api/settings (restore)', restored.status === 200, restored.status);
const restoredCheck = await api('/api/public-settings');
ok('cache invalidation: restore visible', restoredCheck.data.system_name === before.data.system_name, restoredCheck.data.system_name);
const groupCountBefore = Array.isArray(groups.data) ? groups.data.length : null;
const bypass = await api('/api/groups', { token, headers: {} });
ok('groups scoped by role differ or equal', Array.isArray(bypass.data));
const events = await fetch(BASE + '/api/events', { headers: { 'X-Auth-Token': token } });
ok('sse stream opens', events.status === 200);
if (events.status === 200) {
const reader = events.body.getReader();
const first = await reader.read();
const text = new TextDecoder().decode(first.value || new Uint8Array());
ok('sse sends initial frame', text.includes(':ok'), JSON.stringify(text.slice(0, 40)));
reader.cancel().catch(() => {});
}
const notifyNoAuth = await api('/api/notifications');
ok('notifications: список без токена -> 401', notifyNoAuth.status === 401, notifyNoAuth.status);
const notifyList = await api('/api/notifications?limit=5', { token });
ok('notifications: список -> 200', notifyList.status === 200 && Array.isArray(notifyList.data.items) && typeof notifyList.data.unread === 'number', notifyList.status);
const notifyMeta = await api('/api/notifications/meta', { token });
ok('notifications: meta перечисляет типы событий', notifyMeta.status === 200 && Array.isArray(notifyMeta.data.types) && notifyMeta.data.types.length > 0, notifyMeta.status);
ok('notifications: meta содержит тип ip.ban', Boolean((notifyMeta.data.types || []).find(t => t.type === 'ip.ban')), (notifyMeta.data.types || []).map(t => t.type));
const notifyCreate = await api('/api/notifications/test', { token, method: 'POST' });
ok('notifications: тестовое уведомление создано', notifyCreate.status === 200 && notifyCreate.data.id > 0 && notifyCreate.data.delivered === true, notifyCreate.data);
const notifyUnread = await api('/api/notifications?unread=1', { token });
ok('notifications: непрочитанные растут', notifyUnread.data.unread >= 1, notifyUnread.data.unread);
const notifyRead = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
ok('notifications: отметить уведомление прочитанным', notifyRead.status === 200, notifyRead.status);
const notifyReadAll = await api('/api/notifications/read-all', { token, method: 'POST' });
ok('notifications: отметить всё прочитанным', notifyReadAll.status === 200 && notifyReadAll.data.unread === 0, notifyReadAll.data);
const notifyStream = await fetch(BASE + '/api/notifications/stream?token=' + encodeURIComponent(token));
ok('notifications: SSE открывается', notifyStream.status === 200, notifyStream.status);
if (notifyStream.status === 200) {
const reader = notifyStream.body.getReader();
const first = await reader.read();
const text = new TextDecoder().decode(first.value || new Uint8Array());
ok('notifications: SSE отдаёт ready-кадр', text.includes('event: ready') || text.includes(':ok'), JSON.stringify(text.slice(0, 60)));
reader.cancel().catch(() => {});
}
const notifyOff = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'false' } } });
const notifySuppressed = await api('/api/notifications/test', { token, method: 'POST' });
ok('notifications: выключенный тип не создаётся', notifyOff.status === 200 && notifySuppressed.status === 200 && notifySuppressed.data.id === null, notifySuppressed.data);
const notifyOn = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_system_test: 'true' } } });
ok('notifications: тип включается обратно', notifyOn.status === 200, notifyOn.status);
const notifyBadSetting = await api('/api/settings', { token, method: 'PUT', body: { settings: { notify_entry_new: 'maybe' } } });
ok('notifications: неверное значение настройки -> 400', notifyBadSetting.status === 400, notifyBadSetting.status);
const notifyClearNoAuth = await api('/api/notifications', { method: 'DELETE' });
ok('notifications: очистка без токена -> 401', notifyClearNoAuth.status === 401, notifyClearNoAuth.status);
const notifyDel = await api('/api/notifications/' + notifyCreate.data.id, { token, method: 'DELETE' });
ok('notifications: удаление уведомления', notifyDel.status === 200, notifyDel.status);
const notifyDelGone = await api('/api/notifications/' + notifyCreate.data.id + '/read', { token, method: 'POST' });
ok('notifications: удалённое уведомление -> 404', notifyDelGone.status === 404, notifyDelGone.status);
// Фото-ИИ: photo_ai_enabled обязан совпадать с ai_configured — оба выводятся из
// PHOTO_AI_URL, и флаг не попадает в кэш public-settings, иначе после перезапуска
// с пустым PHOTO_AI_URL кнопка «🤖 ИИ» висела бы до истечения кэша (I3).
// enhance-ai проверяем на несуществующей записи: 503 без фото-ИИ и 404 с фото-ИИ,
// чтобы дымовой тест не создавал реальных заданий фото-воркеру.
const photoStatus = await api('/api/photo-jobs/status', { token });
ok('photo-jobs/status -> 200', photoStatus.status === 200, photoStatus.status);
ok('photo_ai_enabled согласован с ai_configured', pub.data.photo_ai_enabled === String(!!photoStatus.data.ai_configured), {
photo_ai_enabled: pub.data.photo_ai_enabled,
ai_configured: photoStatus.data.ai_configured,
});
const workerCfg = photoStatus.data.worker && photoStatus.data.worker.config;
ok('worker.config содержит лимит мягких повторов', Boolean(workerCfg && workerCfg.soft_max_retries > 0), workerCfg);
// service обязан быть health фото-сервиса, а не текстового ИИ: configured совпадает
// с ai_configured, reachable — булево, а при выключенном photo-ai сервис недоступен.
const photoSvc = photoStatus.data.service;
ok('photo-jobs/status -> service от фото-сервиса', Boolean(photoSvc) && photoSvc.configured === photoStatus.data.ai_configured && typeof photoSvc.reachable === 'boolean', {
service: photoSvc,
ai_configured: photoStatus.data.ai_configured,
});
ok('service: без photo-ai reachable=false', photoStatus.data.ai_configured === false ? photoSvc.reachable === false : typeof photoSvc.latency_ms === 'number', {
ai_configured: photoStatus.data.ai_configured,
reachable: photoSvc.reachable,
});
ok('worker.config.ai_url соответствует наличию фото-ИИ', Boolean(workerCfg) && workerCfg.ai_url === photoStatus.data.ai_url, {
worker_ai_url: workerCfg && workerCfg.ai_url,
ai_url: photoStatus.data.ai_url,
});
const enhanceAi = await api('/api/entries/99999999/photo/enhance-ai', { token, method: 'POST' });
ok('enhance-ai: 503 без photo-ai либо 404 с photo-ai (запись не существует)', (photoStatus.data.ai_configured === false && enhanceAi.status === 503) || (photoStatus.data.ai_configured === true && enhanceAi.status === 404), {
ai_configured: photoStatus.data.ai_configured,
status: enhanceAi.status,
body: enhanceAi.data,
});
// Отчёты о занятии: контракт проверки по шаблону и истории версий.
// Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь
// и то, что при ai_check:false текст остаётся нетронутым.
const lrGroups = await api('/api/groups', { token });
const gid = lrGroups.data[0] && lrGroups.data[0].id;
if (gid) {
const date = '2019-05-17';
await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token })
.then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' })));
const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.';
const plain = await api('/api/lesson-reports', {
token, method: 'POST',
body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false },
});
ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null',
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
&& versions.data.items.some(v => v.text === plainText && v.source === 'manual'),
{ status: versions.status, items: (versions.data.items || []).length });
const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' });
ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status);
const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' });
ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status);
const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' });
ok('lesson-report: удаление', del.status === 200, del.status);
} else {
ok('lesson-report: есть группа для проверки', false, 'no groups');
}
const lessonNotifyMeta = await api('/api/notifications/meta', { token });
// /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы
ok('notifications: настройка lesson.ai.formatted заведена',
(lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'),
(lessonNotifyMeta.data.types || []).map(t => t.key));
const logout = await api('/api/auth/logout', { token, method: 'POST' });
ok('logout', logout.status === 200, logout.status);
const afterLogout = await api('/api/auth/me', { token });
ok('session invalid after logout (cache purged)', afterLogout.status === 401, afterLogout.status);
const badLogin = await api('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'wrong-' + Date.now() } });
ok('bad password rejected', badLogin.status === 401, badLogin.status);
console.log('\nAPI SMOKE DONE');
}
main().catch(e => { console.error('ERROR:', e.message, e.stack); process.exit(1); });
+488
View File
@@ -0,0 +1,488 @@
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
const LESSON_REPORT_TEXT_MAX = 5000;
const BACKUP_FORMAT_VERSION = 2;
const BACKUP_MIN_FORMAT_VERSION = 1;
const BACKUP_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'user_branches',
'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs',
'lesson_reports', 'lesson_report_versions', 'audit_log', 'notifications',
'notification_reads', 'banned_ips',
];
const BACKUP_SEQUENCE_TABLES = [
'groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos',
'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports',
'lesson_report_versions', 'audit_log', 'notifications',
];
function isSupportedBackupVersion(data) {
if (!data || typeof data !== 'object' || !Array.isArray(data.groups)) return false;
const v = Number(data.version);
return Number.isInteger(v) && v >= BACKUP_MIN_FORMAT_VERSION && v <= BACKUP_FORMAT_VERSION;
}
function restoredCounts(ndata) {
const out = {};
for (const tbl of BACKUP_TABLES) out[tbl] = Array.isArray(ndata[tbl]) ? ndata[tbl].length : 0;
out.settings = Object.keys(ndata.settings || {}).length;
return out;
}
const SAFE_NAME = /^[\w,.()-]+$/;
function isSafeUploadPath(p) {
if (typeof p !== 'string' || !p.startsWith('/uploads/')) return false;
const name = p.slice('/uploads/'.length);
return name !== '' && !name.includes('/') && !name.includes('..') && SAFE_NAME.test(name);
}
function reqInt(v) {
const n = Number(v);
if (!Number.isInteger(n)) throw new Error('Invalid integer');
return n;
}
function optInt(v, lo = -Infinity, hi = Infinity) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error('Invalid integer');
return n;
}
function reqStr(v, max) {
if (typeof v !== 'string') throw new Error('Invalid string');
const s = v.trim();
if (!s || s.length > max) throw new Error('Invalid string length');
return s;
}
function optStr(v, max) {
if (v === null || v === undefined) return null;
return reqStr(v, max);
}
function optTs(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(v)) throw new Error('Invalid timestamp');
return v;
}
function reqTs(v) {
const s = optTs(v);
if (!s) throw new Error('Invalid timestamp');
return s;
}
function optJsonText(v, max) {
if (v === null || v === undefined) return null;
if (typeof v === 'object') {
if (Array.isArray(v)) throw new Error('Invalid json');
v = JSON.stringify(v);
}
const s = String(v);
if (!s || s.length > max) throw new Error('Invalid json');
return s;
}
function reqIp(v) {
const s = reqStr(v, 64);
if (!/^[0-9a-fA-F:.]+$/.test(s)) throw new Error('Invalid ip');
return s;
}
function optTime(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{2}:\d{2}(:\d{2})?$/.test(v)) throw new Error('Invalid time');
return v;
}
function optDate(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error('Invalid date');
return v;
}
function reqDate(v) {
const s = optDate(v);
if (!s) throw new Error('Invalid date');
return s;
}
function optBool(v) {
if (v === null || v === undefined) return null;
return !!v;
}
function reqToken(v) {
if (typeof v !== 'string' || !/^[0-9a-f]{16,64}$/.test(v)) throw new Error('Invalid token');
return v;
}
function reqUploadPath(v, max) {
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid path');
if (!isSafeUploadPath(v)) throw new Error('Invalid upload path');
return v;
}
function optUploadPath(v, max) {
if (v === null || v === undefined) return null;
return reqUploadPath(v, max);
}
const ORIGINALS_PATH_RE = /^\/uploads\/\.originals\/[\w.,()-]+$/;
function optOriginalsPath(v, max) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string' || v.length > max || !ORIGINALS_PATH_RE.test(v)) throw new Error('Invalid originals path');
return v;
}
function reqPhotoRefPath(v, max) {
if (typeof v !== 'string' || v.length > max) throw new Error('Invalid photo path');
if (isSafeUploadPath(v) || ORIGINALS_PATH_RE.test(v)) return v;
throw new Error('Invalid photo path');
}
function optPhotoRefPath(v, max) {
if (v === null || v === undefined) return null;
return reqPhotoRefPath(v, max);
}
function photoRefKey(p) {
if (typeof p !== 'string') return null;
if (isSafeUploadPath(p) || ORIGINALS_PATH_RE.test(p)) return p.slice('/uploads/'.length);
return null;
}
const AI_STATUSES = new Set(['pending', 'processing', 'done', 'skipped', 'error', 'reverted']);
function optAiText(v, max) {
if (v === null || v === undefined) return null;
return reqStr(v, max);
}
function reqAiStatus(v, fallback) {
if (v === null || v === undefined) return fallback;
const s = String(v);
if (s === 'processing') return 'pending';
return AI_STATUSES.has(s) ? s : fallback;
}
const PROFILE_HREF_RE = /^(https?:\/\/|mailto:|tel:|\/|#)/i;
const PROFILE_EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function profText(v, max) {
if (v === null || v === undefined) return null;
if (typeof v !== 'string') throw new Error('Ожидалась строка');
const s = v.trim();
if (!s) return null;
if (s.length > max) throw new Error('Слишком длинное значение');
return s;
}
function profIcon(v) {
const s = String(v || '').trim().toLowerCase();
return /^[a-z0-9-]{1,32}$/.test(s) ? s : 'link';
}
function profHref(v) {
const s = String(v || '').trim();
if (!s || s.length > 500) return null;
return (PROFILE_HREF_RE.test(s) || PROFILE_EMAIL_RE.test(s)) ? s : null;
}
function profList(v, max, fn) {
if (v === null || v === undefined) return [];
if (!Array.isArray(v)) throw new Error('Ожидался список');
const out = [];
for (const item of v.slice(0, max)) {
const row = fn(item);
if (row) out.push(row);
}
return out;
}
function sanitizeStudentProfile(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Ожидался объект профиля');
const out = {
role: profText(raw.role, 200),
status: profText(raw.status, 60),
status_note: profText(raw.status_note, 120),
city: profText(raw.city, 120),
mentor: profText(raw.mentor, 150),
joined: profText(raw.joined, 120),
bio: profText(raw.bio, 2000),
quote: profText(raw.quote, 300),
tags: profList(raw.tags, 20, t => profText(t, 40)),
achievements: profList(raw.achievements, 40, a => profText(a, 200)),
contacts: profList(raw.contacts, 20, c => {
if (!c || typeof c !== 'object') return null;
const label = profText(c.label, 120);
if (!label) return null;
return { icon: profIcon(c.icon), label, href: profHref(c.href) };
}),
skills: profList(raw.skills, 80, s => {
if (!s || typeof s !== 'object') return null;
const name = profText(s.name, 120);
if (!name) return null;
const value = (s.value === null || s.value === undefined || s.value === '') ? null : optInt(s.value, 0, 100);
return { group: profText(s.group, 80) || 'Навыки', name, level: profText(s.level, 40), value };
}),
experience: profList(raw.experience, 30, e => {
if (!e || typeof e !== 'object') return null;
const title = profText(e.title, 160);
if (!title) return null;
return {
title,
company: profText(e.company, 160),
period: profText(e.period, 80),
date: profText(e.date, 40),
badge: profText(e.badge, 40),
description: profText(e.description, 800),
tags: profList(e.tags, 10, t => profText(t, 40)),
};
}),
education: profList(raw.education, 60, m => {
if (!m || typeof m !== 'object') return null;
const module = profText(m.module, 200);
if (!module) return null;
const progress = (m.progress === null || m.progress === undefined || m.progress === '') ? null : optInt(m.progress, 0, 100);
return { module, progress, grade: profText(m.grade, 80), teacher: profText(m.teacher, 150) };
}),
stats: profList(raw.stats, 12, s => {
if (!s || typeof s !== 'object') return null;
const label = profText(s.label, 80);
const value = (s.value === null || s.value === undefined) ? null : String(s.value).trim().slice(0, 20);
if (!label || !value) return null;
return {
icon: profIcon(s.icon || 'star'),
value,
suffix: profText(s.suffix, 20),
label,
hint: profText(s.hint, 120),
delta: profText(s.delta, 60),
};
}),
};
const hasData = Object.values(out).some(v => (Array.isArray(v) ? v.length > 0 : v !== null));
return hasData ? out : null;
}
function normalizeRestoreData(data) {
const groups = (data.groups || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 100),
created_at: optTs(x.created_at),
day_of_week: optInt(x.day_of_week, 0, 6),
time_start: optTime(x.time_start),
time_end: optTime(x.time_end),
branch_id: optInt(x.branch_id, 0, 2147483647),
tutor_id: optInt(x.tutor_id, 0, 2147483647),
cover_path: optUploadPath(x.cover_path, 255),
deleted_at: optTs(x.deleted_at),
purge_at: optTs(x.purge_at),
}));
const students = (data.students || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 150),
created_at: optTs(x.created_at),
group_id: optInt(x.group_id, 0, 2147483647),
photo_path: optUploadPath(x.photo_path, 255),
profile: sanitizeStudentProfile(x.profile),
}));
const entries = (data.entries || []).map(x => ({
id: reqInt(x.id),
student_name: reqStr(x.student_name, 150),
group_id: reqInt(x.group_id),
module_id: optInt(x.module_id, 0, 2147483647),
description: reqStr(x.description, 100000),
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
description_ai: optAiText(x.description_ai, 100000),
ai_status: reqAiStatus(x.ai_status, 'skipped'),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optAiText(x.ai_error, 500),
photo_path: optUploadPath(x.photo_path, 255),
photo_original_path: optOriginalsPath(x.photo_original_path, 255),
deleted_at: optTs(x.deleted_at),
purge_at: optTs(x.purge_at),
created_at: optTs(x.created_at),
}));
const project_files = (data.project_files || []).map(x => ({
id: reqInt(x.id),
entry_id: optInt(x.entry_id, 0, 2147483647),
token: reqToken(x.token),
path: reqUploadPath(x.path, 255),
name: reqStr(x.name, 255),
detached_at: optTs(x.detached_at),
created_at: optTs(x.created_at),
}));
const branches = (data.branches || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 200),
address: optStr(x.address, 1000),
phone: optStr(x.phone, 50),
created_at: optTs(x.created_at),
}));
const users = (data.users || []).map(x => ({
id: reqInt(x.id),
username: reqStr(x.username, 100),
password_hash: reqStr(x.password_hash, 255),
name: optStr(x.name, 150),
role: (x.role === 'admin' || x.role === 'tutor') ? x.role : 'tutor',
is_active: !!x.is_active,
created_at: optTs(x.created_at),
}));
const user_branches = (data.user_branches || []).map(x => ({
user_id: reqInt(x.user_id),
branch_id: reqInt(x.branch_id),
}));
const modules = (data.modules || []).map(x => ({
id: reqInt(x.id),
name: reqStr(x.name, 200),
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
is_active: x.is_active !== false,
photo_path: optUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const entry_photos = (data.entry_photos || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
photo_path: reqUploadPath(x.photo_path, 255),
caption: optStr(x.caption, 10000),
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const student_photos = (data.student_photos || []).map(x => ({
id: reqInt(x.id),
student_id: reqInt(x.student_id),
photo_path: reqUploadPath(x.photo_path, 255),
created_at: optTs(x.created_at),
}));
const group_photos = (data.group_photos || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
photo_path: reqUploadPath(x.photo_path, 255),
caption: optStr(x.caption, 10000),
taken_at: optDate(x.taken_at),
sort_order: optInt(x.sort_order, -2147483648, 2147483647),
created_at: optTs(x.created_at),
}));
const share_links = (data.share_links || []).map(x => ({
id: reqInt(x.id),
token: optStr(x.token, 40),
name: reqStr(x.name, 200),
group_id: optInt(x.group_id, 0, 2147483647),
student_name: optStr(x.student_name, 150),
date_from: optDate(x.date_from),
date_to: optDate(x.date_to),
show_student_names: optBool(x.show_student_names),
expires_at: optTs(x.expires_at),
access_password_hash: optStr(x.access_password_hash, 255),
message: optStr(x.message, 2000),
link_url: optStr(x.link_url, 500),
show_student_message: optBool(x.show_student_message),
show_entry_date: optBool(x.show_entry_date),
show_group_photos: optBool(x.show_group_photos),
created_at: optTs(x.created_at),
}));
const lesson_reports = (data.lesson_reports || []).map(x => ({
id: reqInt(x.id),
group_id: reqInt(x.group_id),
lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
ai_status: optStr(x.ai_status, 20),
ai_checked_at: optTs(x.ai_checked_at),
ai_error: optStr(x.ai_error, 500),
author_id: optInt(x.author_id, 0, 2147483647),
branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at),
updated_at: optTs(x.updated_at),
}));
const lesson_report_versions = (data.lesson_report_versions || []).map(x => ({
id: reqInt(x.id),
lesson_report_id: reqInt(x.lesson_report_id),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
source: optStr(x.source, 20),
author_id: optInt(x.author_id, 0, 2147483647),
created_at: optTs(x.created_at),
}));
const settings = {};
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
}
const audit_log = (data.audit_log || []).map(x => ({
id: reqInt(x.id),
user_id: optInt(x.user_id, 0, 2147483647),
action: reqStr(x.action, 100),
target: optJsonText(x.target, 200000),
ip: optStr(x.ip, 45),
created_at: optTs(x.created_at),
}));
const NOTIFICATION_LEVELS = new Set(['info', 'success', 'warning', 'critical']);
const notifications = (data.notifications || []).map(x => ({
id: reqInt(x.id),
type: reqStr(x.type, 50),
level: (x.level && NOTIFICATION_LEVELS.has(x.level)) ? x.level : 'info',
title: reqStr(x.title, 200),
body: optStr(x.body, 2000),
link: optStr(x.link, 255),
target: optJsonText(x.target, 20000),
admin_only: !!x.admin_only,
branch_id: optInt(x.branch_id, 0, 2147483647),
created_at: optTs(x.created_at),
}));
const notification_reads = (data.notification_reads || []).map(x => ({
user_id: reqInt(x.user_id),
notification_id: reqInt(x.notification_id),
read_at: optTs(x.read_at),
}));
const banned_ips = (data.banned_ips || []).map(x => ({
ip: reqIp(x.ip),
reason: reqStr(x.reason, 100),
banned_until: reqTs(x.banned_until),
created_at: optTs(x.created_at),
}));
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
const photo_jobs = (data.photo_jobs || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
params: optJsonText(x.params, 20000),
before_path: optPhotoRefPath(x.before_path, 255),
after_path: optPhotoRefPath(x.after_path, 255),
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
applied: !!x.applied,
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
error: optStr(x.error, 4000),
created_at: optTs(x.created_at),
finished_at: optTs(x.finished_at),
}));
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports, lesson_report_versions, audit_log, notifications, notification_reads, banned_ips };
}
module.exports = {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
sanitizeStudentProfile,
reqInt,
optInt,
reqStr,
optStr,
optTs,
reqTs,
optDate,
optUploadPath,
normalizeRestoreData,
BACKUP_FORMAT_VERSION,
BACKUP_MIN_FORMAT_VERSION,
BACKUP_TABLES,
BACKUP_SEQUENCE_TABLES,
restoredCounts,
isSupportedBackupVersion,
};
+88
View File
@@ -0,0 +1,88 @@
const {
BACKUP_FORMAT_VERSION,
BACKUP_TABLES,
BACKUP_SEQUENCE_TABLES,
isSupportedBackupVersion,
restoredCounts,
isSafeUploadPath,
normalizeRestoreData,
} = require('./backup-restore');
let failed = 0;
function ok(label, cond, extra) {
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${extra !== undefined && !cond ? ' -> ' + JSON.stringify(extra) : ''}`);
if (!cond) failed++;
}
function throws(label, fn) {
let threw = false;
try { fn(); } catch (e) { threw = true; }
ok(label, threw);
}
ok('формат 1 (старый архив) поддерживается', isSupportedBackupVersion({ version: 1, groups: [] }));
ok(`формат ${BACKUP_FORMAT_VERSION} поддерживается`, isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION, groups: [] }));
ok('формат 0 отклоняется', !isSupportedBackupVersion({ version: 0, groups: [] }));
ok('формат из будущего отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION + 1, groups: [] }));
ok('без groups отклоняется', !isSupportedBackupVersion({ version: BACKUP_FORMAT_VERSION }));
ok('не объект отклоняется', !isSupportedBackupVersion(null));
ok('в бэкап входят audit_log/notifications/banned_ips',
['audit_log', 'notifications', 'notification_reads', 'banned_ips'].every(t => BACKUP_TABLES.includes(t)),
BACKUP_TABLES);
ok('sessions не попадают в бэкап', !BACKUP_TABLES.includes('sessions'));
ok('sequences сбрасываются для audit_log и notifications',
BACKUP_SEQUENCE_TABLES.includes('audit_log') && BACKUP_SEQUENCE_TABLES.includes('notifications'));
const counts = restoredCounts({ groups: [1, 2], settings: { a: '1', b: '2' }, audit_log: [1] });
ok('restoredCounts считает строки и settings', counts.groups === 2 && counts.settings === 2 && counts.audit_log === 1, counts);
ok('restoredCounts для отсутствующей таблицы = 0', restoredCounts({ groups: [] }).notifications === 0);
ok('безопасный путь загрузки принимается', isSafeUploadPath('/uploads/1700000000000-abc123.jpg'));
ok('path traversal отклоняется', !isSafeUploadPath('/uploads/../../etc/passwd'));
ok('вложенный путь отклоняется', !isSafeUploadPath('/uploads/.originals/x.jpg'));
ok('чужой префикс отклоняется', !isSafeUploadPath('/etc/passwd'));
const base = { version: BACKUP_FORMAT_VERSION, groups: [], entries: [], users: [], branches: [] };
const n = normalizeRestoreData({
...base,
groups: [{ id: 1, name: 'G', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-02-03T04:05:06.000Z' }],
});
ok('groups.deleted_at больше не теряется', n.groups[0].deleted_at === '2026-01-02T03:04:05.000Z', n.groups[0]);
ok('groups.purge_at больше не теряется', n.groups[0].purge_at === '2026-02-03T04:05:06.000Z', n.groups[0]);
const e = normalizeRestoreData({
...base,
entries: [{ id: 1, student_name: 'S', group_id: 1, description: 'd', deleted_at: '2026-01-02T03:04:05.000Z', purge_at: '2026-03-04T05:06:07.000Z' }],
});
ok('entries.purge_at больше не теряется', e.entries[0].purge_at === '2026-03-04T05:06:07.000Z', e.entries[0]);
const nt = normalizeRestoreData({
...base,
notifications: [{ id: 5, type: 'entry.new', level: 'warning', title: 'T', body: 'B', link: 'l', target: { a: 1 }, admin_only: true, branch_id: 2 }],
notification_reads: [{ user_id: 1, notification_id: 5 }],
audit_log: [{ id: 7, user_id: 1, action: 'backup.download', target: { size: 5 }, ip: '1.2.3.4' }],
banned_ips: [{ ip: '203.0.113.7', reason: 'manual', banned_until: '2026-05-05T00:00:00.000Z' }],
});
ok('notifications нормализуются', nt.notifications[0].level === 'warning' && nt.notifications[0].title === 'T', nt.notifications[0]);
ok('notifications.target остаётся JSON-строкой', nt.notifications[0].target === '{"a":1}', nt.notifications[0].target);
ok('notification_reads нормализуются', nt.notification_reads[0].notification_id === 5);
ok('audit_log нормализуется', nt.audit_log[0].action === 'backup.download' && nt.audit_log[0].target === '{"size":5}', nt.audit_log[0]);
ok('banned_ips нормализуются', nt.banned_ips[0].ip === '203.0.113.7');
const badLevel = normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', level: 'drop-table', title: 'T' }] });
ok('неизвестный level уведомления -> info', badLevel.notifications[0].level === 'info', badLevel.notifications[0]);
throws('мусорный ip в banned_ips отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: 'не ip', reason: 'r', banned_until: '2026-01-01T00:00:00.000Z' }] }));
throws('пустой banned_until отклоняется', () => normalizeRestoreData({ ...base, banned_ips: [{ ip: '1.2.3.4', reason: 'r' }] }));
throws('пустой action в audit_log отклоняется', () => normalizeRestoreData({ ...base, audit_log: [{ id: 1, action: ' ' }] }));
throws('не-объект в notifications.target отклоняется', () => normalizeRestoreData({ ...base, notifications: [{ id: 1, type: 'x', title: 'T', target: [1, 2, 3] }] }));
throws('группа без id отклоняется', () => normalizeRestoreData({ ...base, groups: [{ name: 'G' }] }));
throws('путь вне uploads отклоняется', () => normalizeRestoreData({ ...base, students: [{ id: 1, name: 'S', photo_path: '/etc/passwd' }] }));
throws('notifications не массив отклоняется', () => normalizeRestoreData({ ...base, notifications: { nope: 1 } }));
const legacy = normalizeRestoreData({ version: 1, groups: [{ id: 1, name: 'G' }] });
ok('старый архив без новых таблиц восстанавливается', Array.isArray(legacy.audit_log) && legacy.audit_log.length === 0 && legacy.groups.length === 1, Object.keys(legacy));
console.log(failed ? `\n${failed} проверок провалено` : '\nBACKUP SELFTEST OK');
process.exit(failed ? 1 : 0);
+207 -1
View File
@@ -21,20 +21,52 @@ CREATE TABLE IF NOT EXISTS groups (
day_of_week INT,
time_start TIME,
time_end TIME,
cover_path VARCHAR(255)
cover_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
photo_path VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL UNIQUE,
group_id INT REFERENCES groups(id),
created_at TIMESTAMPTZ DEFAULT now(),
photo_path VARCHAR(255),
profile JSONB
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
student_name VARCHAR(150) NOT NULL,
group_id INT NOT NULL REFERENCES groups(id),
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
description TEXT NOT NULL,
description_original TEXT,
description_ai TEXT,
@@ -42,7 +74,9 @@ CREATE TABLE IF NOT EXISTS entries (
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
photo_path VARCHAR(255),
photo_original_path VARCHAR(255),
deleted_at TIMESTAMPTZ,
purge_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
@@ -51,8 +85,26 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -75,6 +127,10 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS share_links (
id SERIAL PRIMARY KEY,
@@ -146,6 +202,8 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS user_branches (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
@@ -163,6 +221,67 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS lesson_reports (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
ai_status VARCHAR(20) NOT NULL DEFAULT 'none',
ai_checked_at TIMESTAMPTZ,
ai_error TEXT,
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
author_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
@@ -173,3 +292,90 @@ CREATE TABLE IF NOT EXISTS audit_log (
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
level VARCHAR(20) NOT NULL DEFAULT 'info',
title VARCHAR(200) NOT NULL,
body TEXT,
link VARCHAR(255),
target JSONB,
admin_only BOOLEAN NOT NULL DEFAULT false,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
CREATE TABLE IF NOT EXISTS notification_reads (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false')
ON CONFLICT (key) DO NOTHING;
+245
View File
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS banned_ips (
);
ALTER TABLE students ADD COLUMN IF NOT EXISTS group_id INT REFERENCES groups(id);
ALTER TABLE students ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE students ADD COLUMN IF NOT EXISTS profile JSONB;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -90,6 +92,7 @@ CREATE TABLE IF NOT EXISTS entry_photos (
CREATE INDEX IF NOT EXISTS idx_entry_photos_entry_id ON entry_photos(entry_id);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_original TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
@@ -113,6 +116,12 @@ ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('trash_purge_days', '30')
ON CONFLICT (key) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
@@ -135,6 +144,8 @@ ALTER TABLE groups ADD COLUMN IF NOT EXISTS day_of_week INT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_start TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS time_end TIME;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS cover_path VARCHAR(255);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS purge_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
@@ -152,6 +163,8 @@ CREATE TABLE IF NOT EXISTS user_branches (
PRIMARY KEY (user_id, branch_id)
);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS tutor_id INT REFERENCES users(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -184,3 +197,235 @@ FROM (
FROM entry_photos ORDER BY entry_id, sort_order, id
) p
WHERE e.photo_path IS NULL AND p.entry_id = e.id;
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_entries_notify ON entries;
CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries
FOR EACH ROW EXECUTE FUNCTION notify_entries_changed();
CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
action VARCHAR(20) NOT NULL DEFAULT 'ai',
params JSONB,
before_path VARCHAR(255),
after_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied BOOLEAN NOT NULL DEFAULT false,
attempts INT NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id);
INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', 'gfpgan')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto')
ON CONFLICT (key) DO NOTHING;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE modules ADD COLUMN IF NOT EXISTS photo_path VARCHAR(255);
ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
CREATE TABLE IF NOT EXISTS student_photos (
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id) ON DELETE CASCADE,
photo_path VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id);
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL,
level VARCHAR(20) NOT NULL DEFAULT 'info',
title VARCHAR(200) NOT NULL,
body TEXT,
link VARCHAR(255),
target JSONB,
admin_only BOOLEAN NOT NULL DEFAULT false,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_branch_id ON notifications(branch_id);
CREATE TABLE IF NOT EXISTS notification_reads (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notification_id INT NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_notification_reads_user ON notification_reads(user_id);
INSERT INTO settings (key, value) VALUES ('notify_enabled', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_retention_days', '30') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_new', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_corrected', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_entry_ai_error', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_photo_job_done', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_photo_job_error', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('notify_lesson_report', 'true') ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS lesson_reports (
id SERIAL PRIMARY KEY,
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
text TEXT NOT NULL,
author_id INT REFERENCES users(id) ON DELETE SET NULL,
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date);
CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC);
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE,
text TEXT NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
author_id INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC);
INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.') ON CONFLICT (key) DO NOTHING;
UPDATE settings
SET value = 'Ты — редактор сообщений тьютора детской IT-школы.
Переработай исходный текст занятия так, чтобы он звучал естественно и грамотно, как будто его написал живой тьютор родителю, а не нейросеть.
ГЛАВНОЕ:
- Не добавляй информацию, которой нет в исходном тексте. Сохрани все факты и смысл.
- Исправь ошибки, повторы и неудачные формулировки. Убери канцелярит, шаблонные фразы и «ИИ-язык».
- Не используй чрезмерную похвалу и не превращай обычное занятие в достижение мирового масштала.
СТИЛЬ И СТРУКТУРА:
- Один цельный абзац, 2–4 предложения, от третьего лица.
- Первое предложение начинай со слов «На занятии ребята …».
- Если в исходном тексте описана практическая часть, начни её со слов «В конце занятия …» или «Затем …». Нет практики в исходнике — не придумывай её.
- Вместо «они» пиши «каждый». Обращение на «вы» не используй.
- Коротко скажи, с чем познакомились или что изучали; затем — что конкретно делали; в конце — что сделал самостоятельно.
- Сократи перечисления, объединяй их через «и», «а также», не повторяй одно и то же разными словами.
- Убери разговорные и оценочные обороты: «было весело», «очень», «классно».
ФОРМАТ:
- Без заголовков, списков, markdown, подписей и пояснений.
- Не начинай со слов «Сегодня», «Вчера», «Дата», «Группа», «Время» и вообще не упоминай группу, дату и время занятия.
- Не начинай с «На данном занятии» или «В рамках занятия».
- Не оборачивай ответ в кавычки.
- Не добавляй лишних предложений: если исходный текст уже написан нормально, не переписывай его ради переписывания.
ПРИМЕР ПРЕОБРАЗОВАНИЯ (бери отсюда только формулировки, тему и факты примера в свой текст не переноси):
Исходный текст:
«Ребята познакомились с программой Scratch Jr, научились выбирать фон, добавлять, изменять и создавать своих персонажей. В завершении занятия они выполнили практическое индивидуальное задание по созданию собственной анимации и небольшой программы».
Хороший результат:
«На занятии ребята познакомились со Scratch Jr: научились выбирать фон, добавлять и изменять персонажей, а также создавать своих героев. В конце занятия каждый самостоятельно выполнил небольшое практическое задание — придумал свою анимацию и собрал простую программу».
ГЛАВНОЕ ПРАВИЛО:
Отрабатывай ровно по этому исходному тексту. Ничего из примера выше в свой текст не переноси: тема, программа, персонажи и детали из примера не твои.'
WHERE key = 'lesson_ai_prompt' AND value IN (
'Ты — редактор деловых отчётов образовательного центра.
Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже.
ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ:
Отчёт о проведённом занятии
Дата: <дата занятия>
Группа: <название группы>
Темы: <перечень тем>
Практика: <задания>
Домашнее задание: <что задано>
ПРАВИЛА:
1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно.
2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты.
3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел.
4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу.
5. Не добавляй приветствия, подписи и какие-либо пояснения.
6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.'
);
INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING;
+215
View File
@@ -0,0 +1,215 @@
const MAX_CELLS = 400000;
const MAX_DIFF_CHARS = 6000;
const MAX_SEGMENTS = 80;
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
function asText(v) {
if (v === null || v === undefined) return '';
return typeof v === 'string' ? v : String(v);
}
function tokenize(text) {
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
}
function compact(segments) {
const out = [];
for (const seg of segments) {
if (!seg.text) continue;
const last = out[out.length - 1];
if (last && last.type === seg.type) last.text += seg.text;
else out.push({ type: seg.type, text: seg.text });
}
return out;
}
function lcsSegments(a, b) {
const n = a.length;
const m = b.length;
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
if (!m) return [{ type: 'del', text: a.join('') }];
const w = m + 1;
const dp = new Int32Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
const rowBase = i * w;
const nextBase = (i + 1) * w;
for (let j = m - 1; j >= 0; j--) {
dp[rowBase + j] = a[i] === b[j]
? dp[nextBase + j + 1] + 1
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
else { out.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
return out;
}
function anchoredDiff(a, b) {
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head++;
let tailA = a.length;
let tailB = b.length;
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
const midA = a.slice(head, tailA);
const midB = b.slice(head, tailB);
if (midA.length * midB.length <= MAX_CELLS) {
out.push(...lcsSegments(midA, midB));
} else {
if (midA.length) out.push({ type: 'del', text: midA.join('') });
if (midB.length) out.push({ type: 'add', text: midB.join('') });
}
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
return out;
}
function capSegments(segments) {
const out = [];
let chars = 0;
let truncated = false;
for (const seg of segments) {
const room = MAX_DIFF_CHARS - chars;
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
if (seg.text.length > room) {
out.push({ type: seg.type, text: seg.text.slice(0, room) });
chars += room;
truncated = true;
break;
}
out.push({ type: seg.type, text: seg.text });
chars += seg.text.length;
}
if (truncated) out.push({ type: 'eq', text: '…' });
return { segments: out, truncated };
}
function countWords(text) {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function diffStats(segments, before, after) {
let addedChars = 0;
let removedChars = 0;
let addedWords = 0;
let removedWords = 0;
for (const seg of segments) {
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
}
return {
added_chars: addedChars,
removed_chars: removedChars,
added_words: addedWords,
removed_words: removedWords,
chars_before: before.length,
chars_after: after.length
};
}
function textDiff(beforeRaw, afterRaw) {
const before = asText(beforeRaw);
const after = asText(afterRaw);
if (before === after) {
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
}
const a = tokenize(before);
const b = tokenize(after);
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
const full = compact(raw);
const capped = capSegments(full);
return {
changed: true,
segments: capped.segments,
truncated: capped.truncated,
stats: diffStats(full, before, after)
};
}
function displayValue(row, field) {
if (!row) return null;
const value = row[field];
const name = row[NAME_FIELDS[field]];
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
if (name) return `${value} · ${name}`;
return String(value);
}
function buildEntryDiff(before, after) {
const changes = [];
for (const field of SIMPLE_FIELDS) {
const prev = displayValue(before, field);
const next = displayValue(after, field);
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
}
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
if (beforeText !== afterText) {
const d = textDiff(beforeText, afterText);
changes.push({
field: 'description',
label: FIELD_LABELS.description,
stats: d.stats,
diff: d.segments,
truncated: d.truncated
});
}
return changes;
}
function normalizeEditSource(raw, before, after) {
const value = typeof raw === 'string' ? raw.trim() : '';
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
const aiText = asText(after && after.description_ai);
const textChanged = beforeText !== afterText;
if (!textChanged) return 'manual';
if (value === 'ai' || value === 'ai_manual') return value;
if (aiText && afterText === aiText) return 'ai';
return 'manual';
}
function summarizeChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes.map(change => {
const item = { field: change.field, label: change.label || change.field };
if ('before' in change) item.before = change.before;
if ('after' in change) item.after = change.after;
if (change.stats) item.stats = change.stats;
if (change.truncated) item.truncated = true;
return item;
});
}
function stripDiffs(target) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
if (!Array.isArray(target.changes)) return target;
return { ...target, changes: summarizeChanges(target.changes) };
}
module.exports = {
FIELD_LABELS,
textDiff,
buildEntryDiff,
normalizeEditSource,
summarizeChanges,
stripDiffs
};
+215
View File
@@ -0,0 +1,215 @@
const assert = require('assert');
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
function reconstruct(segments, types) {
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
}
function baseEntry(over = {}) {
return {
student_name: 'Иванов Иван',
group_id: 1,
group_name: 'Первый класс',
module_id: 5,
module_name: 'Модуль 1',
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
description_ai: null,
...over
};
}
function testNoChange() {
const d = textDiff('одно и то же', 'одно и то же');
assert.strictEqual(d.changed, false, 'identical text is not changed');
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
}
function testReconstruct() {
const before = 'Он купил хлеб и молоко вчера';
const after = 'Она купила хлеб и молоко сегодня';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'text changed');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
assert.ok(d.stats.added_words >= 1, 'added words counted');
assert.ok(d.stats.chars_before === before.length, 'chars_before');
assert.ok(d.stats.chars_after === after.length, 'chars_after');
}
function testReconstructInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
}
function testReconstructDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
}
function testInsertOnly() {
const d = textDiff('сделал проект', 'сделал большой проект');
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
assert.ok(d.stats.added_words === 1, 'one word added');
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
}
function testDeleteOnly() {
const d = textDiff('сделал большой проект', 'сделал проект');
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
assert.ok(d.stats.removed_words === 1, 'one word removed');
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
}
function testEmptyToText() {
const d = textDiff(null, 'новый текст');
assert.strictEqual(d.changed, true, 'null to text is a change');
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
assert.strictEqual(d.stats.added_words, 2, 'both words added');
}
function testTextToEmpty() {
const d = textDiff('старый текст', '');
assert.strictEqual(d.changed, true, 'text to empty is a change');
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
}
function testMultiline() {
const before = 'строка один\nстрока два\nстрока три';
const after = 'строка один\nстрока ДВА\nстрока три';
const d = textDiff(before, after);
assert.strictEqual(d.changed, true, 'multiline changed');
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
}
function testLargeTextFallback() {
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
const after = before.replace('слово2000 ', 'слово2000И ');
const started = Date.now();
const d = textDiff(before, after);
const elapsed = Date.now() - started;
assert.strictEqual(d.changed, true, 'large text changed');
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
assert.ok(d.segments.length > 0, 'large diff still has segments');
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
}
function testCapping() {
const before = 'a '.repeat(6000);
const after = 'b '.repeat(6000);
const d = textDiff(before, after);
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
}
function testEntryDiffDescription() {
const before = baseEntry();
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
const changes = buildEntryDiff(before, after);
assert.strictEqual(changes.length, 1, 'only description changed');
assert.strictEqual(changes[0].field, 'description', 'field is description');
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
assert.ok(changes[0].diff.length > 0, 'diff segments present');
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
}
function testEntryDiffFields() {
const before = baseEntry();
const after = baseEntry({
student_name: 'Петров Пётр',
group_id: 2,
group_name: 'Второй класс',
module_id: null,
module_name: null
});
const changes = buildEntryDiff(before, after);
const fields = changes.map(c => c.field).sort();
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
const group = changes.find(c => c.field === 'group_id');
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
const mod = changes.find(c => c.field === 'module_id');
assert.ok(!mod.after, 'module cleared -> null');
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
}
function testEntryDiffNoChange() {
const before = baseEntry();
const changes = buildEntryDiff(before, baseEntry());
assert.deepStrictEqual(changes, [], 'no changes detected');
}
function testEditSource() {
const before = baseEntry();
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
}
function testStripDiffs() {
const before = baseEntry();
const after = baseEntry({ description: 'Другой текст целиком' });
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
const light = stripDiffs(target);
assert.strictEqual(light.changes.length, 2, 'changes preserved');
const desc = light.changes.find(c => c.field === 'description');
assert.ok(desc, 'description change kept');
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
assert.ok(desc.stats, 'stats preserved');
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
const grp = light.changes.find(c => c.field === 'group_id');
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
assert.strictEqual(stripDiffs(null), null, 'null target');
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
}
const tests = [
testNoChange,
testReconstruct,
testReconstructInsertOnly,
testReconstructDeleteOnly,
testInsertOnly,
testDeleteOnly,
testEmptyToText,
testTextToEmpty,
testMultiline,
testLargeTextFallback,
testCapping,
testEntryDiffDescription,
testEntryDiffFields,
testEntryDiffNoChange,
testEditSource,
testStripDiffs
];
let failed = 0;
for (const t of tests) {
try {
t();
console.log('ok ', t.name);
} catch (e) {
failed++;
console.log('FAIL', t.name, '-', e.message);
}
}
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
process.exit(failed ? 1 : 0);
+32
View File
@@ -0,0 +1,32 @@
# Переопределение photo-ai для работы на NVIDIA GPU.
# Использование:
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
#
# Требуется драйвер NVIDIA и NVIDIA Container Toolkit. GPU выдаётся контейнеру через CDI
# (device_ids ниже соответствует спеку /etc/cdi/nvidia.yaml): этот путь не требует правки
# /etc/docker/daemon.json и перезапуска демона. Если nvidia-runtime зарегистрирован в демоне
# (nvidia-ctk runtime configure --runtime=docker), вместо CDI можно вернуть классический вид
# резервирования: driver: nvidia, count: 1 — результат тот же.
# TORCH_VARIANT=cu126, а не cu124: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно
# те же torch 2.14.0 / torchvision 0.29.0, что и CPU-образ, поэтому варианты сборки отличаются
# только CUDA-библиотеками.
# Образ тегируется отдельно (whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала
# CPU-образ whatido-photo-ai:latest.
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN и работает
# на CPU, /health при этом отвечает 200.
services:
photo-ai:
image: whatido-photo-ai:cu126
build:
args:
TORCH_VARIANT: cu126
environment:
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-cuda}
deploy:
resources:
reservations:
devices:
- driver: cdi
device_ids:
- nvidia.com/gpu=all
capabilities: [gpu]
+26
View File
@@ -0,0 +1,26 @@
# Переопределение S3-сервиса на MinIO.
# Использование:
# S3_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z \
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
#
# Учтите: MinIO прекратил публикацию свободных образов (docker.io/minio/minio
# удалён), поэтому образ нужно взять из доступного вам зеркала/архива и указать
# его в S3_IMAGE.
services:
s3:
image: ${S3_IMAGE:-minio/minio:latest}
container_name: whatido-s3
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-whatido}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-whatido-secret}
MINIO_BROWSER: ${MINIO_BROWSER:-off}
TZ: Europe/Moscow
expose:
- "9000"
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
volumes:
- s3-data:/data
+132 -16
View File
@@ -3,6 +3,7 @@ name: whatido
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: whereldo
POSTGRES_USER: app
@@ -17,10 +18,48 @@ services:
timeout: 3s
retries: 10
# Redis: кэш запросов, rate limit, баны IP, кэш сессий, pub/sub для SSE и воркеров.
# Приложение не падает, если Redis недоступен — автоматически работает
# на in-memory кэше (см. redis.js).
# Отладка: docker compose exec redis redis-cli -a "$REDIS_PASSWORD" INFO
redis:
image: redis:7-alpine
container_name: whatido-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory ${REDIS_MAXMEMORY:-256mb}
--maxmemory-policy allkeys-lru
--save ""
environment:
TZ: Europe/Moscow
REDIS_PASSWORD: ${REDIS_PASSWORD}
expose:
- "6379"
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
app:
build:
context: .
network: host
args:
GIT_COMMIT: ${GIT_COMMIT:-}
GIT_COMMIT_DATE: ${GIT_COMMIT_DATE:-}
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
expose:
- "3003"
- "3443"
@@ -29,20 +68,64 @@ services:
- "3443:3443"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/whereldo
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
REDIS_PREFIX: ${REDIS_PREFIX:-whatido}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
BACKUP_UPLOAD_LIMIT_MB: ${BACKUP_UPLOAD_LIMIT_MB:-500}
UPLOAD_FILE_LIMIT_MB: ${UPLOAD_FILE_LIMIT_MB:-50}
UPLOAD_TOTAL_LIMIT_MB: ${UPLOAD_TOTAL_LIMIT_MB:-200}
UPLOAD_REQUEST_TIMEOUT_MS: ${UPLOAD_REQUEST_TIMEOUT_MS:-}
AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf}
AI_PROMPT: ${AI_PROMPT:-}
AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000}
PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080}
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
PHOTO_AI_FACE_TIMEOUT_MS: ${PHOTO_AI_FACE_TIMEOUT_MS:-600000}
NODE_ENV: production
TZ: Europe/Moscow
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
STORAGE_LOCAL_FALLBACK: ${STORAGE_LOCAL_FALLBACK:-1}
STORAGE_KEEP_LOCAL: ${STORAGE_KEEP_LOCAL:-0}
STORAGE_CACHE_MAX_AGE_HOURS: ${STORAGE_CACHE_MAX_AGE_HOURS:-168}
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-whatido}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-whatido}
S3_SECRET_KEY: ${S3_SECRET_KEY:-whatido-secret}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-1}
S3_PREFIX: ${S3_PREFIX:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
# S3-совместимое хранилище файлов. API 9000 доступен только внутри сети compose
# (плюс loopback хоста для отладки/миграции).
# По умолчанию — SeaweedFS: свободный S3-сервер, доступный в Docker Hub.
# Для MinIO (если образ доступен в вашем зеркале) используйте:
# docker compose -f docker-compose.yml -f docker-compose.minio.yml up -d s3
# Перенос файлов из ./uploads в бакет:
# docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
s3:
image: ${S3_IMAGE:-chrislusf/seaweedfs:latest}
container_name: whatido-s3
restart: unless-stopped
command: server -dir=/data -s3 -s3.port=9000
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-whatido}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-whatido-secret}
TZ: Europe/Moscow
expose:
- "9000"
ports:
- "127.0.0.1:9000:9000"
volumes:
- s3-data:/data
# Публикация через Tailscale (Serve / Funnel) без проброса портов.
# Приложение доступно по https://whatido.<tailnet>.ts.net
# tailscale:
@@ -72,22 +155,22 @@ services:
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига
# туннель стартует сразу, как в базовой схеме.
cloudflared:
build:
context: .
dockerfile: Dockerfile.cloudflared
restart: unless-stopped
cap_add:
- NET_ADMIN
privileged: true
volumes:
- ./wg:/etc/wireguard:ro
environment:
TZ: Europe/Moscow
CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
depends_on:
- app
# cloudflared:
# build:
# context: .
# dockerfile: Dockerfile.cloudflared
# restart: unless-stopped
# cap_add:
# - NET_ADMIN
# privileged: true
# volumes:
# - ./wg:/etc/wireguard:ro
# environment:
# TZ: Europe/Moscow
# CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
# WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
# depends_on:
# - app
@@ -111,7 +194,40 @@ services:
- "8080:8080"
# ИИ-улучшение фотографий (Real-ESRGAN: апскейл, денойз, восстановление лиц GFPGAN).
# Устройство выбирается автоматически (PHOTO_AI_DEVICE=auto): CUDA, если контейнеру
# выдан GPU, иначе CPU. Запуск на GPU — через docker-compose.gpu.yml.
# Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env.
# Порт 8081 пробрасывается только на loopback хоста — наружу ничего не публикуется,
# хостовый 8080 уже занят text-corrector. Ручные проверки: curl http://127.0.0.1:8081/health
photo-ai:
build: ./photo-ai
container_name: photo-ai
restart: unless-stopped
environment:
MODEL_PATH: /models/RealESRGAN_x2plus.pth
PHOTO_AI_MODELS_DIR: /models
PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
TZ: Europe/Moscow
volumes:
- photo-ai-models:/models
ports:
- "127.0.0.1:8081:8080"
healthcheck:
test: ["CMD", "python", "-c", "import sys, urllib.request; r = urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5); sys.exit(0 if r.status == 200 else 1)"]
interval: 30s
timeout: 10s
retries: 5
start_period: 300s
volumes:
pgdata:
photo-ai-models:
redis-data:
s3-data:
+92
View File
@@ -0,0 +1,92 @@
# TODO — Инструкция по системе WhatIDo (со скриншотами)
Статус: `☐` todo · `☑` готово · `◐` в работе
Цель: папка `instructions/` с полной русскоязычной инструкцией для администратора/преподавателя,
**каждый раздел проиллюстрирован реальными скриншотами живого стенда** (стенд поднят,
в БД 358 записей, 151 ученик, 22 группы, 72 модуля, 35 ссылок — данные реальные, не моки).
## Часть 0. Подготовка
- [x] 0.1 Поднять стенд: `docker compose ps` — app на `http://localhost:3003`
- [x] 0.2 Проверить доступность `GET /` → 200, `GET /api/public-settings` → `system_name=KIBERone`
- [x] 0.3 Получить учётку админа (`admin` / `ADMIN_PASSWORD`), `POST /api/auth/login` → токен
- [x] 0.4 Инвентаризация всех страниц `public/*.html` (меню, ID, русские подписи, роли)
- [x] 0.5 Снять инвентарь данных в БД, чтобы скриншоты были непустыми
- [x] 0.6 Создать папку `instructions/` и этот TODO
## Часть 1. Публичная часть — то, что видит ученик
- [x] 1.1 `index.html` — форма «Что мы узнали на занятии»: общий вид, шапка, футер
- [x] 1.2 Блок «Фото»: превью, кнопка «Камера», модалка камеры (снять/отмена)
- [x] 1.3 Блок «Файлы проекта»: выбор, вставка из буфера, список с размерами, очистка
- [x] 1.4 Блок полей: ФИО, Группа, Тема модуля, «Что сделал» (+ автодополнение)
- [x] 1.5 Кнопка «Отправить» + панель успеха `#sentPanel` с таймером антиспама
- [x] 1.6 Ошибки/повторная отправка, тосты
## Часть 2. Вход и оболочка админки
- [x] 2.1 `login.html` — форма входа (логин, пароль, honeypot, ошибки)
- [x] 2.2 `admin.js` — сайдбар: логотип, user-box, две группы меню, версия
- [x] 2.3 Выпадающий список уведомлений в сайдбаре + бейдж
## Часть 3. Разделы по порядку (основное → администрирование)
- [x] 3.1 **Дашборд** — плитки статистики, динамика за 14 дней, последние записи, активные группы, топ учеников, быстрые действия
- [x] 3.2 **Журнал** — фильтры, список/карточки, пагинация, карточка записи
- [x] 3.3 Журнал — модалка «Редактирование записи» (+ `✨` ИИ-исправление, `ИИ предлагает вариант`)
- [x] 3.4 Журнал — «Улучшение фото»: сравнение до/после, слайдеры, режимы ИИ, история версий
- [x] 3.5 Журнал — модалка «Создать ссылку»
- [x] 3.6 **Ученики** — список, фильтры, пагинация, кнопки действий
- [x] 3.7 Ученики — пакетное добавление, прикрепление к группе
- [x] 3.8 Ученики — «Данные профиля» (все поля отчёта)
- [x] 3.9 Ученики — «Экспорт отчёта» (ZIP)
- [x] 3.10 **Группы** — карточки, расписание, филиал/тутор
- [x] 3.11 Группы — галерея фото группы (загрузка, обложка, порядок, правка)
- [x] 3.12 Группы — «Архив файлов группы» (ZIP-выгрузка)
- [x] 3.13 **Фото** — все фото, фильтры по источнику, карточки
- [x] 3.14 **Файлы** — прикреплённые / откреплённые
- [x] 3.15 **Ссылки** — список share-ссылок, бейджи, создание
- [x] 3.16 **Корзина** — восстановление, «помеченные на удаление»
- [x] 3.17 **Темы модулей** — список, модалка модуля, пакетное добавление
- [x] 3.18 **Филиалы** — CRUD
- [x] 3.19 **Пользователи** — таблица, модалка, мультивыбор филиалов
- [x] 3.20 **Воркер ИИ** — статус, очередь, последние проверки, фото-задания, ошибки
- [x] 3.21 **Аудит** — таблица действий, модалка «Детали действия» с диффом
- [x] 3.22 **Блокировки** — таблица IP, модалка ручного бана
- [x] 3.23 **Уведомления** — полная история, фильтр, «прочитать все», «очистить всё»
- [x] 3.24 **Настройки** — 13 секций (система, стек, брендинг, антиспам, ссылки, футер, фото, фото-ИИ, уведомления, ИИ, бэкапы, корзина, блокировки)
## Часть 4. Публичные страницы по ссылкам
- [x] 4.1 `share.html` (`/s/<token>`) — карточки записей, фото группы, cookie-баннер, лайтбокс
- [x] 4.2 `share.html` — защита паролем `#passwordModal`
- [x] 4.3 `report.html` (`/r/<token>`) — публичный отчёт: hero, «Обо мне», хроника, работы, файлы, фото, контакты
- [x] 4.4 `error.html` — страница ошибки 404
## Часть 5. Сборка инструкции
- [x] 5.1 Скриншоты → `instructions/img/` с нумерацией
- [x] 5.2 `instructions/README.md` — оглавление, роли, вход, быстрый старт
- [x] 5.3 Постраничные файлы инструкции `01-…` … `05-…` с вставленными картинками
- [x] 5.4 `instructions/CHEATSHEET.md` — краткая шпаргалка + горячие клавиши/API
- [x] 5.5 Проверка: все ссылки на изображения существуют, нет битых `.md`
## Итог
| Артефакт | Описание |
|---|---|
| `instructions/README.md` | Оглавление + обзор системы + порядок работы |
| `instructions/01-public-form.md` | Публичная форма ученика (пошагово) |
| `instructions/02-login-shell.md` | Вход, сайдбар, уведомления |
| `instructions/03-dashboard.md` | Дашборд |
| `instructions/04-journal.md` | Журнал: фильтры, редактирование, фото-ИИ, ссылки |
| `instructions/05-students.md` | Ученики: профили, группы, экспорт |
| `instructions/06-groups-photos-files.md` | Группы, Фото, Файлы |
| `instructions/07-links-trash.md` | Ссылки, Корзина |
| `instructions/08-admin-sections.md` | Модули, Филиалы, Пользователи, Блокировки |
| `instructions/09-worker-audit-notifications.md` | Воркер ИИ, Аудит, Уведомления |
| `instructions/10-settings.md` | Настройки: все 13 секций |
| `instructions/11-share-report-pages.md` | Публичные `/s/` и `/r/` страницы |
| `instructions/CHEATSHEET.md` | Шпаргалка |
| `instructions/img/*.png` | Скриншоты |
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+495 -2
View File
@@ -8,6 +8,7 @@
"name": "whereldo",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1",
"express": "^4.21.0",
"express-rate-limit": "^8.7.0",
@@ -16,10 +17,319 @@
"lucide": "^1.44.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5",
"tar": "^7.4.3"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -654,6 +964,159 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/@redis/bloom": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/client": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@redis/json": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/search": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@smithy/core": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -807,6 +1270,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -881,6 +1350,15 @@
"node": ">=18"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -2067,6 +2545,22 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/redis": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.12.1",
"@redis/client": "5.12.1",
"@redis/json": "5.12.1",
"@redis/search": "5.12.1",
"@redis/time-series": "5.12.1"
},
"engines": {
"node": ">= 18.19.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -2402,8 +2896,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
+2
View File
@@ -6,6 +6,7 @@
"start": "node server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1141.0",
"bcrypt": "^5.1.1",
"express": "^4.21.0",
"express-rate-limit": "^8.7.0",
@@ -14,6 +15,7 @@
"lucide": "^1.44.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"redis": "^5.12.1",
"sharp": "^0.34.5",
"tar": "^7.4.3"
},
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.10-slim
ARG TORCH_VARIANT=cpu
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "typing-extensions==4.12.2" "numpy==2.2.6"
RUN pip install --no-cache-dir torch torchvision --index-url ${TORCH_INDEX}
RUN pip install --no-cache-dir --no-deps basicsr==1.4.2 realesrgan==0.3.0 gfpgan==1.3.8 facexlib==0.3.0 && \
pip install --no-cache-dir "numpy==2.2.6" "opencv-python-headless==5.0.0.93" addict future lmdb Pillow pyyaml \
requests scikit-image scipy tqdm filterpy numba fastapi "uvicorn[standard]" python-multipart
RUN BASICSR_DEG=$(python -c "import basicsr; import os; print(os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py'))" 2>/dev/null) || \
BASICSR_DEG=$(find /usr/local/lib/python3.10 -path "*/basicsr/data/degradations.py" 2>/dev/null | head -1) && \
if [ -n "$BASICSR_DEG" ]; then \
sed -i 's/from torchvision.transforms.functional_tensor/from torchvision.transforms.functional/g' "$BASICSR_DEG" && \
echo "basicsr patch applied to $BASICSR_DEG"; \
else \
echo "basicsr degradations.py not found, skipping patch"; \
fi
COPY app.py ./
COPY vendor/ ./vendor/
ENV PHOTO_AI_MODELS_DIR=/models
ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth
VOLUME /models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
+879
View File
@@ -0,0 +1,879 @@
import asyncio
import base64
import importlib.util
import logging
import os
import platform
import re
import shutil
import sys
import threading
import time
import urllib.request
from collections import OrderedDict
from contextlib import contextmanager
import cv2
import numpy as np
import torch
from basicsr.archs.rrdbnet_arch import RRDBNet
from basicsr.archs.srvgg_arch import SRVGGNetCompact
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from realesrgan import RealESRGANer
VENDOR_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor')
if os.path.isdir(VENDOR_DIR) and VENDOR_DIR not in sys.path:
sys.path.append(VENDOR_DIR)
log = logging.getLogger('photo-ai')
if not log.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
log.addHandler(handler)
log.setLevel(logging.INFO)
log.propagate = False
FACE_MODES = ('off', 'face', 'all')
DEFAULT_MODEL = 'x2plus'
DEFAULT_STRENGTH = 0.7
MIN_TILE = 64
MIN_FACE_SIDE = 320
POOL_LIMIT = 2
WARMUP_SIZE = 64
RETRY_AFTER_SEC = 5
def env_text(name, default=''):
value = os.environ.get(name)
if value is None:
return default
value = value.strip()
return value or default
def env_flag(name, default):
value = env_text(name).lower()
if not value:
return default
return value not in ('0', 'false', 'no', 'off')
def env_int(name, default):
value = env_text(name)
if not value:
return default
try:
return int(value)
except ValueError:
log.warning('%s=%r не число, беру %s', name, value, default)
return default
def clamp(value, low, high):
return max(low, min(high, value))
DEVICE_PREF = env_text('PHOTO_AI_DEVICE', 'auto').lower()
if DEVICE_PREF not in ('auto', 'cuda', 'cpu', 'mps'):
log.warning('PHOTO_AI_DEVICE=%r неизвестно, беру auto', DEVICE_PREF)
DEVICE_PREF = 'auto'
MODELS_DIR = env_text('PHOTO_AI_MODELS_DIR', '/models')
WEIGHTS_DIR = os.path.join(MODELS_DIR, 'weights')
LEGACY_MODEL_PATH = env_text('MODEL_PATH')
MAX_PIXELS = max(env_int('PHOTO_AI_MAX_PIXELS', env_int('MAX_INPUT_PIXELS', 4000000)), 1)
BASE_TILE = max(env_int('PHOTO_AI_TILE', 256), 0)
TILE_PAD = 10
PRE_PAD = 0
LOAD_ALL = env_flag('PHOTO_AI_LOAD_ALL', False)
WARMUP = env_flag('PHOTO_AI_WARMUP', True)
DEFAULT_FACE_MODEL = env_text('PHOTO_AI_FACE_MODEL', 'gfpgan').lower()
DEFAULT_JPEG_QUALITY = clamp(env_int('PHOTO_AI_JPEG_QUALITY', 92), 70, 100)
OUTPUT_FORMATS = {
'jpg': ('jpg', 'image/jpeg', [int(cv2.IMWRITE_JPEG_QUALITY)]),
'png': ('png', 'image/png', [int(cv2.IMWRITE_PNG_COMPRESSION), 3]),
'webp': ('webp', 'image/webp', [int(cv2.IMWRITE_WEBP_QUALITY), 92]),
}
def rrdb_x2():
return RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32)
def srvgg(num_conv):
return lambda: SRVGGNetCompact(num_in_ch=3, num_out_ch=3, num_feat=64, num_conv=num_conv,
upscale=4, act_type='prelu')
MODEL_REGISTRY = OrderedDict([
('x2plus', {
'scale': 2,
'arch': rrdb_x2,
'file': 'RealESRGAN_x2plus.pth',
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth',
'min_bytes': 60_000_000,
'alias': True,
'denoise': False,
'title': 'Универсальный апскейл x2, дефолт',
}),
('general-x4v3', {
'scale': 4,
'arch': srvgg(32),
'file': 'realesr-general-x4v3.pth',
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth',
'min_bytes': 2_000_000,
'alias': False,
'denoise': True,
'title': 'Быстрый апскейл x4 с денойзом',
'dni': {
'file': 'realesr-general-wdn-x4v3.pth',
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth',
'min_bytes': 2_000_000,
'weight': 0.5,
},
}),
('animevideo-v3', {
'scale': 4,
'arch': srvgg(16),
'file': 'realesr-animevideov3.pth',
'url': 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth',
'min_bytes': 1_000_000,
'alias': False,
'denoise': False,
'title': 'Быстрый апскейл x4 для скриншотов и иллюстраций',
}),
])
FACE_REGISTRY = OrderedDict([
('gfpgan', {
'module': 'gfpgan',
'file': 'GFPGANv1.4.pth',
'url': 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth',
'min_bytes': 300_000_000,
'arch': 'clean',
'channel_multiplier': 2,
'strength': False,
'title': 'GFPGAN v1.4, восстановление лиц, дефолт',
}),
('codeformer', {
'module': 'codeformer',
'file': 'codeformer.pth',
'url': 'https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth',
'min_bytes': 300_000_000,
'strength': True,
'title': 'CodeFormer, восстановление лиц с регулируемой силой',
}),
])
FACEXLIB_WEIGHTS = OrderedDict([
('detection_Resnet50_Final.pth', {
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth',
'min_bytes': 90_000_000,
}),
('parsing_parsenet.pth', {
'url': 'https://github.com/xinntao/facexlib/releases/download/v0.2.2/parsing_parsenet.pth',
'min_bytes': 70_000_000,
}),
])
def cuda_ready():
try:
return bool(torch.cuda.is_available()) and torch.cuda.device_count() > 0
except Exception:
return False
def mps_ready():
try:
return bool(torch.backends.mps.is_available())
except Exception:
return False
def cpu_device_name():
return 'CPU (' + (platform.machine() or 'unknown') + ')'
def pick_device():
if DEVICE_PREF == 'cpu':
return 'cpu', False, cpu_device_name()
if DEVICE_PREF == 'mps':
if mps_ready():
return 'mps', False, 'Apple Silicon (MPS)'
log.warning('PHOTO_AI_DEVICE=mps, но MPS недоступен — работаю на CPU')
return 'cpu', False, cpu_device_name()
if DEVICE_PREF == 'cuda':
if not cuda_ready():
log.warning('PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU')
return 'cpu', False, cpu_device_name()
return 'cuda:0', True, torch.cuda.get_device_name(0)
if cuda_ready():
return 'cuda:0', True, torch.cuda.get_device_name(0)
if mps_ready():
return 'mps', False, 'Apple Silicon (MPS)'
return 'cpu', False, cpu_device_name()
state = {'device': 'cpu', 'half': False, 'device_name': 'CPU', 'tile': BASE_TILE, 'degraded': False}
state['device'], state['half'], state['device_name'] = pick_device()
INFER_LOCK = threading.RLock()
class EnhanceError(Exception):
def __init__(self, message, status_code=500):
super().__init__(message)
self.message = message
self.status_code = status_code
class ModelNotReady(EnhanceError):
def __init__(self, label):
super().__init__('модель %s ещё загружается, повторите позже' % label, 503)
self.label = label
class TileOOM(Exception):
pass
def error_response(status_code, message, headers=None):
return JSONResponse(status_code=status_code, content={'ok': False, 'error': message},
headers=headers)
def is_oom(err):
if isinstance(err, (torch.cuda.OutOfMemoryError, TileOOM)):
return True
text = str(err).lower()
return any(mark in text for mark in ('out of memory', 'not enough memory',
'alloc_cpu', "can't allocate memory"))
def guard_forward(model):
if getattr(model, '_photo_ai_guarded', False):
return model
forward = model.forward
def guarded(*args, **kwargs):
try:
return forward(*args, **kwargs)
except RuntimeError as err:
if not is_oom(err):
raise
raise TileOOM(str(err)) from err
model.forward = guarded
model._photo_ai_guarded = True
return model
def tile_ladder(base):
if base <= 0:
return [base]
ladder = []
for tile in (base, base // 2, base // 4):
if tile >= MIN_TILE and (not ladder or ladder[-1] != tile):
ladder.append(tile)
return ladder or [base]
def file_ok(path, min_bytes):
return os.path.isfile(path) and os.path.getsize(path) >= min_bytes
def link_or_copy(src, dst):
os.makedirs(os.path.dirname(dst), exist_ok=True)
try:
os.link(src, dst)
except OSError:
shutil.copyfile(src, dst)
def download_weight(url, target, min_bytes):
os.makedirs(os.path.dirname(target), exist_ok=True)
tmp = target + '.tmp'
log.info('качаю веса %s -> %s', url, target)
try:
urllib.request.urlretrieve(url, tmp)
except Exception as err:
if os.path.exists(tmp):
os.unlink(tmp)
raise EnhanceError('не удалось скачать веса %s: %s' % (os.path.basename(target), err), 500) from err
size = os.path.getsize(tmp) if os.path.isfile(tmp) else 0
if size < min_bytes:
os.unlink(tmp)
raise EnhanceError('веса %s повреждены: %d байт, минимум %d'
% (os.path.basename(target), size, min_bytes), 500)
os.replace(tmp, target)
def ensure_weight(name, spec, alias=''):
target = os.path.join(WEIGHTS_DIR, name)
if file_ok(target, spec['min_bytes']):
return target
if os.path.isfile(target):
log.warning('удаляю битые веса %s (%d байт)', target, os.path.getsize(target))
os.unlink(target)
if alias and file_ok(alias, spec['min_bytes']):
link_or_copy(alias, target)
log.info('веса %s взяты из существующего файла %s', name, alias)
return target
download_weight(spec['url'], target, spec['min_bytes'])
return target
def ensure_facexlib_weights():
for name, spec in FACEXLIB_WEIGHTS.items():
ensure_weight(name, spec)
def upsamplers_of(obj):
found = []
if isinstance(obj, RealESRGANer):
found.append(obj)
inner = getattr(obj, 'upsampler', None)
if isinstance(inner, RealESRGANer):
found.append(inner)
bg = getattr(obj, 'bg_upsampler', None)
if isinstance(bg, RealESRGANer):
found.append(bg)
elif isinstance(getattr(bg, 'upsampler', None), RealESRGANer):
found.append(bg.upsampler)
return found
def apply_tile(obj, tile):
for upsampler in upsamplers_of(obj):
upsampler.tile_size = tile
def warm_up(runner):
if not WARMUP:
return
noise = np.random.default_rng(0).integers(0, 256, (WARMUP_SIZE, WARMUP_SIZE, 3), dtype=np.uint8)
with INFER_LOCK:
runner.enhance(noise)
class Upscaler:
def __init__(self, upsampler, outscale):
self.upsampler = upsampler
self.outscale = outscale
def enhance(self, img, outscale=None):
output, _mode = self.upsampler.enhance(img, outscale=outscale or self.outscale)
return output
class FaceRunner:
def __init__(self, label, outscale, bg, restore):
self.label = label
self.outscale = outscale
self.bg_upsampler = bg
self.restore = restore
def enhance(self, img, strength=DEFAULT_STRENGTH):
output, faces_found = self.restore(img, strength)
return output, faces_found
class ModelPool:
def __init__(self, limit=POOL_LIMIT):
self.lock = threading.Lock()
self.entries = OrderedDict()
self.loading = OrderedDict()
self.in_use = {}
self.limit = limit
def get(self, key, label, factory):
with self.lock:
entry = self.entries.get(key)
if entry is not None:
self.entries.move_to_end(key)
self.in_use[key] = self.in_use.get(key, 0) + 1
return entry['obj']
if key in self.loading:
raise ModelNotReady(label)
self.loading[key] = label
try:
obj = factory()
warm_up(obj)
except BaseException:
with self.lock:
self.loading.pop(key, None)
raise
with self.lock:
self.loading.pop(key, None)
self.entries[key] = {'obj': obj, 'label': label}
self.in_use[key] = self.in_use.get(key, 0) + 1
apply_tile(obj, state['tile'])
self.evict_locked()
return obj
def release(self, key):
with self.lock:
if self.in_use.get(key):
self.in_use[key] -= 1
def evict_locked(self):
while len(self.entries) > self.limit:
for key in list(self.entries):
if not self.in_use.get(key):
self.entries.pop(key, None)
self.in_use.pop(key, None)
log.info('выгружаю из кэша модель %s (LRU, лимит %d)', key, self.limit)
break
else:
break
@contextmanager
def acquire(self, key, label, factory):
obj = self.get(key, label, factory)
try:
yield obj
finally:
self.release(key)
def contains(self, key):
with self.lock:
return key in self.entries
def clear(self):
with self.lock:
self.entries.clear()
self.in_use.clear()
def set_tile(self, tile):
with self.lock:
for entry in self.entries.values():
apply_tile(entry['obj'], tile)
def loaded_labels(self):
with self.lock:
labels = []
for entry in self.entries.values():
if entry['label'] not in labels:
labels.append(entry['label'])
return labels
def loading_labels(self):
with self.lock:
return list(self.loading.values())
pool = ModelPool()
def build_esrgan(name, denoise):
spec = MODEL_REGISTRY[name]
alias = LEGACY_MODEL_PATH if spec['alias'] else ''
path = ensure_weight(spec['file'], spec, alias)
model_path = path
dni_weight = None
if denoise and spec.get('dni'):
dni_spec = spec['dni']
dni_path = ensure_weight(dni_spec['file'], dni_spec)
weight = float(dni_spec.get('weight', 0.5))
model_path = [path, dni_path]
dni_weight = (1.0 - weight, weight)
upsampler = RealESRGANer(
scale=spec['scale'],
model_path=model_path,
dni_weight=dni_weight,
model=guard_forward(spec['arch']()),
tile=state['tile'],
tile_pad=TILE_PAD,
pre_pad=PRE_PAD,
half=state['half'],
device=torch.device(state['device']),
)
return Upscaler(upsampler, spec['scale'])
def face_helper(outscale):
from facexlib.utils.face_restoration_helper import FaceRestoreHelper
return FaceRestoreHelper(
upscale_factor=outscale,
face_size=512,
crop_ratio=(1, 1),
det_model='retinaface_resnet50',
save_ext='png',
use_parse=True,
device=torch.device(state['device']),
model_rootpath=WEIGHTS_DIR,
)
def link_default_facexlib_dir():
default_dir = os.path.abspath('gfpgan/weights')
try:
if os.path.realpath(default_dir) == os.path.realpath(WEIGHTS_DIR):
return
if os.path.isdir(default_dir) and not os.path.islink(default_dir):
shutil.rmtree(default_dir, ignore_errors=True)
os.makedirs(os.path.dirname(default_dir), exist_ok=True)
if not os.path.lexists(default_dir):
os.symlink(WEIGHTS_DIR, default_dir)
log.info('каталог facexlib %s смотрит в том моделей', default_dir)
except OSError as err:
log.warning('не удалось направить %s в %s: %s', default_dir, WEIGHTS_DIR, err)
def build_face_gfpgan(spec, outscale, bg):
from gfpgan import GFPGANer
link_default_facexlib_dir()
ensure_facexlib_weights()
path = ensure_weight(spec['file'], spec)
restorer = GFPGANer(
model_path=path,
upscale=outscale,
arch=spec['arch'],
channel_multiplier=spec['channel_multiplier'],
bg_upsampler=bg.upsampler,
device=torch.device(state['device']),
)
guard_forward(restorer.gfpgan)
def restore(img, strength):
cropped, _restored, output = restorer.enhance(img, has_aligned=False, only_center_face=False,
paste_back=True)
if output is None:
output = bg.enhance(img, outscale)
return output, len(cropped)
return FaceRunner('gfpgan', outscale, bg, restore)
def build_face_codeformer(spec, outscale, bg):
from codeformer import CodeFormer
from gfpgan.utils import img2tensor, tensor2img
from torchvision.transforms.functional import normalize
ensure_facexlib_weights()
path = ensure_weight(spec['file'], spec)
net = CodeFormer(dim_embd=512, codebook_size=1024, n_head=8, n_layers=9,
connect_list=['32', '64', '128', '256'], device=state['device'], fp16=False)
net.load_state_dict(torch.load(path, map_location=lambda storage, loc: storage))
net.eval()
guard_forward(net)
helper = face_helper(outscale)
def restore(img, strength):
helper.clean_all()
helper.read_image(img)
helper.get_face_landmarks_5(only_center_face=False, eye_dist_threshold=5)
helper.align_warp_face()
for cropped in helper.cropped_faces:
tensor = img2tensor(cropped / 255., bgr2rgb=True, float32=True)
normalize(tensor, (0.5, 0.5, 0.5), (0.5, 0.5, 0.5), inplace=True)
tensor = tensor.unsqueeze(0).to(net.device)
with torch.no_grad():
output = net(tensor, w=clamp(float(strength), 0.0, 1.0))[0]
restored = tensor2img(output.squeeze(0), rgb2bgr=True, min_max=(-1, 1))
helper.add_restored_face(restored.astype('uint8'))
bg_img = bg.enhance(img, outscale)
helper.get_inverse_affine(None)
return helper.paste_faces_to_input_image(upsample_img=bg_img), len(helper.cropped_faces)
return FaceRunner('codeformer', outscale, bg, restore)
FACE_BUILDERS = {'gfpgan': build_face_gfpgan, 'codeformer': build_face_codeformer}
def face_installed(name):
module = FACE_REGISTRY[name]['module']
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError, AttributeError):
return False
def available_face_models():
return [name for name in FACE_REGISTRY if face_installed(name)]
def build_face(name, outscale, bg):
spec = FACE_REGISTRY[name]
return FACE_BUILDERS[name](spec, outscale, bg)
def process_image(img, outscale, model_name, face, face_name, strength):
denoise = face == 'all' and bool(MODEL_REGISTRY[model_name].get('denoise'))
if face == 'off':
suffix = ':wdn' if denoise else ''
with pool.acquire('esrgan:' + model_name + suffix, model_name,
lambda: build_esrgan(model_name, denoise)) as runner:
return runner.enhance(img, outscale), 0
key = 'face:%s@%d' % (face_name, outscale)
def factory():
return build_face(face_name, outscale, build_esrgan(model_name, denoise))
with pool.acquire(key, face_name, factory) as runner:
return runner.enhance(img, strength)
def degrade_to_cpu(warnings):
if state['degraded']:
return
log.warning('устройство %s не справилось, переключаюсь на CPU', state['device'])
warnings.append('не хватило памяти на %s, обработка переведена на CPU' % state['device'])
pool.clear()
state['device'] = 'cpu'
state['half'] = False
state['device_name'] = cpu_device_name()
state['degraded'] = True
state['tile'] = BASE_TILE
def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
with INFER_LOCK:
start_tile = state['tile']
try:
for tile in tile_ladder(start_tile):
state['tile'] = tile
pool.set_tile(tile)
try:
return process_image(img, outscale, model_name, face, face_name, strength)
except (RuntimeError, TileOOM) as err:
if not is_oom(err):
raise EnhanceError('ошибка модели: %s' % err, 500) from err
log.warning('нехватка памяти при tile=%s: %s', tile, err)
warnings.append('не хватило памяти при tile=%d' % tile)
if not state['device'].startswith('cpu'):
degrade_to_cpu(warnings)
try:
return process_image(img, outscale, model_name, face, face_name, strength)
except (RuntimeError, TileOOM) as err:
if is_oom(err):
raise EnhanceError('не хватило памяти даже на CPU: %s' % err, 500) from err
raise EnhanceError('ошибка модели на CPU: %s' % err, 500) from err
raise EnhanceError('не хватило памяти даже при tile=%d: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS'
% state['tile'], 500)
finally:
state['tile'] = start_tile
pool.set_tile(start_tile)
def encode_image(out, fmt, quality):
ext, media_type, params = fmt
if ext == 'jpg':
params = params + [quality]
ok, encoded = cv2.imencode('.' + ext, out, params)
if not ok:
raise EnhanceError('не удалось закодировать результат как %s' % ext, 500)
return encoded.tobytes(), media_type
def output_format(filename):
name = (filename or '').rsplit('/', 1)[-1]
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
if ext == 'jpeg':
ext = 'jpg'
return OUTPUT_FORMATS.get(ext, OUTPUT_FORMATS['jpg'])
def wants_json(request):
return 'application/json' in (request.headers.get('accept') or '').lower()
def driver_version():
try:
with open('/proc/driver/nvidia/version', 'r') as handle:
text = handle.read()
except OSError:
return None
match = re.search(r'\d+\.\d+\.\d+', text)
return match.group(0) if match else None
def vram_total_mb():
if not state['device'].startswith('cuda'):
return None
try:
return round(torch.cuda.get_device_properties(0).total_memory / (1024 * 1024))
except Exception:
return None
def vram_free_mb():
if not state['device'].startswith('cuda'):
return None
try:
free, _total = torch.cuda.mem_get_info()
return round(free / (1024 * 1024))
except Exception:
return None
def validate(model_name, face, face_name, strength, quality):
if model_name not in MODEL_REGISTRY:
raise EnhanceError('неизвестная модель %r, доступны: %s'
% (model_name, ', '.join(MODEL_REGISTRY)), 400)
if face not in FACE_MODES:
raise EnhanceError('неизвестный режим лиц %r, доступны: %s' % (face, ', '.join(FACE_MODES)), 400)
if quality < 70 or quality > 100:
raise EnhanceError('jpeg_quality должен быть 70..100, получено %d' % quality, 400)
if face == 'off':
return
if face_name not in FACE_REGISTRY:
raise EnhanceError('неизвестная face-модель %r, доступны: %s'
% (face_name, ', '.join(available_face_models()) or 'нет'), 400)
if not face_installed(face_name):
others = [name for name in available_face_models()]
raise EnhanceError('модель лиц %s не установлена в образ, доступен %s'
% (face_name, ', '.join(others) or 'ни один'), 400)
if strength < 0.0 or strength > 1.0:
raise EnhanceError('strength должен быть 0..1, получено %s' % strength, 400)
if not FACE_REGISTRY[face_name]['strength'] and abs(strength - DEFAULT_STRENGTH) > 1e-6:
raise EnhanceError('strength применяется только к CodeFormer, для %s оставьте %s'
% (face_name, DEFAULT_STRENGTH), 400)
def preload():
names = list(MODEL_REGISTRY) if LOAD_ALL else [DEFAULT_MODEL]
for name in names:
try:
with pool.acquire('esrgan:' + name, name, lambda n=name: build_esrgan(n, False)):
log.info('модель %s готова', name)
except Exception as err:
log.error('предзагрузка модели %s не удалась: %s', name, err)
app = FastAPI()
@app.on_event('startup')
async def startup():
log.info('устройство: %s (%s), half=%s, tile=%s, веса: %s',
state['device'], state['device_name'], state['half'], state['tile'], WEIGHTS_DIR)
threading.Thread(target=preload, name='photo-ai-preload', daemon=True).start()
@app.get('/health')
def health():
return {
'ok': True,
'ready': pool.contains('esrgan:' + DEFAULT_MODEL),
'device': state['device'],
'device_name': state['device_name'],
'half': state['half'],
'tile': state['tile'],
'driver': driver_version(),
'cuda': torch.version.cuda,
'vram_total_mb': vram_total_mb(),
'vram_free_mb': vram_free_mb(),
'models': list(MODEL_REGISTRY),
'face_models': available_face_models(),
'loaded': pool.loaded_labels(),
'loading': pool.loading_labels(),
'max_pixels': MAX_PIXELS,
}
@app.get('/models')
def models():
loaded = pool.loaded_labels()
faces = available_face_models()
return {
'device': state['device'],
'device_name': state['device_name'],
'half': state['half'],
'tile': state['tile'],
'max_pixels': MAX_PIXELS,
'defaults': {
'model': DEFAULT_MODEL,
'scale': 2,
'face': 'off',
'face_model': DEFAULT_FACE_MODEL,
'strength': DEFAULT_STRENGTH,
'jpeg_quality': DEFAULT_JPEG_QUALITY,
},
'models': [
{
'name': name,
'title': spec['title'],
'scale': spec['scale'],
'weights': spec['file'],
'denoise': bool(spec.get('denoise')),
'loaded': name in loaded,
}
for name, spec in MODEL_REGISTRY.items()
],
'face_models': [
{
'name': name,
'title': spec['title'],
'weights': spec['file'],
'available': name in faces,
'strength': bool(spec['strength']),
'loaded': name in loaded,
}
for name, spec in FACE_REGISTRY.items()
],
}
@app.post('/enhance')
async def enhance(request: Request,
image: UploadFile = File(...),
scale: int = Form(2),
model: str = Form(DEFAULT_MODEL),
face: str = Form('off'),
face_model: str = Form(''),
strength: float = Form(DEFAULT_STRENGTH),
jpeg_quality: int = Form(0)):
started = time.time()
model_name = (model or DEFAULT_MODEL).strip().lower()
face_mode = (face or 'off').strip().lower()
face_name = (face_model or '').strip().lower() or (
DEFAULT_FACE_MODEL if DEFAULT_FACE_MODEL in FACE_REGISTRY else 'gfpgan')
quality = jpeg_quality if jpeg_quality else DEFAULT_JPEG_QUALITY
try:
validate(model_name, face_mode, face_name, strength, quality)
data = await image.read()
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
raise EnhanceError('bad image', 400)
if img.shape[0] * img.shape[1] > MAX_PIXELS:
ratio = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5
img = cv2.resize(img, (int(img.shape[1] * ratio), int(img.shape[0] * ratio)),
interpolation=cv2.INTER_AREA)
outscale = min(max(int(scale), 2), 4)
fmt = output_format(image.filename)
warnings = []
if face_mode != 'off':
if min(img.shape[0], img.shape[1]) < MIN_FACE_SIDE:
warnings.append('вход меньше %d×%d — лица могут не найтись'
% (MIN_FACE_SIDE, MIN_FACE_SIDE))
if face_name == 'codeformer' and not state['device'].startswith('cuda'):
warnings.append('CodeFormer на %s медленнее GFPGAN' % state['device'])
output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
face_mode, face_name, float(strength), warnings)
payload, media_type = encode_image(output, fmt, int(quality))
except EnhanceError as err:
if err.status_code == 503:
return error_response(503, err.message, headers={'Retry-After': str(RETRY_AFTER_SEC)})
return error_response(err.status_code, err.message)
if face_mode != 'off' and not faces_found:
warnings.append('лица не найдены, фон обработан апскейлом')
elapsed_ms = int((time.time() - started) * 1000)
if wants_json(request):
return {
'ok': True,
'image_base64': base64.b64encode(payload).decode('ascii'),
'image_ext': fmt[0],
'model': model_name,
'face': face_mode,
'face_model': face_name if face_mode != 'off' else None,
'faces_found': faces_found,
'device': state['device'],
'elapsed_ms': elapsed_ms,
'warnings': warnings,
}
return Response(payload, media_type=media_type, headers={'X-Photo-AI-Model': model_name,
'X-Photo-AI-Face': face_mode,
'X-Photo-AI-Device': state['device'],
'X-Photo-AI-Elapsed-Ms': str(elapsed_ms)})
View File
+305 -20
View File
@@ -3,23 +3,50 @@
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh}
.layout{display:flex;min-height:100dvh}
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.user-box .user-role{color:var(--muted);font-size:.72rem}
.sidebar nav{flex:1;padding:12px 0}
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column;z-index:50;box-shadow:1px 0 0 rgba(0,0,0,.02);user-select:none}
.sidebar .logo{height:58px;padding:0 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;min-width:0;flex-shrink:0;font-size:1.02rem;font-weight:700;letter-spacing:-.01em;background:var(--card)}
.sidebar .logo-ic{height:28px;width:auto;min-width:28px;border-radius:6px;background:var(--bg);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0}
.sidebar .logo-ic img{max-width:100%;max-height:100%;object-fit:contain}
.sidebar #sidebarName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.user-box{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--card)}
.user-box .user-avatar{width:32px;height:32px;border-radius:8px;background:rgba(37,99,235,.08);color:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.user-box .user-avatar svg{width:16px;height:16px}
.user-box .user-meta{display:flex;flex-direction:column;min-width:0;flex:1}
.user-box .user-name{font-weight:600;font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);line-height:1.25}
.user-box .user-role{color:var(--muted);font-size:.7rem;font-weight:500;line-height:1.2}
.nav-scroll-wrap{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:8px 0;scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.nav-scroll-wrap::-webkit-scrollbar{width:5px}
.nav-scroll-wrap::-webkit-scrollbar-track{background:transparent}
.nav-scroll-wrap::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:10px}
.nav-scroll-wrap::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.22)}
.sidebar nav{display:flex;flex-direction:column;gap:10px;padding:0 8px}
.nav-group{display:flex;flex-direction:column;gap:2px}
.nav-group-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:6px 10px 4px;user-select:none;opacity:.8}
.nav-items{display:flex;flex-direction:column;gap:1px}
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.84rem;font-weight:500;line-height:1.3;transition:all .14s ease;position:relative}
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.06);border-left-color:var(--accent);font-weight:500}
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0}
.sidebar nav a.active{color:var(--accent);background:rgba(37,99,235,.08);font-weight:600}
.sidebar nav a.active::before{content:'';position:absolute;left:0;top:6px;bottom:6px;width:3px;border-radius:0 3px 3px 0;background:var(--accent)}
.sidebar nav a i,.sidebar .bottom a i{width:16px;height:16px;flex-shrink:0;transition:transform .14s ease}
.sidebar nav a:hover i{transform:scale(1.06)}
.sidebar nav a .nav-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sidebar-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:auto;box-shadow:0 1px 3px rgba(239,68,68,.35)}
.sidebar-badge[hidden]{display:none}
.sidebar nav a[data-nav=dashboard] svg{color:#6366f1}
.sidebar nav a[data-nav=journal] svg{color:#0ea5e9}
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
.sidebar nav a[data-nav=students] svg{color:#14b8a6}
.sidebar nav a[data-nav=groups] svg{color:#8b5cf6}
.sidebar nav a[data-nav=files] svg{color:#f59e0b}
.sidebar nav a[data-nav=photos] svg{color:#0891b2}
.sidebar nav a[data-nav=links] svg{color:#06b6d4}
.sidebar nav a[data-nav=trash] svg{color:#ef4444}
.sidebar nav a[data-nav=modules] svg{color:#3b82f6}
.sidebar nav a[data-nav=branches] svg{color:#10b981}
.sidebar nav a[data-nav=users] svg{color:#ec4899}
.sidebar nav a[data-nav=worker] svg{color:#a855f7}
@@ -27,11 +54,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.sidebar nav a[data-nav=bans] svg{color:#dc2626}
.sidebar nav a[data-nav=settings] svg{color:#475569}
.sidebar nav a.active svg{color:var(--accent)}
.sidebar .bottom a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom{padding:12px 20px;border-top:1px solid var(--border)}
.sidebar .bottom a{display:flex;align-items:center;gap:10px;color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 0}
.sidebar .bottom a:hover{color:var(--text)}
.sidebar .bottom .logout{color:#ef4444;cursor:pointer;margin-top:4px}
.sidebar .bottom{padding:10px 10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;flex-shrink:0;background:var(--card)}
.sidebar .bottom-actions{display:flex;flex-direction:column;gap:1px}
.sidebar .bottom-actions a{display:flex;align-items:center;gap:10px;padding:6px 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:.82rem;font-weight:500;transition:all .14s ease;position:relative}
.sidebar .bottom-actions a:hover{color:var(--text);background:var(--bg)}
.sidebar .bottom-actions a[data-nav=home] svg{color:#2563eb}
.sidebar .bottom-actions .notify-bell svg{color:#f59e0b}
.sidebar .bottom-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:6px;border-top:1px dashed var(--border)}
.sidebar .bottom-footer .logout-btn{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;color:#ef4444;text-decoration:none;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .14s ease}
.sidebar .bottom-footer .logout-btn:hover{background:rgba(239,68,68,.08)}
.sidebar .bottom-footer .logout-btn svg{width:14px;height:14px;color:#ef4444}
.sidebar .bottom-footer .version{color:var(--muted);font-size:.7rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
.main{flex:1;margin-left:var(--sidebar);padding:32px}
.main h2{font-size:1.3rem;font-weight:600;margin-bottom:20px}
@@ -49,8 +83,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
.search-input:focus{border-color:var(--accent)}
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
@@ -127,6 +161,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.item .name-input:focus{border-color:var(--accent)}
.item .group-select{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 8px;font-size:.8rem;outline:none;max-width:200px}
.item .group-select:focus{border-color:var(--accent)}
.student-main{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.student-main .name-input{flex:1;min-width:0}
.student-avatar{width:40px;height:40px;flex:0 0 auto;border-radius:50%;overflow:hidden;border:1px solid var(--border);background:var(--bg);padding:0;cursor:zoom-in;display:flex;align-items:center;justify-content:center;transition:border-color .15s,opacity .15s}
.student-avatar img{width:100%;height:100%;object-fit:cover;display:block}
.student-avatar:hover{border-color:var(--accent);opacity:.9}
.student-avatar.empty{cursor:pointer;border-style:dashed}
.student-avatar.empty span{font-size:.75rem;font-weight:700;color:var(--muted);letter-spacing:.02em;user-select:none}
.item.expired{opacity:.6;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.03)}
.item.expired:hover{opacity:.85}
.badge-expired{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle;margin-left:6px}
.badge-soon{display:inline-block;font-size:.68rem;font-weight:700;line-height:1;padding:3px 8px;border-radius:999px;background:rgba(245,158,11,.14);color:#f59e0b;vertical-align:middle;margin-left:6px}
.attach-list{max-height:280px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;border:1px solid var(--border);border-radius:8px;padding:8px}
.attach-list label{display:flex;gap:8px;align-items:center;font-size:.85rem;color:var(--text);margin:0;cursor:pointer}
.attach-list .cur{color:var(--muted);font-size:.75rem;margin-left:auto}
@@ -158,7 +203,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.group-card .save-sched:hover{opacity:.85}
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
.group-card .btn-link:hover{border-color:var(--accent)}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:16px}
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
@@ -177,6 +222,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex}
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
#videoModal{background:rgba(0,0,0,.85)}
.video-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:16px;width:min(1000px,94vw);max-height:94dvh;display:flex;flex-direction:column;gap:12px}
.video-head{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text)}
.video-head > span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.video-dl{background:none;border:1px solid var(--border);border-radius:6px;padding:3px 10px;color:var(--muted);text-decoration:none;font-size:.75rem}
.video-dl:hover{color:var(--accent);border-color:var(--accent)}
.video-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px}
.video-close:hover{color:var(--text)}
#videoPlayer{width:100%;max-height:78dvh;background:#000;border-radius:var(--radius);display:block}
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
.edit-modal h3{font-size:1.1rem;font-weight:600}
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
@@ -192,6 +246,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.edit-modal .actions button:hover{opacity:.85}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
.toast.show{opacity:1}
.pf-photos{display:flex;flex-wrap:wrap;gap:10px}
.pf-photo{position:relative;width:96px;height:96px;border-radius:10px;overflow:hidden;border:2px solid var(--border);cursor:pointer;flex:0 0 auto}
.pf-photo.main{border-color:var(--accent)}
.pf-photo img{width:100%;height:100%;object-fit:cover;display:block}
.pf-badge{position:absolute;left:4px;bottom:4px;background:var(--accent);color:#fff;font-size:.65rem;font-weight:600;padding:2px 6px;border-radius:6px}
.pf-del{position:absolute;top:4px;right:4px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:.7rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}
.pf-del:hover{background:#ef4444}
/* --- Фото группы (хронология) --- */
.group-cover{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;display:block;background:var(--bg)}
@@ -242,17 +303,23 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
.del-btn:hover{opacity:.8}
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
.pending-toggle{margin-bottom:16px;max-width:680px}
.trash-section{margin-top:24px}
.trash-section h4{margin:16px 0 8px;font-size:.95rem;font-weight:600}
.card.pending{border-color:#d97706;background:linear-gradient(180deg,rgba(217,119,6,.06),transparent 55%)}
.trash-time.warn{color:#d97706;font-weight:600}
/* --- Настройки (UX/UI) --- */
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
.page-sub{color:var(--muted);font-size:.85rem}
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
.settings-stack{display:flex;flex-direction:column;gap:20px}
.link-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
.link-fields > .full{grid-column:1/-1}
@media(max-width:600px){.link-fields{grid-template-columns:1fr}}
.settings-stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;max-width:1200px}
.settings-stack-grid > .settings-card{max-width:none;height:100%}
.settings-stack-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;width:100%}
@media(max-width:900px){.settings-stack-grid{grid-template-columns:1fr}}
.settings-stack-grid > .settings-card{max-width:none;min-width:0;height:100%}
.settings-stack-grid > .settings-card.wide{grid-column:1/-1}
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
@@ -287,6 +354,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
@media(max-width:640px){.save-bar{left:16px;right:16px}}
.logo-picker{display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap}
.logo-preview{width:160px;max-width:100%;min-width:96px;min-height:72px;max-height:96px;height:auto;border:1px dashed var(--border);background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;font-size:1.4rem;font-weight:700;color:var(--muted)}
.logo-preview img{max-width:100%;max-height:96px;width:auto;height:auto;object-fit:contain;display:none}
.logo-picker-actions{display:flex;flex-direction:column;gap:10px;align-items:flex-start;min-width:0}
.logo-picker-btns{display:flex;gap:10px;flex-wrap:wrap}
.logo-upload-status{font-size:.8rem;color:var(--muted);min-height:18px;display:flex;align-items:center;gap:8px}
.logo-upload-status .fail{color:#ef4444}
/* --- Дашборд --- */
.dash-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:20px;align-items:start}
.dash-col{display:flex;flex-direction:column;gap:20px;min-width:0}
@@ -365,6 +440,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.sys-bar-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:4px}
.sys-bar{height:100%;transition:width .3s,background .3s}
.sys-meta{font-size:.7rem;color:var(--muted);margin-top:4px}
.sys-inline{display:inline-flex;align-items:center;gap:7px;justify-content:flex-end}
.actions-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.audit-table{width:100%;border-collapse:collapse;font-size:.85rem}
@@ -377,6 +453,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.audit-details:hover{text-decoration:underline}
.audit-details-body{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;max-height:60vh;overflow:auto}
.audit-details-body pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.78rem;line-height:1.45;color:var(--text)}
.audit-details-body.audit-loading{opacity:.6}
.audit-details-head{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.audit-pill{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-size:.72rem;color:var(--muted)}
.audit-changes{display:flex;flex-direction:column;gap:12px}
.audit-change{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
.audit-change-title{font-weight:600;font-size:.82rem;margin-bottom:6px}
.audit-change-stats{display:flex;flex-wrap:wrap;gap:10px;font-size:.75rem;margin-bottom:8px}
.audit-change-line{font-size:.8rem;line-height:1.6;word-break:break-word}
.audit-tag-del,.audit-tag-add{display:inline-block;min-width:52px;font-size:.7rem;text-transform:uppercase;letter-spacing:.03em;padding:1px 6px;border-radius:4px;margin-right:6px}
.audit-tag-del{background:rgba(239,68,68,.14);color:#dc2626}
.audit-tag-add{background:rgba(22,163,74,.14);color:#16a34a}
.audit-stat-add{color:#16a34a;font-weight:600}
.audit-stat-del{color:#dc2626;font-weight:600}
.audit-stat-muted{color:var(--muted)}
.audit-diff{white-space:pre-wrap;word-break:break-word;font-size:.85rem;line-height:1.6;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;max-height:40vh;overflow:auto}
.audit-diff-add{background:rgba(22,163,74,.18);color:#14532d;text-decoration:none;border-radius:3px;padding:0 2px}
.audit-diff-del{background:rgba(239,68,68,.16);color:#7f1d1d;border-radius:3px;padding:0 2px}
.audit-truncated{margin-top:6px;font-size:.72rem;color:#b45309}
.audit-raw{margin-top:12px;font-size:.78rem;color:var(--muted)}
.audit-raw summary{cursor:pointer}
.audit-raw pre{margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px}
/* --- AI Correction --- */
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
@@ -418,4 +515,192 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.worker-counts .chip.skipped b{color:var(--muted)}
.worker-counts .chip.reverted b{color:var(--accent)}
@keyframes entryEnter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
@keyframes entryPopIn{0%{opacity:0;transform:translateY(26px) scale(.95)}55%{opacity:1;transform:translateY(-5px) scale(1.015)}100%{opacity:1;transform:none}}
@keyframes entryFadeOut{to{opacity:0}}
.entry-enter{animation:entryEnter .4s cubic-bezier(.22,1,.36,1) both}
.entry-appear{animation:entryPopIn .6s cubic-bezier(.22,1,.36,1) both;position:relative}
.entry-appear::before{content:'';position:absolute;inset:-1px;border-radius:inherit;box-shadow:0 0 0 3px rgba(247,201,14,.9),0 10px 30px rgba(247,201,14,.4);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
.entry-appear::after{content:'';position:absolute;inset:0;border-radius:inherit;background:linear-gradient(180deg,rgba(247,201,14,.16),transparent 55%);animation:entryFadeOut 2.4s ease .35s both;pointer-events:none}
@media (prefers-reduced-motion: reduce){.entry-enter,.entry-appear,.entry-appear::before,.entry-appear::after{animation:none}}
.ai-profiles-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
.ai-profile-item{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card)}
.ai-profile-item.active{border-color:var(--accent);background:rgba(37,99,235,.05)}
.ai-profile-item .pi-info{flex:1;min-width:0}
.ai-profile-item .pi-name{font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:8px}
.ai-profile-item .pi-meta{color:var(--muted);font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ai-profile-item .pi-actions{display:flex;gap:6px;flex-shrink:0}
.pi-btn{background:none;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:.78rem;cursor:pointer;color:var(--text);transition:all .15s}
.pi-btn:hover{border-color:var(--accent);color:var(--accent)}
.pi-btn.danger:hover{border-color:#ef4444;color:#ef4444}
.pi-badge{font-size:.68rem;font-weight:700;padding:2px 8px;border-radius:999px;background:rgba(22,163,74,.12);color:#16a34a;white-space:nowrap}
/* --- Настройки (UX/UI v2) --- */
body[data-page=settings] .page-head{align-items:flex-end}
.page-status{margin-left:auto;display:inline-flex;align-items:center;gap:8px;font-size:.8rem;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 14px;white-space:nowrap;transition:opacity .3s}
.page-status.loaded{opacity:0;pointer-events:none}
.page-status.err{color:#ef4444;opacity:1}
.head-btn{display:inline-flex;align-items:center;gap:7px;background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:10px;padding:8px 14px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}
.head-btn:hover{color:var(--accent);border-color:var(--accent)}
.head-btn svg{width:14px;height:14px}
.settings-layout{display:grid;grid-template-columns:190px minmax(0,1fr);gap:28px;align-items:start}
.settings-nav{position:sticky;top:16px;display:flex;flex-direction:column;gap:2px}
.settings-nav a{display:block;padding:9px 13px;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.86rem;font-weight:500;transition:all .15s;white-space:nowrap}
.settings-nav a:hover{color:var(--text);background:var(--card)}
.settings-nav a.active{color:var(--accent);background:var(--card);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
.settings-nav a svg{width:15px;height:15px;flex-shrink:0;vertical-align:-3px;margin-right:2px}
.settings-layout .settings-stack,.settings-layout .settings-stack-grid{max-width:none}
.settings-card[id]{scroll-margin-top:16px}
.settings-card{transition:border-color .2s,box-shadow .2s}
.settings-card:target{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1)}
.head-live{margin-left:auto;font-size:.72rem;color:var(--muted);white-space:nowrap}
.toggle{position:relative;display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--bg);border:1px solid transparent;border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
.toggle:hover{border-color:var(--border);background:var(--card)}
.toggle input{position:absolute;opacity:0;width:1px;height:1px}
.toggle-track{position:relative;width:40px;height:22px;border-radius:999px;background:#d4d4d4;flex-shrink:0;transition:background .2s}
.toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .2s}
.toggle input:checked ~ .toggle-track{background:var(--accent)}
.toggle input:checked ~ .toggle-track .toggle-thumb{transform:translateX(18px)}
.toggle input:focus-visible ~ .toggle-track{box-shadow:0 0 0 3px rgba(37,99,235,.25)}
.toggle-text{display:flex;flex-direction:column;gap:1px;min-width:0}
.toggle-text b{font-size:.87rem;font-weight:600}
.toggle-text small{font-size:.74rem;color:var(--muted);line-height:1.4}
.settings-field{position:relative}
.field-error{color:#ef4444;font-size:.75rem;font-weight:600;min-height:0;display:block}
.settings-input.invalid{border-color:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.1)}
.settings-input.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.84rem;line-height:1.5;min-height:110px;resize:vertical}
.preset-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
.preset-btn{background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 12px;font-size:.75rem;font-weight:600;color:var(--muted);cursor:pointer;transition:all .15s}
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
.preset-btn.active{background:rgba(37,99,235,.08);border-color:var(--accent);color:var(--accent)}
.toast.error{background:#dc2626}
.queue-link{color:var(--accent);text-decoration:none;font-weight:600;font-size:.8rem;white-space:nowrap}
.queue-link:hover{text-decoration:underline}
.ai-queue{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.ai-queue .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 12px;font-size:.78rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
.ai-queue .chip b{color:var(--accent);font-weight:700}
.ban-chip{flex-direction:column;align-items:flex-start;gap:2px;padding-right:8px}
.chip-sub{font-size:.72rem;color:var(--muted);font-weight:400}
.unban-btn{background:none;border:none;color:var(--accent);cursor:pointer;font-weight:700;font-size:.9rem;padding:0 2px;line-height:1}
.unban-btn:hover{color:#ef4444}
.ban-chip.confirming{background:rgba(239,68,68,.07);border-color:rgba(239,68,68,.4)}
.chip-confirm{display:inline-flex;align-items:center;gap:8px;font-size:.74rem;font-weight:600;white-space:nowrap;padding-right:4px}
.chip-yes,.chip-no{background:none;border:none;cursor:pointer;font-weight:700;padding:0}
.chip-yes{color:#16a34a}
.chip-no{color:var(--muted)}
.btn-primary.danger-ghost{background:var(--card);border:1px solid rgba(239,68,68,.35);color:#ef4444}
.btn-primary.danger-ghost:hover{border-color:#ef4444;opacity:1}
.ai-test-status{font-size:.85rem;min-height:1.3em}
.ai-test-status .ok{color:#16a34a;font-weight:600}
.ai-test-status .fail{color:#ef4444;font-weight:600}
.save-bar{animation:saveBarIn .25s cubic-bezier(.22,1,.36,1)}
.save-bar.show{border-color:rgba(245,158,11,.5)}
.save-bar-actions{display:flex;gap:10px;flex-wrap:wrap}
@keyframes saveBarIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
.icon-btn:hover{color:var(--accent);border-color:var(--accent)}
.icon-btn svg{width:14px;height:14px}
.icon-btn.spinning svg{animation:spin 1s linear infinite}
.sys-card.warn{border-color:rgba(245,158,11,.5);background:rgba(245,158,11,.05)}
.sys-card.danger{border-color:rgba(239,68,68,.5);background:rgba(239,68,68,.05)}
@media(max-width:1024px){
.settings-layout{grid-template-columns:1fr;gap:14px}
.settings-nav{position:sticky;top:0;z-index:40;flex-direction:row;gap:6px;overflow-x:auto;background:var(--bg);padding:8px 0;scrollbar-width:none}
.settings-nav::-webkit-scrollbar{display:none}
.settings-nav a{border:1px solid var(--border);background:var(--card);border-radius:999px;padding:7px 14px;font-size:.8rem}
.settings-nav a.active{box-shadow:none;border-color:var(--accent)}
.settings-card[id]{scroll-margin-top:60px}
}
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
/* --- Notifications --- */
.notify-bell{position:relative}
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
.notify-badge[hidden]{display:none}
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
.notify-panel[hidden]{display:none}
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
.notify-list{overflow-y:auto;flex:1}
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
.notify-item:hover{background:var(--bg)}
.notify-item:last-child{border-bottom:none}
.notify-item.unread{background:rgba(37,99,235,.05)}
.notify-item.unread .notify-title{font-weight:600}
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
.notify-ic svg{width:15px;height:15px}
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
.notify-title{font-size:.84rem;line-height:1.3}
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
.notify-time{font-size:.7rem;color:var(--muted)}
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
.notify-foot a{color:var(--accent);text-decoration:none}
.notify-foot a:hover{text-decoration:underline}
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
.notify-link:hover{text-decoration:underline}
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
.notify-page-head .grow{flex:1}
.notify-page-list{display:flex;flex-direction:column;gap:8px}
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
.notify-card.level-warning{border-left:3px solid #f59e0b}
.notify-card.level-critical{border-left:3px solid #ef4444}
.notify-card .notify-body{flex:1}
.notify-card .notify-title{font-size:.92rem}
.notify-card .notify-text{font-size:.82rem}
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
@media(max-width:1024px){
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
}
.lesson-modal{max-width:560px;width:92vw}
.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right}
.lesson-modal textarea{min-height:130px}
.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px}
.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px}
.lesson-ai-status.err{color:#ef4444}
.lesson-dup{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg);color:var(--muted);font-size:.8rem;line-height:1.45}
.lesson-dup[hidden]{display:none}
.lesson-dup .btn-link{padding:5px 10px;font-size:.75rem;flex-shrink:0}
.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0}
.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)}
.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)}
.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lesson-version-label{font-size:.78rem;font-weight:600}
.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto}
.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px}
.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
.lesson-version-author{font-size:.74rem;color:var(--muted)}
.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem}
.lesson-item .ai-badge{margin-left:0}
@media(max-width:560px){
.lesson-modal .lesson-row{grid-template-columns:1fr}
}
.lesson-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:8px}
.lesson-item-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.lesson-group{font-weight:600;font-size:.95rem}
.lesson-when{color:var(--muted);font-size:.78rem;display:inline-flex;align-items:center;gap:5px}
.lesson-when i{width:13px;height:13px}
.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)}
.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669}
.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55}
.lesson-author{font-size:.75rem;color:var(--muted)}
.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
.lesson-item .btn-link{padding:6px 12px;font-size:.78rem}
.lesson-item .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)}
.lesson-item .btn-link.danger:hover{border-color:#ef4444}
.lesson-list{display:flex;flex-direction:column;gap:10px}
.lesson-dash-item{display:flex;flex-direction:column;gap:3px;padding:8px 6px;border-radius:10px;transition:background .15s;cursor:pointer}
.lesson-dash-item:hover{background:var(--bg)}
.lesson-dash-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lesson-dash-group{font-weight:600;font-size:.86rem}
.lesson-dash-when{color:var(--muted);font-size:.74rem;margin-left:auto;white-space:nowrap}
.lesson-dash-text{font-size:.8rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.45}
+719 -9
View File
@@ -49,13 +49,17 @@ function buildSidebar(active) {
const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' },
{ page: 'photos', label: 'Фото', icon: 'Image' },
{ page: 'files', label: 'Файлы', icon: 'folder' },
{ page: 'links', label: 'Ссылки', icon: 'link' },
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
@@ -63,23 +67,328 @@ function buildSidebar(active) {
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
{ page: 'settings', label: 'Настройки', icon: 'settings' },
];
const nav = [...base, ...(isAdmin() ? adminNav : [])];
const userName = currentUser?.name || currentUser?.username || '';
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
const renderItem = n => {
const isAct = n.page === active;
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
<i data-lucide="${n.icon}"></i>
<span class="nav-text">${n.label}</span>
${badgeHtml}
</a>`;
};
el.innerHTML = `
<div class="logo">Админ-панель</div>
<div class="logo">
<span class="logo-ic" id="sidebarLogo"></span>
<span id="sidebarName">Админ-панель</span>
</div>
<div class="user-box">
<div class="user-name">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div>
<div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
<div class="user-meta">
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div>
</div>
</div>
<div class="nav-scroll-wrap">
<nav>
<div class="nav-group">
<div class="nav-group-title">Основное</div>
<div class="nav-items">
${base.map(renderItem).join('')}
</div>
</div>
${isAdmin() ? `
<div class="nav-group">
<div class="nav-group-title">Администрирование</div>
<div class="nav-items">
${adminNav.map(renderItem).join('')}
</div>
</div>` : ''}
</nav>
</div>
<nav>
${nav.map(n => `<a href="${n.page}.html" data-nav="${n.page}" class="${n.page === active ? 'active' : ''}"><i data-lucide="${n.icon}"></i>${n.label}</a>`).join('')}
</nav>
<div class="bottom">
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
<div class="bottom-actions">
<a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
<i data-lucide="bell"></i>
<span>Уведомления</span>
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
</a>
<a href="/" data-nav="home" title="Перейти на главную страницу">
<i data-lucide="home"></i>
<span>На главную</span>
</a>
</div>
<div class="bottom-footer">
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
<i data-lucide="log-out"></i>
<span>Выйти</span>
</a>
<span class="version" id="sidebarVersion"></span>
</div>
</div>`;
renderIcons();
loadVersion();
loadBrand();
initNotifications();
}
async function loadBrand() {
const logoEl = document.getElementById('sidebarLogo');
const nameEl = document.getElementById('sidebarName');
if (!logoEl || !nameEl) return;
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
if ((s.system_name || '').trim()) nameEl.textContent = s.system_name;
if (s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logoEl.classList.add('has-logo');
logoEl.appendChild(img);
}
} catch {}
}
async function loadVersion() {
const el = document.getElementById('sidebarVersion');
if (!el) return;
try {
const res = await fetch(`${API}/version.json?t=${Date.now()}`);
if (!res.ok) { el.textContent = ''; return; }
const v = await res.json();
if (!v.short) { el.textContent = ''; return; }
const d = (v.date || '').slice(0, 10);
el.textContent = `v${v.short}${d ? ' · ' + d : ''}`;
el.title = `${v.short} · ${v.date || ''}`;
} catch { el.textContent = ''; }
}
// --- Notifications ---
const NOTIFY_ICONS = {
'entry.new': 'book-open',
'entry.ai.corrected': 'sparkles',
'entry.ai.error': 'bot',
'photo.job.done': 'image',
'photo.job.error': 'image-off',
'ip.ban': 'shield-off',
'backup.restore': 'database',
'backup.create': 'download',
'system.test': 'send',
'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
let notifyList = [];
let notifyUnread = 0;
let notifyStream = null;
let notifyReconnectTimer = null;
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '&quot;')); }
function notifyTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function setNotifyUnread(n) {
notifyUnread = Number(n) || 0;
const badge = document.getElementById('notifyBadge');
if (badge) {
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
badge.hidden = notifyUnread <= 0;
}
const navBadge = document.getElementById('sidebarNotifyBadge');
if (navBadge) {
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
navBadge.hidden = notifyUnread <= 0;
}
}
function notifyItemHtml(n, expanded) {
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
<span class="notify-body">
<span class="notify-title">${esc(n.title)}</span>
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
</span>
</a>`;
}
function notifyPanelEl() {
let panel = document.getElementById('notifyPanel');
if (panel) return panel;
panel = document.createElement('div');
panel.className = 'notify-panel';
panel.id = 'notifyPanel';
panel.hidden = true;
document.body.appendChild(panel);
return panel;
}
function renderNotifyPanel() {
const panel = notifyPanelEl();
if (!panel) return;
const items = notifyList.slice(0, 12);
panel.innerHTML = `
<div class="notify-head">
<b>Уведомления</b>
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
</div>
<div class="notify-list">
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
</div>
<div class="notify-foot">
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
</div>`;
renderIcons();
}
async function loadNotifications() {
try {
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
notifyList = data.items || [];
setNotifyUnread(data.unread);
renderNotifyPanel();
} catch {}
}
function toggleNotifyPanel(force) {
const panel = notifyPanelEl();
if (!panel) return;
const open = force === undefined ? panel.hidden : !!force;
panel.hidden = !open;
if (open) { renderNotifyPanel(); loadNotifications(); }
}
async function markNotifyRead(id) {
const item = notifyList.find(n => n.id === id);
if (item && !item.read) {
item.read = true;
setNotifyUnread(Math.max(0, notifyUnread - 1));
renderNotifyPanel();
}
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
}
async function markAllNotifyRead() {
try {
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
if (!res.ok) return;
notifyList.forEach(n => { n.read = true; });
setNotifyUnread(0);
renderNotifyPanel();
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
} catch {}
}
function showDesktopNotification(n) {
if (!desktopNotify) return;
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
try {
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
note.onclick = () => {
window.focus();
if (n.link) location.href = n.link;
note.close();
};
} catch {}
}
async function toggleDesktopNotifications() {
if (desktopNotify) {
desktopNotify = false;
localStorage.setItem('notifyDesktop', '0');
renderNotifyPanel();
return;
}
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
let perm = Notification.permission;
if (perm === 'default') {
try { perm = await Notification.requestPermission(); } catch {}
}
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
desktopNotify = true;
localStorage.setItem('notifyDesktop', '1');
renderNotifyPanel();
showToast('Уведомления в браузере включены');
}
function handleIncomingNotification(n) {
notifyList.unshift(n);
if (notifyList.length > 40) notifyList.length = 40;
setNotifyUnread(notifyUnread + 1);
renderNotifyPanel();
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
showDesktopNotification(n);
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
}
function connectNotifyStream() {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
notifyStream.addEventListener('ready', ev => {
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
});
notifyStream.addEventListener('notification', ev => {
let n = null;
try { n = JSON.parse(ev.data); } catch { return; }
if (!n || !n.id) return;
handleIncomingNotification(n);
});
notifyStream.onerror = () => {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
clearTimeout(notifyReconnectTimer);
notifyReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
}
function initNotifications() {
const bell = document.getElementById('notifyBell');
const panel = notifyPanelEl();
if (!bell || !panel) return;
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
panel.addEventListener('click', e => {
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
const item = e.target.closest('[data-notify-id]');
if (!item) return;
e.preventDefault();
const id = parseInt(item.dataset.notifyId, 10);
const link = item.dataset.notifyLink;
toggleNotifyPanel(false);
markNotifyRead(id);
if (link) location.href = link;
});
document.addEventListener('click', e => {
if (panel.hidden) return;
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
toggleNotifyPanel(false);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
loadNotifications();
connectNotifyStream();
}
// --- Common UI ---
@@ -177,6 +486,407 @@ function copyText(text) {
}
}
let lessonModalGroups = null;
let lessonModalGroupsPromise = null;
function lessonTodayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function lessonNowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
async function loadLessonGroups() {
if (lessonModalGroups) return lessonModalGroups;
if (!lessonModalGroupsPromise) {
lessonModalGroupsPromise = fetch(`${API}/api/groups`, { headers: hdr() })
.then(res => (res.ok ? res.json() : []))
.then(g => { lessonModalGroups = Array.isArray(g) ? g.filter(x => !x.deleted_at) : []; return lessonModalGroups; })
.catch(() => { lessonModalGroups = []; return lessonModalGroups; });
}
return lessonModalGroupsPromise;
}
function ensureLessonModal() {
let modal = document.getElementById('lessonModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3 id="lessonModalTitle">Что прошли на занятии</h3>
<form id="lessonForm" class="settings-stack" style="gap:12px" autocomplete="off">
<input type="hidden" id="lessonId">
<div class="settings-field">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="lessonGroup" class="settings-input" required></select>
</div>
<div class="lesson-row">
<div class="settings-field">
<label>Дата занятия <span style="color:#ef4444">*</span></label>
<input type="date" id="lessonDate" class="settings-input" required>
</div>
<div class="settings-field">
<label>Время</label>
<input type="time" id="lessonTime" class="settings-input">
</div>
</div>
<div class="settings-field">
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
<span class="hint" id="lessonHint">0 / 5000</span>
</div>
<div class="lesson-dup" id="lessonDup" hidden>
<span>За эту группу и дату отчёт уже есть — создать второй нельзя.</span>
<button type="button" class="btn-link" id="lessonDupGo">Перейти к существующему отчёту</button>
</div>
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
<input type="checkbox" id="lessonAiCheck" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
</label>
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
</div>
</form>
</div>`;
document.body.appendChild(modal);
const groupSel = modal.querySelector('#lessonGroup');
const textEl = modal.querySelector('#lessonText');
const hintEl = modal.querySelector('#lessonHint');
const submitBtn = modal.querySelector('#lessonSubmit');
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
const id = modal.querySelector('#lessonId').value;
if (id) openLessonVersions(id);
});
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDate').addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDupGo').addEventListener('click', () => {
const id = modal.querySelector('#lessonDup').dataset.id;
if (!id) return;
closeLessonModal();
openLessonModal({ id });
});
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
return modal;
}
async function submitLessonModal(e) {
e.preventDefault();
const modal = document.getElementById('lessonModal');
if (!modal) return;
const groupSel = modal.querySelector('#lessonGroup');
const dateEl = modal.querySelector('#lessonDate');
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value;
const date = dateEl.value;
const text = textEl.value.trim();
if (!groupId) { showToast('Выберите группу'); groupSel.focus(); return; }
if (!date) { showToast('Укажите дату занятия'); return; }
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
const id = modal.querySelector('#lessonId').value;
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
submitBtn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 409 && data.id) {
showLessonDuplicate(modal, data.id);
showToast('За эту группу и дату отчёт уже есть — новый создать нельзя');
return;
}
showToast(data.error || 'Не удалось сохранить отчёт');
return;
}
closeLessonModal();
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
} else if (data.ai_status === 'error') {
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
} else {
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
}
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
submitBtn.disabled = false;
}
}
let lessonDupSeq = 0;
async function prefetchLessonForGroupDate() {
const modal = document.getElementById('lessonModal');
if (!modal || !modal.classList.contains('open')) return;
if (modal.querySelector('#lessonId').value) return;
const groupId = modal.querySelector('#lessonGroup').value;
const date = modal.querySelector('#lessonDate').value;
if (!groupId || !date) { showLessonDuplicate(modal, null); return; }
const seq = ++lessonDupSeq;
try {
const q = `group_id=${encodeURIComponent(groupId)}&date_from=${encodeURIComponent(date)}&date_to=${encodeURIComponent(date)}&limit=1`;
const res = await fetch(`${API}/api/lesson-reports?${q}`, { headers: hdr() });
if (seq !== lessonDupSeq) return;
if (!res.ok) { showLessonDuplicate(modal, null); return; }
const data = await res.json();
if (seq !== lessonDupSeq) return;
const item = (data.items || [])[0];
showLessonDuplicate(modal, item ? item.id : null);
} catch {
if (seq === lessonDupSeq) showLessonDuplicate(modal, null);
}
}
function showLessonDuplicate(modal, existingId) {
const row = modal.querySelector('#lessonDup');
if (!row) return;
row.dataset.id = existingId ? String(existingId) : '';
row.hidden = !existingId;
}
function fillLessonModalFromReport(modal, r) {
modal.querySelector('#lessonId').value = r.id;
if (r.lesson_time && !modal.querySelector('#lessonTime').value) {
modal.querySelector('#lessonTime').value = String(r.lesson_time).slice(0, 5);
}
if (!modal.querySelector('#lessonText').value) {
const textEl = modal.querySelector('#lessonText');
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
}
function lessonAiStatusText(r) {
if (!r || !r.ai_status || r.ai_status === 'none') return '';
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
return `Статус ИИ: ${r.ai_status}`;
}
function updateLessonAiRow(modal, r) {
const row = modal.querySelector('#lessonAiRow');
const status = modal.querySelector('#lessonAiStatus');
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
if (row) row.hidden = false;
const text = lessonAiStatusText(r);
if (status) {
status.textContent = text;
status.hidden = !text;
status.classList.toggle('err', !!r && r.ai_status === 'error');
}
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
}
function resetLessonModalFields(modal) {
lessonDupSeq++;
modal.querySelector('#lessonId').value = '';
showLessonDuplicate(modal, null);
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = true;
updateLessonAiRow(modal, null);
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
}
const LESSON_VERSION_LABELS = {
manual: 'Тьютор',
ai: 'ИИ по шаблону',
restore: 'Восстановлено'
};
let currentLessonVersionId = '';
function ensureLessonVersionsModal() {
let modal = document.getElementById('lessonVersionsModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonVersionsModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3>История версий отчёта</h3>
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
return modal;
}
function closeLessonVersions() {
const modal = document.getElementById('lessonVersionsModal');
if (modal) modal.classList.remove('open');
}
function lessonVersionItemHtml(v, index) {
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
const who = v.author_name || v.author_username || '';
return `
<div class="lesson-version${index === 0 ? ' current' : ''}">
<div class="lesson-version-head">
<span class="lesson-version-label">${esc(label)}</span>
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
</div>
<div class="lesson-version-text">${esc(v.text || '')}</div>
<div class="lesson-version-foot">
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
</div>
</div>`;
}
async function openLessonVersions(id) {
currentLessonVersionId = String(id || '');
const modal = ensureLessonVersionsModal();
const list = modal.querySelector('#lessonVersionsList');
list.innerHTML = '<div class="empty">Загрузка…</div>';
modal.classList.add('open');
renderIcons();
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
const items = data.items || [];
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
list.innerHTML = items.map(lessonVersionItemHtml).join('');
renderIcons();
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function restoreLessonVersion(id, versionId, btn) {
if (!confirm('Восстановить эту версию отчёта?')) return;
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
closeLessonVersions();
showToast('Версия отчёта восстановлена');
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
if (btn) btn.disabled = false;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('[data-restore]');
if (!btn) return;
e.preventDefault();
let id = btn.dataset.report || currentLessonVersionId;
if (!id) {
const lessonModal = document.getElementById('lessonModal');
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
}
if (!id) return;
restoreLessonVersion(id, btn.dataset.restore, btn);
});
async function openLessonModal(opts = {}) {
const modal = ensureLessonModal();
const groupSel = modal.querySelector('#lessonGroup');
const previous = groupSel.value;
const groups = await loadLessonGroups();
groupSel.innerHTML = '';
if (!groups.length) {
const o = document.createElement('option');
o.value = '';
o.textContent = 'Нет доступных групп';
groupSel.appendChild(o);
} else {
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id;
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
groupSel.appendChild(o);
});
}
resetLessonModalFields(modal);
const wanted = opts.groupId ? String(opts.groupId) : previous;
if (wanted && groups.some(g => String(g.id) === wanted)) groupSel.value = wanted;
if (opts.text) {
const textEl = modal.querySelector('#lessonText');
textEl.value = opts.text;
textEl.dispatchEvent(new Event('input'));
}
modal.classList.add('open');
if (opts.id) {
try {
const res = await fetch(`${API}/api/lesson-reports/${opts.id}`, { headers: hdr() });
if (res.ok) {
const r = await res.json();
groupSel.value = String(r.group_id);
modal.querySelector('#lessonDate').value = String(r.lesson_date).slice(0, 10);
modal.querySelector('#lessonTime').value = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
fillLessonModalFromReport(modal, r);
}
} catch {}
} else {
await prefetchLessonForGroupDate();
}
renderIcons();
setTimeout(() => modal.querySelector('#lessonText').focus(), 50);
}
function closeLessonModal() {
const modal = document.getElementById('lessonModal');
if (modal) modal.classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('lessonModal');
if (modal && modal.classList.contains('open')) closeLessonModal();
});
function renderPager(el, page, totalPages, onPage) {
if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; }
+8 -3
View File
@@ -16,6 +16,9 @@
</div>
<div class="actions-row">
<select id="auditActionFilter" class="settings-input" style="max-width:280px">
<option value="">Все действия</option>
</select>
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
<span class="hint" id="auditCount"></span>
@@ -26,6 +29,7 @@
<thead>
<tr>
<th>Время</th>
<th>Кто</th>
<th>Действие</th>
<th>Детали</th>
<th>IP</th>
@@ -39,11 +43,12 @@
</div>
</div>
<div class="modal-overlay" id="auditDetailsModal">
<div class="edit-modal" style="max-width:640px">
<div class="edit-modal" style="max-width:820px">
<h3 id="auditDetailsTitle">Детали действия</h3>
<div class="audit-details-body"><pre id="auditDetailsBody"></pre></div>
<div class="audit-details-head" id="auditDetailsHead"></div>
<div class="audit-details-body" id="auditDetailsBody"></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="cancel" id="auditDetailsCloseBtn">Закрыть</button>
<button type="button" class="btn-primary ghost" id="auditDetailsCloseBtn">Закрыть</button>
</div>
</div>
</div>
+7
View File
@@ -28,6 +28,11 @@
<div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic"><i data-lucide="notebook-pen"></i></span> Что прошли на занятиях</h3>
<div class="recent" id="lessonList"></div>
<div class="dash-link-row"><a href="lessons.html">Все отчёты →</a></div>
</div>
<div class="dash-card">
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div>
@@ -40,7 +45,9 @@
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
<div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
<button type="button" class="action-btn" id="lessonReportBtn" style="border:1px solid var(--border);cursor:pointer;font-family:inherit"><span class="aic"><i data-lucide="notebook-pen"></i></span>Что прошли на занятии</button>
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
<a class="action-btn" href="links.html"><span class="aic"><i data-lucide="link"></i></span>Ссылки</a>
<a class="action-btn" href="trash.html"><span class="aic"><i data-lucide="trash-2"></i></span>Корзина</a>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+65
View File
@@ -14,6 +14,7 @@
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<select id="newGroupTutor" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:160px"><option value="">— не назначен —</option></select>
<button type="button" id="addGroupBtn">Добавить</button>
</div>
<div class="list" id="groupList"></div>
@@ -70,6 +71,70 @@
</div>
<div class="toast" id="toast"></div>
<div class="modal-overlay" id="groupFilesModal">
<div class="edit-modal" style="max-width:440px">
<h3 id="groupFilesTitle">Архив файлов группы</h3>
<form id="groupFilesForm" class="settings-stack" style="gap:14px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="groupFilesGroup" class="settings-input" required></select>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата с</label>
<input type="date" id="groupFilesFrom" class="settings-input">
</div>
<div class="settings-field" style="flex:1">
<label>Дата по</label>
<input type="date" id="groupFilesTo" class="settings-input">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div class="hint" style="font-size:.78rem;color:var(--muted);margin:0">Файлы всех учеников группы за период. По умолчанию — с самого начала и по сегодня.</div>
</div>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludeWorks" checked>
Работы (файлы проектов)
</label>
</div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="groupFilesIncludePhotos">
Фото записи
</label>
</div>
<div class="settings-field full">
<label>Какие фото выгружать</label>
<div style="display:flex;flex-direction:column;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesMainPhoto" checked>
Главное фото каждой записи
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesGallery">
Вся галерея записи
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:400">
<input type="checkbox" id="groupFilesOriginals">
Оригиналы фото (до обработки)
</label>
</div>
<span class="hint" style="font-size:.78rem">Главное фото — текущая версия снимка записи. Галерея — все фото записи. Оригиналы — версии до обработки, они попадают в подпапку «Оригиналы», если обработка уже применялась.</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="closeGroupFilesBtn">Отмена</button>
<button type="submit" class="btn-primary" id="downloadGroupFilesBtn">Скачать ZIP</button>
</div>
</form>
</div>
</div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
+41 -7
View File
@@ -9,7 +9,10 @@
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding:24px 16px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
.sub{color:var(--muted);font-size:.85rem;text-align:center}
.brand-head{display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:32px}
.brand-logo{height:76px;width:auto;max-width:min(46vw,340px);object-fit:contain;flex-shrink:0}
.brand-text{display:flex;flex-direction:column;align-items:flex-start}
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
@@ -36,9 +39,14 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
.files-list .file-row button:hover{color:#ef4444}
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
.files-card .paste-hint{text-align:left}
@media(hover:none){.paste-hint{display:none}}
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s}
.preview.has-photo{border-style:solid}
.preview:hover,.preview:focus-visible{border-color:var(--accent)}
.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35}
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
.remove-photo.show{display:block}
@@ -63,6 +71,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
.toast.show{opacity:1}
.toast.error{background:#dc2626}
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
nav a:hover{color:var(--text)}
@@ -83,17 +97,23 @@ nav a:hover{color:var(--text)}
<nav>
<a href="admin.html">Админка</a>
</nav>
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
<div class="brand-head">
<img class="brand-logo" id="brandLogo" alt="" hidden>
<div class="brand-text">
<h1 id="title">Что мы узнали на занятии</h1>
<p class="sub">Отметь, что ты делал(а) на занятии</p>
</div>
</div>
<form class="form" id="form">
<div class="photo-col">
<div class="photo-card">
<div class="preview-wrap">
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
</div>
<div class="photo-btns">
<div class="photo-btns" id="camBtns">
<button type="button" class="cam" id="openCamBtn">Камера</button>
</div>
</div>
@@ -104,6 +124,7 @@ nav a:hover{color:var(--text)}
</div>
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
<input type="file" id="filesInput" multiple hidden>
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
<div class="files-list" id="filesList"></div>
</div>
</div>
@@ -121,9 +142,15 @@ nav a:hover{color:var(--text)}
</select>
</div>
<div class="field" id="moduleField" style="display:none">
<label>Тема модуля</label>
<input type="text" id="moduleInput" list="moduleInputList" placeholder="Начни вводить название темы..." autocomplete="off">
<datalist id="moduleInputList"></datalist>
</div>
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
<textarea id="descInput" maxlength="300" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
</div>
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
@@ -132,6 +159,13 @@ nav a:hover{color:var(--text)}
</div>
</form>
<div class="sent-panel" id="sentPanel">
<div class="sent-check">✓</div>
<h2 id="sentTitle">Запись отправлена!</h2>
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
<div class="sent-timer" id="sentTimer"></div>
</div>
<footer class="footer">
<div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div>
+102
View File
@@ -5,6 +5,36 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Журнал — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
<link rel="stylesheet" href="admin.css">
<style>
.enhance-modal { max-width: 1100px; width: 94vw; }
.enhance-body { display: flex; gap: 16px; align-items: stretch; margin-top: 4px; }
.enhance-compare { position: relative; flex: 1 1 auto; min-width: 0; border-radius: 10px; overflow: hidden; background: var(--bg); border: 1px solid var(--border); user-select: none; touch-action: none; cursor: ew-resize; }
.enhance-compare.zoomed { cursor: grab; }
.enhance-compare.panning { cursor: grabbing; }
.enhance-stage { transform-origin: 0 0; will-change: transform; }
.enhance-stage > img, .enhance-stage > canvas { display: block; width: 100%; height: 100%; }
.enhance-stage > img { height: auto; }
.enhance-top { position: absolute; inset: 0; overflow: hidden; }
.enhance-top .enhance-stage { position: absolute; inset: 0; }
.enhance-divider { position: absolute; top: 0; bottom: 0; left: 50%; width: 32px; transform: translateX(-50%); pointer-events: auto; cursor: ew-resize; }
.enhance-divider::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; transform: translateX(-50%); background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.6); pointer-events: none; }
.enhance-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #333; display: flex; align-items: center; justify-content: center; font-size: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.4); pointer-events: none; }
.enhance-badge { position: absolute; top: 10px; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: .72rem; pointer-events: none; }
.enhance-badge.left { left: 10px; }
.enhance-badge.right { right: 10px; }
.enhance-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: .85rem; background: var(--bg); }
.enhance-sliders { flex: 0 0 240px; display: flex; flex-direction: column; gap: 14px; }
.enhance-sliders .settings-field input[type=range] { width: 100%; }
.enhance-foot { justify-content: flex-end; gap: 8px; margin-top: 14px; }
.enhance-ai-mode { width: auto; min-width: 200px; padding: 9px 12px; font-size: .85rem; }
.enhance-ai-hint { font-size: .75rem; color: var(--muted); max-width: 340px; }
.photo-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); display: block; }
@media (max-width: 800px) {
.enhance-body { flex-direction: column; }
.enhance-sliders { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
</style>
</head>
<body data-page="journal">
<div class="layout">
@@ -15,10 +45,12 @@
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
<div><label>Модуль</label><input type="text" id="moduleFilter" list="moduleFilterList" placeholder="Все модули" autocomplete="off"><datalist id="moduleFilterList"></datalist></div>
<div><label>С</label><input type="date" id="dateFrom"></div>
<div><label>По</label><input type="date" id="dateTo"></div>
<div><label>&nbsp;</label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
<div><label>&nbsp;</label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
<div><label>&nbsp;</label><button type="button" id="lessonReportBtn" style="background:var(--card);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 16px;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap">Что прошли на занятии</button></div>
<div><label>&nbsp;</label>
<div class="view-toggle" id="viewToggle">
<button data-view="list" type="button">Список</button>
@@ -35,6 +67,17 @@
<img id="imgModalSrc">
</div>
<div class="modal-overlay" id="videoModal">
<div class="video-modal">
<div class="video-head">
<span id="videoTitle"></span>
<a class="video-dl" id="videoDownload" download title="Скачать файл">Скачать</a>
<button type="button" class="video-close" id="videoClose" title="Закрыть">&times;</button>
</div>
<video id="videoPlayer" controls playsinline preload="metadata"></video>
</div>
</div>
<div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3>
@@ -48,6 +91,11 @@
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="editGroup" class="settings-input" required></select>
</div>
<div class="settings-field">
<label>Тема модуля</label>
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
<datalist id="editModuleList"></datalist>
</div>
<div class="settings-field full">
<label>Что делала(а) <span style="color:#ef4444">*</span>
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
@@ -161,6 +209,60 @@
</div>
</div>
<div class="modal-overlay" id="enhanceModal">
<div class="edit-modal enhance-modal">
<h3>Улучшение фотографии</h3>
<div class="enhance-body">
<div class="enhance-compare" id="enhanceCompare">
<div class="enhance-stage" id="enhanceStage">
<img id="enhanceOrig" alt="Оригинал">
</div>
<div class="enhance-top" id="enhanceTop" style="clip-path:inset(0 0 0 50%)">
<div class="enhance-stage" id="enhanceInner">
<canvas id="enhanceCanvas"></canvas>
</div>
</div>
<div class="enhance-divider" id="enhanceDivider"><div class="enhance-handle">⇔</div></div>
<div class="enhance-badge left">Оригинал</div>
<div class="enhance-badge right">Результат</div>
<div class="enhance-loading" id="enhanceLoading">Загрузка...</div>
</div>
<div class="enhance-sliders">
<div class="settings-field"><label>Яркость <span id="enhanceBrightnessVal">100%</span></label><input type="range" id="enhanceBrightness" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Контраст <span id="enhanceContrastVal">100%</span></label><input type="range" id="enhanceContrast" min="50" max="150" value="100"></div>
<div class="settings-field"><label>Насыщенность <span id="enhanceSaturateVal">100%</span></label><input type="range" id="enhanceSaturate" min="0" max="200" value="100"></div>
<div class="settings-field"><label>Шумодав <span id="enhanceDenoiseVal">0%</span></label><input type="range" id="enhanceDenoise" min="0" max="100" value="0"></div>
<div class="settings-field"><label>Резкость <span id="enhanceSharpVal">0%</span></label><input type="range" id="enhanceSharp" min="0" max="100" value="0"></div>
</div>
</div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="enhanceHistoryBtn" title="История изменений фотографии">🕘 История</button>
<button type="button" class="btn-primary ghost" id="enhanceRestoreBtn" style="display:none" title="Вернуть исходную фотографию">↩ Вернуть оригинал</button>
<select id="enhanceAiMode" class="settings-input enhance-ai-mode" style="display:none" title="Режим ИИ-обработки фото">
<option value="x2">Универсально (x2)</option>
<option value="x4">Быстро (x4)</option>
<option value="faces">Лица (GFPGAN)</option>
<option value="facesbg">Лица + фон</option>
</select>
<span class="enhance-ai-hint" id="enhanceAiHint" style="display:none"></span>
<button type="button" class="btn-primary ghost" id="enhanceAiBtn" style="display:none" title="ИИ-восстановление (Real-ESRGAN)">🤖 ИИ</button>
<button type="button" class="btn-primary ghost" id="enhanceAutoBtn" title="Подобрать настройки автоматически">✨ Авто</button>
<button type="button" class="btn-primary ghost" id="enhanceCancelBtn">Оставить как есть</button>
<button type="button" class="btn-primary" id="enhanceSaveBtn">Применить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoHistoryModal">
<div class="edit-modal enhance-modal">
<h3 id="photoHistoryTitle">История изменений фотографии</h3>
<div id="photoHistoryList" style="display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto"></div>
<div class="card-foot enhance-foot">
<button type="button" class="btn-primary ghost" id="photoHistoryCloseBtn">Закрыть</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
+178 -10
View File
@@ -9,6 +9,11 @@ const ACTION_LABELS = {
'group.create': 'Создана группа',
'group.update': 'Изменена группа',
'group.delete': 'Удалена группа',
'group.soft-delete': 'Группа в корзину',
'group.restore': 'Группа восстановлена',
'group.permanent-delete': 'Группа удалена безвозвратно',
'group.schedule-delete': 'Группа помечена на удаление',
'group.unschedule': 'Удаление группы отменено',
'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы',
@@ -21,13 +26,43 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу',
'student.profile.update': 'Изменён профиль ученика',
'student.photo.create': 'Загружено фото ученика',
'student.photo.delete': 'Удалено фото ученика',
'student.photo.main': 'Главное фото ученика',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'module.photo.create': 'Загружено фото модуля',
'module.photo.delete': 'Удалено фото модуля',
'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи',
'entry.photo.delete': 'Удалено фото записи',
'entry.photo.enhance': 'Фото улучшено',
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
'entry.photo.apply': 'Применён результат обработки фото',
'entry.photo.reject': 'Отклонён результат обработки фото',
'entry.photo.rollback': 'Фото отката к версии',
'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото',
'photo.job.soft_retry': 'ИИ-сервис недоступен, ожидание',
'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'notifications.delete': 'Удалено уведомление',
'notifications.clear': 'Очищены уведомления',
'settings.logo.upload': 'Загружен логотип системы',
'settings.logo.remove': 'Удалён логотип системы',
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
'entry.schedule-delete': 'Запись помечена на удаление',
'entry.unschedule': 'Удаление записи отменено',
'trash.clear': 'Корзина помечена на удаление',
'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка',
@@ -41,6 +76,19 @@ const ACTION_LABELS = {
'ai.wake': 'Воркер ИИ разбужен вручную',
'ai.enabled': 'Переключена автопроверка ИИ',
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
'ai.profile.create': 'Создан профиль ИИ',
'ai.profile.update': 'Изменён профиль ИИ',
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
'export.group_files': 'Экспорт файлов группы (ZIP)',
'lesson_report.create': 'Создан отчёт о занятии',
'lesson_report.update': 'Изменён отчёт о занятии',
'lesson_report.delete': 'Удалён отчёт о занятии',
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
'lesson_report.version.restore': 'Восстановлена версия отчёта',
};
const AUDIT_PAGE_SIZE = 50;
@@ -48,10 +96,30 @@ let auditPage = 1;
let auditTotal = 0;
let auditRows = [];
const EDIT_SOURCE_LABELS = {
manual: 'Вручную',
ai: 'ИИ',
ai_manual: 'ИИ + правка вручную',
ai_revert: 'Откат к оригиналу (ИИ)'
};
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
function fmtTime(t) {
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
}
function parseTarget(t) {
if (t === null || t === undefined) return null;
if (typeof t === 'object') return t;
try { return JSON.parse(t); } catch { return null; }
}
function dt(t) {
if (!t) return '';
if (typeof t === 'object') {
@@ -71,7 +139,77 @@ function fullTarget(t) {
} catch { return String(t); }
}
function renderPager(container, page, totalPages, onPageChange) {
function changeLabel(c) {
return c.label || FIELD_LABELS[c.field] || c.field;
}
function changeSummary(c) {
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
if (c.field === 'description') return 'текст работы';
return changeLabel(c).toLowerCase();
}
function targetSummary(t) {
const o = parseTarget(t);
if (!o) return dt(t);
if (Array.isArray(o.changes)) {
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
const parts = o.changes.map(changeSummary);
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
return esc(parts.join(', ') + src);
}
return dt(t);
}
function renderDiffSegments(segments) {
return (segments || []).map(s => {
const text = esc(s.text);
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
return `<span>${text}</span>`;
}).join('');
}
function renderChange(c) {
const label = changeLabel(c);
if (c.field !== 'description') {
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
</div>`;
}
const s = c.stats || {};
const parts = [];
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
return `<div class="audit-change">
<div class="audit-change-title">${esc(label)}</div>
<div class="audit-change-stats">${parts.join('')}</div>
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
</div>`;
}
function renderTargetDetails(target) {
const o = parseTarget(target);
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
const head = [];
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
const body = o.changes.length
? o.changes.map(renderChange).join('')
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
return `${head.join('')}<div class="audit-changes">${body}</div>
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
}
function renderAuditPager(container, page, totalPages, onPageChange) {
if (totalPages <= 1) { container.innerHTML = ''; return; }
const pages = [];
const maxVisible = 5;
@@ -91,19 +229,20 @@ function renderPager(container, page, totalPages, onPageChange) {
async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody');
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
const p = new URLSearchParams();
p.set('limit', AUDIT_PAGE_SIZE);
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
const action = document.getElementById('auditActionFilter').value;
if (action) p.set('action', action);
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json();
// Total count for pagination (approximate: if we got less than page size, we're on last page)
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
auditTotal = totalPages;
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = '';
@@ -113,12 +252,13 @@ async function loadAudit() {
el.innerHTML = filtered.map((r, i) => `
<tr>
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
renderAuditPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
}
function goAuditPage(p) {
@@ -126,17 +266,44 @@ function goAuditPage(p) {
loadAudit();
}
function fillActionFilter() {
const sel = document.getElementById('auditActionFilter');
const current = sel.value;
const opts = Object.entries(ACTION_LABELS)
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
.join('');
sel.innerHTML = '<option value="">Все действия</option>' + opts;
sel.value = current;
}
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
document.getElementById('auditTable').addEventListener('click', e => {
document.getElementById('auditTable').addEventListener('click', async e => {
const btn = e.target.closest('.audit-details');
if (!btn) return;
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
if (!r) return;
const modal = document.getElementById('auditDetailsModal');
const headEl = document.getElementById('auditDetailsHead');
const bodyEl = document.getElementById('auditDetailsBody');
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
document.getElementById('auditDetailsModal').classList.add('open');
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
bodyEl.innerHTML = renderTargetDetails(r.target);
modal.classList.add('open');
if (!r.id) return;
bodyEl.classList.add('audit-loading');
try {
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
if (!res.ok) return;
const full = await res.json();
bodyEl.innerHTML = renderTargetDetails(full.target);
} catch (err) {
} finally {
bodyEl.classList.remove('audit-loading');
}
});
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
document.getElementById('auditDetailsModal').classList.remove('open');
@@ -148,6 +315,7 @@ document.getElementById('auditDetailsModal').addEventListener('click', e => {
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
fillActionFilter();
loadAudit();
}
})();
+31
View File
@@ -100,6 +100,24 @@ function renderPhotos(rows) {
</div>`).join('');
}
function renderLessons(rows) {
const el = document.getElementById('lessonList');
if (!el) return;
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Отчётов пока нет</div>'; return; }
el.innerHTML = rows.map(r => {
const date = String(r.lesson_date || '').slice(0, 10).split('-').reverse().join('.');
const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
return `
<div class="lesson-dash-item" data-lesson="${r.id}">
<div class="lesson-dash-head">
<span class="lesson-dash-group">${esc(r.group_name)}</span>
<span class="lesson-dash-when">${esc(date + (time ? ' · ' + time : ''))}</span>
</div>
<div class="lesson-dash-text">${esc(r.text)}</div>
</div>`;
}).join('');
}
async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
@@ -110,11 +128,24 @@ async function loadDashboard() {
renderActive(d.active_groups);
renderTop(d.top_students);
renderPhotos(d.photos);
renderLessons(d.recent_lessons || []);
renderIcons();
}
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
const lessonBtn = document.getElementById('lessonReportBtn');
if (lessonBtn) lessonBtn.addEventListener('click', () => openLessonModal());
function onLessonReportSaved() {
loadDashboard();
}
document.addEventListener('click', e => {
const item = e.target.closest('[data-lesson]');
if (item && !e.target.closest('button')) openLessonModal({ id: item.dataset.lesson });
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
+4
View File
@@ -14,3 +14,7 @@ if (details) {
})();
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
fetch('/api/public-settings').then(r => r.ok ? r.json() : null).then(s => {
if (s && (s.system_name || '').trim()) document.title = `Ошибка — ${s.system_name}`;
}).catch(() => {});
+131 -4
View File
@@ -1,5 +1,6 @@
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let tutorsCache = [];
let allGroups = [];
let currentCover = null;
@@ -8,6 +9,16 @@ async function loadBranchesCache() {
if (res.ok) branchesCache = await res.json();
}
async function loadTutorsCache() {
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (res.ok) tutorsCache = await res.json();
}
function tutorSelectHTML(selectedId) {
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
}
function branchSelectHTML(selectedId) {
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
@@ -15,6 +26,7 @@ function branchSelectHTML(selectedId) {
async function loadGroups() {
await loadBranchesCache();
await loadTutorsCache();
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
allGroups = groups;
@@ -33,6 +45,7 @@ async function loadGroups() {
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
${tutorSelectHTML(g.tutor_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
@@ -52,6 +65,7 @@ async function loadGroups() {
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
@@ -66,13 +80,15 @@ async function saveGroup(id) {
const groupCard = nameInput.closest('.group-card');
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
const branch_id = branchSelect ? branchSelect.value : '';
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
const dow = document.getElementById(`dow-${id}`).value;
const ts = document.getElementById(`ts-${id}`).value;
const te = document.getElementById(`te-${id}`).value;
const res = await fetch(`${API}/api/groups/${id}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
});
if (res.ok) {
loadGroups();
@@ -87,16 +103,20 @@ async function addGroup() {
const name = input.value.trim();
if (!name) return;
await loadBranchesCache();
await loadTutorsCache();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
const tutorSelect = document.getElementById('newGroupTutor');
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
if (res.ok) { input.value = ''; loadGroups(); }
else { const e = await res.json(); alert(e.error); }
}
async function delGroup(id) {
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
loadGroups();
}
@@ -278,6 +298,101 @@ async function exportGroupUrls(groupId, groupName) {
showToast(`Экспортировано: ${list.length}`);
}
function openGroupFilesModal(groupId, groupName) {
const sel = document.getElementById('groupFilesGroup');
sel.innerHTML = allGroups.map(g => `<option value="${g.id}" ${String(g.id) === String(groupId) ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
document.getElementById('groupFilesTitle').textContent = `Архив файлов — ${groupName}`;
document.getElementById('groupFilesFrom').value = '';
document.getElementById('groupFilesTo').value = todayISOLocal();
document.getElementById('groupFilesIncludeWorks').checked = true;
document.getElementById('groupFilesIncludePhotos').checked = false;
document.getElementById('groupFilesMainPhoto').checked = true;
document.getElementById('groupFilesGallery').checked = false;
document.getElementById('groupFilesOriginals').checked = false;
onGroupFilesPhotosToggle();
document.getElementById('groupFilesModal').classList.add('open');
}
function closeGroupFilesModal() {
document.getElementById('groupFilesModal').classList.remove('open');
}
function todayISOLocal() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const GROUP_FILES_PHOTO_BOXES = ['groupFilesMainPhoto', 'groupFilesGallery', 'groupFilesOriginals'];
function onGroupFilesPhotosToggle() {
const on = document.getElementById('groupFilesIncludePhotos').checked;
GROUP_FILES_PHOTO_BOXES.forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = !on;
});
}
function groupFilesPhotosOptions() {
const main = document.getElementById('groupFilesMainPhoto').checked;
const gallery = document.getElementById('groupFilesGallery').checked;
const originals = document.getElementById('groupFilesOriginals').checked;
return {
main,
gallery,
originals,
mode: gallery ? 'all' : 'latest',
any: main || gallery || originals,
};
}
async function downloadGroupFiles() {
const groupId = document.getElementById('groupFilesGroup').value;
const dateFrom = document.getElementById('groupFilesFrom').value;
const dateTo = document.getElementById('groupFilesTo').value;
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
const photoOpts = groupFilesPhotosOptions();
if (!groupId) { alert('Выберите группу'); return; }
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
if (includePhotos && !photoOpts.any) { alert('Выберите, какие фото выгружать: главное фото, галерея или оригиналы'); return; }
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
const btn = document.getElementById('downloadGroupFilesBtn');
btn.disabled = true;
btn.textContent = 'Формируем...';
try {
const q = new URLSearchParams();
if (dateFrom) q.set('date_from', dateFrom);
if (dateTo) q.set('date_to', dateTo);
q.set('include_files', includeWorks ? '1' : '0');
q.set('include_photos', includePhotos ? '1' : '0');
q.set('include_main', photoOpts.main ? '1' : '0');
q.set('photos_mode', photoOpts.mode);
q.set('include_originals', photoOpts.originals ? '1' : '0');
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
if (!res.ok) {
let msg = 'Не удалось сформировать архив';
try { const e = await res.json(); msg = e.error || msg; } catch {}
alert(msg);
return;
}
const blob = await res.blob();
let disp = res.headers.get('Content-Disposition') || '';
let fname = 'group_files.zip';
const star = disp.match(/filename\*=UTF-8''([^;]+)/i);
const plain = disp.match(/filename="?([^";]+)"?/i);
if (star) { try { fname = decodeURIComponent(star[1]); } catch { fname = plain ? plain[1] : fname; } }
else if (plain) fname = plain[1];
downloadBlob(blob, fname);
showToast('Архив сформирован');
closeGroupFilesModal();
} catch (err) {
alert('Ошибка при формировании архива: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Скачать ZIP';
}
}
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
@@ -288,6 +403,10 @@ document.getElementById('photoForm').addEventListener('submit', e => {
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
document.getElementById('groupFilesModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGroupFilesModal(); });
document.getElementById('closeGroupFilesBtn').addEventListener('click', closeGroupFilesModal);
document.getElementById('groupFilesForm').addEventListener('submit', e => { e.preventDefault(); downloadGroupFiles(); });
document.getElementById('groupFilesIncludePhotos').addEventListener('change', onGroupFilesPhotosToggle);
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
@@ -301,6 +420,8 @@ document.getElementById('groupList').addEventListener('click', e => {
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
const eu = e.target.closest('[data-export-urls]');
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
const ef = e.target.closest('[data-export-files]');
if (ef) { openGroupFilesModal(parseInt(ef.dataset.exportFiles, 10), ef.dataset.groupName); return; }
const photoBtn = e.target.closest('.photo-btn');
if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10);
@@ -414,6 +535,12 @@ async function populateNewGroupBranch() {
const sel = document.getElementById('newGroupBranch');
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
}
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
if (tRes.ok) {
const tutors = await tRes.json();
const tSel = document.getElementById('newGroupTutor');
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
}
}
(async () => {

Some files were not shown because too many files have changed in this diff Show More