166 lines
21 KiB
Markdown
166 lines
21 KiB
Markdown
# Архитектура
|
||
|
||
## Общий подход
|
||
|
||
Backend строится на Go по принципам Clean Architecture. Зависимости направлены внутрь: инфраструктура и доставка зависят от сценариев приложения, сценарии — от доменной модели.
|
||
|
||
## Backend
|
||
|
||
Предлагаемая структура:
|
||
|
||
```text
|
||
backend/
|
||
cmd/api/ точка входа HTTP-сервера
|
||
internal/
|
||
domain/ сущности, value objects, правила, доменные ошибки
|
||
application/
|
||
usecase/ сценарии приложения
|
||
port/ интерфейсы репозиториев, часов, генераторов, событий
|
||
adapter/
|
||
in/http/ handlers, middleware, DTO
|
||
out/postgres/ реализации репозиториев
|
||
out/realtime/ доставка событий клиентам
|
||
platform/ конфигурация, логирование, подключение инфраструктуры
|
||
migrations/
|
||
```
|
||
|
||
Технологии первой версии:
|
||
|
||
- Go;
|
||
- PostgreSQL;
|
||
- REST API для команд и запросов;
|
||
- WebSocket или Server-Sent Events для синхронизации драфта и сетки;
|
||
- Discord OAuth 2.0 для входа;
|
||
- серверная cookie-сессия с HttpOnly, Secure и SameSite;
|
||
- структурированное логирование;
|
||
- OpenAPI как контракт frontend/backend.
|
||
|
||
## Frontend
|
||
|
||
Предлагаемый стек:
|
||
|
||
- React + TypeScript;
|
||
- Vite;
|
||
- TanStack Router и TanStack Query;
|
||
- собственный типизированный слой локализации RU/EN с сохранением языка в localStorage;
|
||
- Tailwind CSS;
|
||
- shadcn/ui как основа компонентов;
|
||
- Framer Motion для умеренных анимаций;
|
||
- React Hook Form + Zod;
|
||
- dnd-kit для ручной корректировки составов;
|
||
- библиотека турнирной сетки только после проверки мобильного UX; при необходимости — собственный SVG-компонент.
|
||
|
||
Структура frontend группируется по продуктовым возможностям:
|
||
|
||
```text
|
||
frontend/src/
|
||
app/
|
||
pages/
|
||
widgets/
|
||
features/
|
||
entities/
|
||
shared/
|
||
```
|
||
|
||
## Границы модулей
|
||
|
||
- Players: профили, рейтинги, предпочтения ролей, желаемых союзников и avoid-списки;
|
||
- Auth: Discord OAuth, сессии и роли доступа;
|
||
- Staff (Admin/Moderator): управление игроками, регистрациями, событиями и капитанами;
|
||
- Events: календарь, регистрации участников и состояние игрового вечера;
|
||
- Balancing: генерация и оценка составов;
|
||
- Draft: жеребьёвка, карты и герои;
|
||
- Matches: карты, счёт и завершение серии;
|
||
- Tournaments: сетка и продвижение команд;
|
||
- Realtime: подписка клиентов на изменения.
|
||
|
||
## Ключевые решения
|
||
|
||
### Балансировщик
|
||
|
||
В MVP — детерминированный оптимизатор с весовой функцией качества и ограниченным поиском вариантов. Overwatch-ранги Bronze 5 → Champion 1 отображаются в ordinal 1–40 только для внутренних вычислений. Жёсткие ограничения обеспечивают состав 1/2/2 и уникальность игроков. Мягкие штрафы учитывают общий и ролевой разброс рейтинга, назначение вне предпочитаемой роли, разделение желаемых союзников и попадание avoid-пары в одну команду. Avoid имеет больший мягкий вес, чем пожелание играть вместе, но не нарушает требования баланса. После построения нескольких начальных вариантов выполняется локальный поиск обменами игроков одной роли.
|
||
|
||
Алгоритм располагается в доменном слое и не зависит от БД. При росте сообщества его можно заменить на CP-SAT/MILP solver без изменения API сценария.
|
||
|
||
### Оркестрация события и серии
|
||
|
||
Application-слой управляет единым versioned workflow события. Состояние события, выбранные команды, резерв и активная серия сохраняются транзакционно. Команды `close-registration`, `select-balance`, `edit-rosters`, `confirm-rosters` и `start-scrim` проверяют текущее состояние и `expectedVersion`, поэтому повторные клики и конкурирующие вкладки не создают две серии.
|
||
|
||
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
|
||
|
||
После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; API обновления сохраняет структурно корректные промежуточные черновики с незаполненными слотами, а полную проверку команд, матчей и единственного финала выполняет команда подтверждения. Ссылки Winner / Loser всегда разрешены только на предыдущие колонки. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode.
|
||
|
||
### Драфт как конечный автомат
|
||
|
||
Жеребьёвка и баны моделируются состояниями и допустимыми командами. После жеребьёвки победитель получает первый бан для первой карты, а проигравший — первый бан героев. Первая карта определяется map draft. После результата второй и последующих карт FSM переходит в `MapPick`, где проигравшая команда напрямую выбирает карту из следующего пула; затем запускается hero draft. При ничьей право выбора получает проигравший начальную жеребьёвку. Сервер является источником истины и отклоняет действие, если оно нарушает порядок, версию или регламент.
|
||
|
||
### История действий
|
||
|
||
Все действия драфта сохраняются как неизменяемые записи аудита. Текущее состояние можно хранить в обычных таблицах; полноценный Event Sourcing для MVP не требуется.
|
||
|
||
### Авторизация
|
||
|
||
Вход выполняется через Discord OAuth 2.0. После callback backend создаёт собственную серверную сессию и безопасную HttpOnly-cookie; OAuth-токен Discord не передаётся frontend. Discord ID является внешним идентификатором аккаунта.
|
||
|
||
Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс.
|
||
|
||
### Публичные профили и статистика
|
||
|
||
Authenticated `GET /players/{id}` возвращает consumer-safe read model без teammate preferences и avoid-списка: nickname, BattleTag, ранги, агрегат серии/карт и десять последних матчей. Агрегатор находится в domain и не зависит от PostgreSQL; adapter загружает завершённые Event, Series и финальные Team rosters. Corrections сворачиваются до эффективного результата, draw учитывается отдельно. Отдельная таблица статистики не нужна для текущего размера сообщества.
|
||
|
||
Nickname хранится как `Player.DisplayName`, нормализуется и защищён case-insensitive unique index. Discord username остаётся атрибутом Account; player-facing UI использует nickname. BattleTag является необязательным публичным полем.
|
||
|
||
### Календарь и участие
|
||
|
||
События хранятся в UTC и отображаются в локальном часовом поясе пользователя. REST API отдаёт будущие и прошедшие события и статусы Going/Maybe/NotGoing. Изменения регистрации публикуются через realtime-канал, чтобы Admin сразу видел актуальный список. При изменении статуса за другого игрока API сохраняет actor ID и источник `admin`, а UI показывает, кем сделана отметка.
|
||
|
||
### Realtime-синхронизация
|
||
|
||
Один глобальный authenticated SSE-канал подписывается на wildcard topic и адресно инвалидирует TanStack Query-кэш для событий, RSVP, составов, серий, турниров и игроков. Поэтому новые регистрации, участники, жеребьёвка, баны, результаты и сетка появляются без перезагрузки. WebSocket не требуется: команды остаются обычными HTTP mutations, а серверные изменения передаются клиентам однонаправленно через SSE. Nginx отключает buffering и держит соединение открытым.
|
||
|
||
### Discord-уведомления
|
||
|
||
После успешного создания события application-слой вызывает consumer-owned `EventAnnouncer` port. Discord REST adapter публикует в настроенный текстовый канал локализованный RU/EN embed, время начала и ссылку на `/events/{eventId}`. Create-команда передаёт отдельный флаг: по умолчанию сообщение содержит разрешённый `@everyone`, а при отключённой галочке тот же анонс отправляется без mention. Флаг не сохраняется в Event, потому что обновления не создают повторный анонс. Внешний вызов имеет короткий timeout и выполняется только после сохранения события: ошибка Discord логируется, но не откатывает созданный микс и не превращает успешную команду в HTTP 500.
|
||
|
||
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
|
||
|
||
Глобальная Discord invite URL хранится в `community_settings`. Authenticated FAQ читает её через REST; обновление разрешено только Admin и записывается в аудит. Это пользовательская ссылка сообщества и не заменяет секретные Discord bot/guild/channel настройки окружения.
|
||
|
||
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
|
||
|
||
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
|
||
|
||
При старте и затем раз в `DISCORD_GLOBAL_SYNC_INTERVAL` один bucketed `full_reconcile` job получает фактические guild roles и пагинированный список участников. Удалённые вручную managed roles пересоздаются, свойства name/hoist/position восстанавливаются, недостающие назначения добавляются, а лишние назначения только известных Mixmaker role ID снимаются. Посторонние серверные роли не изменяются. Для member inventory требуется включённый Server Members Intent, но постоянное Gateway-соединение не используется.
|
||
|
||
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.
|
||
|
||
Discord hoist включён только для team roles и общей `registered`-роли. Team roles автоматически поднимаются выше `registered`, поэтому после формирования состава игрок отображается в группе команды, а ещё не распределённые участники — в общей группе регистрации. Captain, Tank/Damage/Support и конкретные RSVP status roles остаются служебными и не создают отдельные группы участников.
|
||
|
||
### Капитаны
|
||
|
||
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.
|
||
|
||
### Результаты матчей
|
||
|
||
Исход каждой карты фиксируется отдельной командой use case. Домен серии пересчитывает счёт и победителя, а турнир продвигает победившую команду только после завершения серии. Исправление результата не удаляет историю первоначального действия.
|
||
|
||
### Развёртывание в Coolify
|
||
|
||
Production разворачивается из репозитория как один Docker Compose resource: frontend/reverse proxy, Go API, миграции и PostgreSQL с named volume. Coolify сам создаёт сеть стека и подключает свой proxy, поэтому в Compose не объявляются `networks`, `container_name` и вручную заданные Traefik labels.
|
||
|
||
Frontend-контейнер обслуживает SPA и проксирует `/api` и SSE к сервису `api`, поэтому приложению достаточно одного публичного домена. PostgreSQL и API не публикуют host-порты; сервисы обращаются друг к другу по Compose-именам. Секреты и production-переменные задаются в Coolify UI, а в репозитории хранится только `.env.example`.
|
||
|
||
Для всех долгоживущих сервисов задаются healthcheck и корректный graceful shutdown. Данные PostgreSQL сохраняются в named volume; для production обязательно настраивается регулярный backup тома/БД вне сервера.
|
||
|
||
## Нефункциональные требования
|
||
|
||
- идемпотентность команд, которые могут повториться из-за сети;
|
||
- optimistic UI только там, где возможен безопасный откат;
|
||
- серверная валидация всех правил;
|
||
- миграции схемы БД;
|
||
- unit-тесты доменных правил и балансировки;
|
||
- интеграционные тесты репозиториев и API;
|
||
- production Docker Compose совместим с Coolify без пользовательских сетей;
|
||
- PostgreSQL имеет persistent volume, healthcheck и внешние резервные копии;
|
||
- адаптивность от мобильного экрана до большого дисплея.
|