frontend/ React + Vite
↓ HTTP JSON + JWT
backend/ Express + TypeScript
↓ Mongoose
MongoDB
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/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, чтобы показать результат.
- Пользователь открывает
/teamsи создаёт команду. - Backend создаёт
Team, назначает создателя капитаном и добавляет рольteam_captain. - Капитан добавляет участников по никнейму (
POST /teams/:teamId/members). - Участник может выйти из команды (
POST /teams/:teamId/leave); капитан - только после передачи прав (POST /teams/:teamId/transfer-captain).
- Капитан выбирает предстоящий квест на
/games. - Frontend вызывает
POST /appls- заявка подаётся от имени команды капитана. - Backend проверяет, что пользователь капитан, квест ещё не начался и команда не подавала заявку-дубликат.
- Создаётся
GameApplсо статусомpendingи ссылкой наTeam.
- Админ или организатор-создатель игры выбирает квест на
/admin. - Меняет статус заявки через
PATCH /appls/:id/status. - Все участники команды видят approved-заявку на
/my-appls.
- Любой участник команды открывает
/game/:gameId/play/:gameApplId. - Frontend вызывает
POST /progress/start. Если команде назначено индивидуальное время старта (startAt), раньше него старт невозможен. - Backend создаёт
GameTeamProgress(привязан к заявке и команде) или возвращает существующий. Порядок заданий зависит отtaskOrderModeигры: общий линейный, случайный для каждой команды или заданный организатором вручную. - Frontend получает текущее задание через
/current-task. - Ответ отправляет любой участник команды через
/submit-answer; в попытке сохраняетсяsubmittedBy, действие пишется вTeamLog. - Если любой участник ответил правильно, вся команда переходит на следующее задание.
- После последнего задания прогресс становится
completed.
- Во время игры технический прогресс и логи (
GET /games/:id/logs) видят только админ и организаторы игры. - Организатор может корректировать итоговое время команды (
POST /progress/:gameApplId/adjust-time): штраф добавляет секунды, бонус убавляет; обязательна причина. - Организатор завершает игру публикацией результатов:
POST /games/:id/publish(published: true, необратимо). - После публикации участники видят статистику
GET /games/:id/statsна/games/:gameId/results: матрица «команды x шаги» - какое задание команда проходила на шаге, кто отправил правильный ответ, время прохождения и время на задание, итоговое время (с учётом штрафов и бонусов, которые показываются с причинами) и место; сортировка по моменту прохождения и по времени на задание.
Оффлайн-мультиплеер на 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.