Skip to content

Latest commit

 

History

History
146 lines (110 loc) · 17.6 KB

ARCHITECTURE.md

File metadata and controls

146 lines (110 loc) · 17.6 KB

Архитектура

Обзор

frontend/ React + Vite
    ↓ HTTP JSON + JWT
backend/ Express + TypeScript
    ↓ Mongoose
MongoDB

Backend

backend/src/
├── config/        # env и MongoDB connection
├── controllers/   # бизнес-логика
├── middleware/    # auth/admin middleware
├── models/        # Mongoose-модели
├── routes/        # Express routes + Swagger comments
├── services/      # общие бизнес-правила
├── types/         # TypeScript interfaces
├── utils/         # password/JWT helpers
└── index.ts       # Express app

Главные модели:

  • User - профиль, roles, ссылки на заявки.
  • Team - команда: название, капитан, список участников.
  • Game - игра. Поле kind (quest | guess_song) и format (online | offline). Для квеста: даты начала/окончания, приз, депозит, заявки, published, taskOrderMode (linear/random/manual), createdBy и organizers (соорганизаторы). Для «Угадай мелодию»: code (код входа) и blocks (блоки песен); квест-поля при kind==='guess_song' необязательны.
  • Song - песня игры «Угадай мелодию»: название/исполнитель/обложка, startSec/endSec (отрезок), sourceUrl, file, status (pending/downloading/ready/error), note — подсказка ведущему (что ещё засчитывать: оригинал кавера, фильм). Подсказка приходит только в пульт из авторизованного API и в состояние сокета не попадает, поэтому на телефоны и проектор не утекает. Отдельная коллекция (атомарное обновление статуса фоновой загрузки).
  • GameAppl - заявка на квест. Командный квест: подаётся капитаном, хранит ссылку на Team. Одиночный квест (participation: 'solo'): подаётся игроком, без team (teamName = ник). Также хранит индивидуальное время старта (startAt, линейный режим) и ручной порядок заданий (taskOrder, режим manual).
  • Оси игры: participation (solo/team) и auth (required/open). Квест на сервере всегда auth: required. У «Угадай мелодии» оси независимы: team + required — команды Questix по аккаунтам, team + open — ad-hoc столы вечеринки (команда вводится названием на телефоне, аккаунт не нужен). GameTeamProgress.teamId и TeamLog.team опциональны (null для одиночного квеста — прогресс/логи привязаны к userId).
  • Task - задание квеста, ответы, подсказки, лимит времени. Очков нет - победитель определяется по времени.
  • GameTeamProgress - прохождение квеста командой: привязано к заявке (gameApplId) и команде (teamId); каждая попытка ответа в completedTasks хранит submittedBy - кто из участников её отправил; timeAdjustments - штрафы и бонусы организаторов (входят в итоговое время).
  • TeamLog - лог действий команды во время игры: старт, каждый ответ (кто, текст, верный/неверный, время), переходы между заданиями, финиш.

Frontend

frontend/src/
├── components/    # Navbar, PrivateRoute, ErrorBoundary
├── pages/         # экраны приложения
├── services/      # Axios API wrappers
├── store/         # Zustand auth state
├── types/         # frontend-типы
├── utils/         # date helpers
├── App.tsx        # router
└── index.tsx

Роли

roles хранится как массив строк:

["user", "organizer"]

Роли:

  • user - базовая роль: редактирует свой профиль, состоит в команде, может выйти из команды, играет в составе команды.
  • team_captain - выдаётся автоматически при создании команды: управляет составом, подаёт заявки на игры от команды, может передать капитанство участнику. Пользователь может быть капитаном только одной команды.
  • organizer - создаёт игры и модерирует только свои: заявки, задания, логи, публикация результатов. У игры может быть несколько организаторов: создатель добавляет соорганизаторов по никнейму (роль organizer выдаётся им автоматически), и они правят игру наравне с ним.
  • admin - модерирует все игры и назначает роли пользователям (PATCH /users/:id/roles).

Проверка доступа выполняется на backend (adminMiddleware, organizerMiddleware, canModerateGame и общий помощник services/gamePermissions.ts - админ, создатель или соорганизатор игры) и на frontend route guard.

Даты

UI использует datetime-local, то есть вводит дату без timezone. Перед отправкой frontend конвертирует значение в ISO UTC через new Date(value).toISOString().

Состояние квеста:

  • scheduled: текущее время меньше dateofstart;
  • active: текущее время в [dateofstart, dateofend);
  • finished: текущее время больше или равно dateofend.

Backend применяет эти правила через backend/src/services/questState.ts, чтобы контроллеры не дублировали сравнения дат:

  • заявка закрывается после dateofstart;
  • старт игры возможен только для approved-заявки в активном окне;
  • получение задания и отправка ответа закрываются после dateofend;
  • завершённый прогресс можно открыть после dateofend, чтобы показать результат.

Основные Потоки

Создание команды

  1. Пользователь открывает /teams и создаёт команду.
  2. Backend создаёт Team, назначает создателя капитаном и добавляет роль team_captain.
  3. Капитан добавляет участников по никнейму (POST /teams/:teamId/members).
  4. Участник может выйти из команды (POST /teams/:teamId/leave); капитан - только после передачи прав (POST /teams/:teamId/transfer-captain).

Подача заявки

  1. Капитан выбирает предстоящий квест на /games.
  2. Frontend вызывает POST /appls - заявка подаётся от имени команды капитана.
  3. Backend проверяет, что пользователь капитан, квест ещё не начался и команда не подавала заявку-дубликат.
  4. Создаётся GameAppl со статусом pending и ссылкой на Team.

Одобрение заявки

  1. Админ или организатор-создатель игры выбирает квест на /admin.
  2. Меняет статус заявки через PATCH /appls/:id/status.
  3. Все участники команды видят approved-заявку на /my-appls.

Прохождение

  1. Любой участник команды открывает /game/:gameId/play/:gameApplId.
  2. Frontend вызывает POST /progress/start. Если команде назначено индивидуальное время старта (startAt), раньше него старт невозможен.
  3. Backend создаёт GameTeamProgress (привязан к заявке и команде) или возвращает существующий. Порядок заданий зависит от taskOrderMode игры: общий линейный, случайный для каждой команды или заданный организатором вручную.
  4. Frontend получает текущее задание через /current-task.
  5. Ответ отправляет любой участник команды через /submit-answer; в попытке сохраняется submittedBy, действие пишется в TeamLog.
  6. Если любой участник ответил правильно, вся команда переходит на следующее задание.
  7. После последнего задания прогресс становится completed.

Публикация результатов и статистика

  1. Во время игры технический прогресс и логи (GET /games/:id/logs) видят только админ и организаторы игры.
  2. Организатор может корректировать итоговое время команды (POST /progress/:gameApplId/adjust-time): штраф добавляет секунды, бонус убавляет; обязательна причина.
  3. Организатор завершает игру публикацией результатов: POST /games/:id/publish (published: true, необратимо).
  4. После публикации участники видят статистику GET /games/:id/stats на /games/:gameId/results: матрица «команды x шаги» - какое задание команда проходила на шаге, кто отправил правильный ответ, время прохождения и время на задание, итоговое время (с учётом штрафов и бонусов, которые показываются с причинами) и место; сортировка по моменту прохождения и по времени на задание.

«Угадай мелодию» (realtime)

Оффлайн-мультиплеер на Socket.IO поверх того же Express-бэкенда и Mongo:

  • Стейт-машина backend/src/modules/guess-song/services/musicSession.ts (in-memory, по gameId): фазы lobby → intro → playing → ended → buzzed → reveal → standings → blockIntro → … → finished. intro показывает список блоков, standings — промежуточные итоги в перерыве между блоками, blockIntro — анонс следующего блока, ended — отрезок доиграл и ждём решения ведущего. Счёт эфемерный, в Mongo не пишется - хот-путь баззера остаётся быстрым (стейт привязан к процессу: один инстанс backend, при рестарте сессия теряется - осознанный компромисс для оффлайн-сценария). Мета игры (gameName/code) кэшируется в сессии при входе (setMeta), publicState() синхронный - без запроса в БД на каждый broadcast. Реестр сессий чистится: dropSession при удалении игры + периодический свип простаивающих (нет ни одного подключения и тишина >30 мин — фазу не смотрим, иначе сессия, брошенная посреди игры, висела бы вечно). Поле mode (solo/team): группировка баззера, блокировки и счёта по ключу groupId() - команда (team) или сам игрок (solo). В команде очки и блокировка раунда общие на всю команду; publicState отдаёт teams[] (сводка по командам) и buzzed.by (кто именно нажал).
  • Сокеты backend/src/modules/guess-song/sockets/music.ts: оси participation и auth независимы. team + required — команды Questix из БД (сервер находит Team игрока; без команды вход отклоняется). team + open — ad-hoc столы вечеринки: команда задаётся названием с телефона, ключ нормализован по регистру (Стол 1 = стол 1), отображается первое введённое написание. До входа телефон шлёт peek и видит уже созданные команды, не становясь игроком. Сменить команду и имя можно в лобби (повторный join с тем же playerId), в игре — нет: очки и блокировка не должны переезжать. Ведущий располагает admin:kick, admin:remove-team, admin:finish (досрочные итоги) и admin:reset. Экран входит по gameId; команды ведущего (admin:*) проверяют JWT и право модерации. transports: ['websocket'] - без polling-апгрейда.
  • REST backend/src/modules/guess-song/controllers/music.ts (/music/*): CRUD игр/блоков/песен, поиск и загрузка через SpotiFLAC, ручной аплоад, QR/LAN-IP, версия SpotiFLAC, экспорт/импорт игры одним zip, прокси обложек и /music/artwork/:songId — обложка, вшитая в сам аудиофайл (запасной источник, когда внешней нет или площадка без интернета). Аудио раздаётся из /media (Docker-том media_data).
  • SpotiFLAC изолирован за backend/src/modules/guess-song/services/python.ts + backend/tools/ (зафиксированная версия в requirements.txt, обновление кнопкой /music/spotiflac/update). Backend-образ - Debian-slim с python3.
  • Фронт: MusicAdmin (/admin/music, редактор), MusicHost (/admin/music/host/:gameId, пульт ведущего), MusicScreen (/m/screen/:gameId, проектор, Web Audio + эквалайзер, публичный), MusicPlay (/m/play?code=, телефон-баззер, публичный). Аудио держится в useRef вне React-рендера, баззер на onPointerDown. Проектор не прокручивается никогда: содержимое рисуется в натуральную величину и целиком ужимается, если не влезло в окно (FitScreen).
  • Оффлайн-LAN/Docker: QR кодирует PUBLIC_WEB_BASE (origin фронта по LAN-IP хоста), сокет коннектит backend (:5000). HOST_IP/PUBLIC_WEB_BASE задаются в docker-compose.yml.

Безопасность

  • Пароль хешируется bcryptjs на backend.
  • JWT хранит id, username, roles. Роли кэшируются в токене (срок JWT_EXPIRE, по умолчанию 7д): выдача/снятие роли (например соорганизатор) применяется к запросам пользователя только после релогина — осознанный компромисс ради скорости (не ходим в БД за ролями на каждый запрос). Права на конкретную игру при этом проверяются по факту (isGameModerator сверяет id со списком organizers/createdBy в БД).
  • JWT_SECRET обязателен в production — backend падает на старте, если не задан (в dev — небезопасный дефолт). Хранить в .env, не в репозитории.
  • На /auth/login и /auth/signup включён rate-limit (express-rate-limit, 10/IP): в dev отключён, в production лимитирует только публичные адреса — локалхост и приватные сети (игроки в LAN, e2e, docker-gateway) не троттлятся, что соответствует оффлайн-LAN модели приложения.
  • Протухший токен: фронтовый axios-интерсептор на 401 чистит сессию и уводит на /login.
  • Ответы заданий не отправляются в endpoint текущего задания.
  • hashed_pwd не возвращается из user endpoints.