Files
mixmaker/memory_bank/architecture.md
lemintare fa4edc0df5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add player profiles and community settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 21:43:48 +03:00

166 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
## Общий подход
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 140 только для внутренних вычислений. Жёсткие ограничения обеспечивают состав 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 и внешние резервные копии;
- адаптивность от мобильного экрана до большого дисплея.