This commit introduces a new feature for global role synchronization in Discord, allowing for periodic reconciliation of managed roles. A new environment variable, `DISCORD_GLOBAL_SYNC_INTERVAL`, has been added to configure the synchronization interval, defaulting to 5 minutes. The `RoleWorker` has been updated to schedule global sync jobs, ensuring that missing managed roles are restored and extra assignments are removed without affecting unrelated server roles. Database schema changes support the new synchronization logic, and tests have been added to validate the functionality of the global reconciliation process.
158 lines
19 KiB
Markdown
158 lines
19 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; ссылки разрешены только на предыдущие колонки, а в последней колонке должен быть один финальный матч. `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`, чтобы роль нельзя было получить через публичный интерфейс.
|
||
|
||
### Календарь и участие
|
||
|
||
События хранятся в 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-соединение.
|
||
|
||
После подтверждения 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 и внешние резервные копии;
|
||
- адаптивность от мобильного экрана до большого дисплея.
|