# Tech Stack — решения и обоснование (ADR-lite) Формат: **Решение** → короткое обоснование → альтернативы. Отклонения фиксировать здесь же. ## Backend ### Платформа: .NET 10 + ASP.NET Core Web API Долгосрочная (LTS-класса) современная платформа, нативная поддержка Minimal API, rate limiting, health checks, DI. `ThreeXui.Net` таргетит `net10.0` — совпадение целевого фреймворка. ### Архитектура: Clean Architecture (4 проекта) `Domain / Application / Infrastructure / Api`. Тестируемость, изоляция домена, заменяемость инфраструктуры. Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) — можно комбинировать: слои + организация Application «по фичам». ### CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано) **Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). `ISender.Send()` резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`. Реализованы три поведения: `ValidationBehavior` (FluentValidation), `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Плюсы: нет лицензий и внешних зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели). **Отличие от исходного плана**: отдельного диспетчера доменных событий (`IDomainEventHandler`) в итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `IRealtimeNotifier`/ `ITelegramNotifier` и запись `AuditLog` прямо в хендлере команды читаются проще, чем публикация события и поиск обработчика где-то ещё (см. [domain-model.md](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)). Также нет отдельного `AuthorizationBehavior` — роль проверяется на уровне эндпоинта (`RequireAuthorization(...)`), а более тонкие проверки (владение, активация) — в самом хендлере. ### Валидация: FluentValidation Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`. Заводится не для каждой команды — только там, где есть что проверить помимo типов (например, у команд без пользовательского ввода валидатора нет). ### Маппинг: вручную, без Mapster В исходном плане был Mapster — на практике для такого числа полей ручной статический метод `XxxDto.FromDomain(entity)` на самом DTO читается не хуже конфига маппера и не добавляет зависимость. `Mapster` в проект так и не попал. ### ORM: EF Core 10 + Npgsql Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql. Запросы-чтения — проекции в DTO (`AsNoTracking` + `Select`). ### БД: PostgreSQL Надёжная, богатая по типам (jsonb, массивы), бесплатная. Для истории трафика в будущем — TimescaleDB-расширение. ### Auth: ASP.NET Core Identity + JWT Identity для пользователей/ролей/хэширования; JWT access (короткий TTL) + refresh (httpOnly cookie, ротация). Альтернатива — внешний OIDC (Keycloak/Auth0); отклонено на этом этапе в пользу полного контроля. ### RBAC: динамические роли с квотой (`AppRole.MaxConfigs`) Роли — стандартный Identity, но `AppRole` расширен `MaxConfigs`. Админ создаёт/назначает роли; доступ к инбаундам — по ролям (`Inbound.AllowedRoles`). Квота на число конфигов — на роли, а не на `Plan`. ### Активация пользователей `AppUser.IsActivated` + `ActivationRequest` (с комментарием). Неактивированный не создаёт конфиги. Решение принимает админ на сайте или в Telegram — одними и теми же CQRS-командами. ### Сидинг из env Идемпотентный `DbInitializer` на старте: системные роли (`admin`/`user`), учётка админа и Telegram id админов — из переменных окружения. Пример — [`.env.example`](../.env.example). Строго типизированные `IOptions` с валидацией на старте. ### Realtime: SignalR Нативно для ASP.NET Core, авто-транспорт (WebSocket→SSE→long-poll), группы/пользователи, JWT-авторизация хабов. ### Telegram-бот: Telegram.Bot (in-process hosted service) Де-факто стандартная C#-библиотека. Бот хостится в процессе Api как `BackgroundService` (условие единого контейнера) и вызывает те же CQRS-хендлеры, что и REST. Транспорт — **long polling** для MVP (не нужен публичный webhook, проще в одиночном контейнере); webhook — опция для прод (с секретным заголовком). Passwordless-вход выпускает те же JWT/refresh, что и веб. Детали — [telegram-bot.md](telegram-bot.md). ### Фоновые задачи: BackgroundService + PeriodicTimer (MVP) Без внешних зависимостей для MVP. При росте (ретраи, расписания, дашборд) — **Hangfire** или **Quartz.NET**. ### Result-модель: собственный `Result` (или ErrorOr) Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного. ### Логирование: Serilog ✅ (зафиксировано) **Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`), настройка через `appsettings`/env, `UseSerilogRequestLogging()` + `Enrich.FromLogContext()`. Синк MVP — Console. Секреты (пароли, JWT, `BotToken`) в логи не попадают. **Не реализовано**: явное обогащение контекста полями `UserId`/`NodeId`/`ConfigId`, сквозной `CorrelationId`, rolling file/Seq/OTel-экспорт — было в исходном плане, осталось в backlog. Сегодня для расследования инцидента доступны только то, что даёт `Enrich.FromLogContext()` + запрос/ответ из request-логирования. ### API-документация: нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar UI `AddOpenApi()`/`MapOpenApi()` — встроенная в ASP.NET Core (.NET 9+) генерация схемы, без Swashbuckle. `/openapi/v1.json` используется фронтом для `pnpm gen:api` (openapi-typescript). `/scalar` — Scalar UI вместо Swagger UI. Каждый эндпоинт аннотирован `.Produces()`, чтобы схема полностью описывала и тела запросов, и тела ответов. ### Тесты: xUnit + NSubstitute + Testcontainers Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers (`Testcontainers.PostgreSql` + `WebApplicationFactory`). ## Frontend ### React 19 + Vite + TypeScript Максимальная экосистема, быстрый dev-сервер и сборка Vite, строгая типизация. SPA (не SSR) — для внутренней панели SSR избыточен и усложняет деплой рядом с C# API. ### Данные с сервера: TanStack Query Кэш, инвалидация, фоновые рефетчи, статусы загрузки/ошибок. Идеально для CRUD-панели. ### Роутинг: TanStack Router Типобезопасный роутинг, интеграция с TanStack Query. Альтернатива — React Router 7. ### UI: shadcn/ui + Tailwind CSS v4 Копируемые в проект, полностью кастомизируемые компоненты (Radix под капотом), современный вид, тёмная тема из коробки. Иконки — `lucide-react`. ### Клиентский стейт: Zustand Лёгкий стор для глобального (авторизация, тема). Серверный стейт — только в TanStack Query. ### Формы: react-hook-form + zod Производительные формы + схемная валидация; те же zod-схемы для типобезопасности API-ответов. ### Realtime: @microsoft/signalr Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query. ### Типы API: openapi-typescript ✅ (зафиксировано) `pnpm gen:api` гоняет `openapi-typescript` по `/openapi/v1.json` живого бэкенда → `shared/api/schema.gen.ts`. На практике фичи импортируют типы из руками написанного `shared/api/types.ts` (см. [frontend.md](frontend.md)) — он логически совпадает со сгенерированной схемой (сверено), но даёт нормальные generic (`PagedList`) и понятные имена, которых нет в JSON Schema. `orval` рассматривался как альтернатива (codegen хуков), не использовался. ### Графики: Recharts (установлен, графики не построены) Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с цифрами, без графиков. QR-коды конфигов — `qrcode.react` (реально используется). ### i18n: react-i18next, RU + EN ✅ (зафиксировано) **Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через ключи (`react-i18next`), не хардкод строк в компонентах. ## Инфраструктура ### Упаковка: единый образ приложения + PostgreSQL По требованию — **один контейнер на всё приложение** (REST + SignalR + Telegram-бот + статика SPA) и отдельный контейнер БД. - **Multi-stage Dockerfile**: (1) `node` собирает фронт → `dist/`; (2) `dotnet sdk` публикует Api и копирует статику в `wwwroot`; (3) `aspnet` runtime запускает Api. Api раздаёт SPA (`UseStaticFiles` + fallback на `index.html`), фронт и бек — один origin. - **docker-compose**: `app` (единый образ) + `db` (PostgreSQL) с томом. - Почему не отдельный nginx: единый origin упрощает CORS/куки/деплой и укладывается в требование «фронт+бек в одном контейнере». - **TLS — внешний** (решение): HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB) вне compose; `app` отдаёт HTTP и доверяет `X-Forwarded-*` через `ForwardedHeaders`. Свой nginx/Caddy не вводим. - **Миграции** — авто на старте приложения (MVP). - **CI** — GitHub Actions, **только сборка/тесты**: `dotnet build`/`test`, `pnpm build`/`lint`/`typecheck`. Публикация образа и деплой — вручную/позже (в MVP не автоматизируем). - **Пакетный менеджер фронта**: pnpm (быстрый, экономный по диску). ## Принятые решения (по открытым вопросам) Все ключевые развилки закрыты: | # | Вопрос | Решение | | - | ------------------------------ | ------------------------------------------------------------------- | | 1 | CQRS-медиатор | **Собственный тонкий диспетчер** (не MediatR) | | 2 | Ролей у пользователя | **Ровно одна роль** (квота = `MaxConfigs` роли) | | 3 | Секреты нод | **ASP.NET Core Data Protection** (шифрование at-rest, key-ring на томе) | | 4 | Тарифы `Plan` в MVP | **Backlog** — в MVP конфиги без лимитов трафика/срока | | 5 | i18n | **RU + EN** с первого дня (react-i18next) | | 6 | Telegram-транспорт | **Long polling** | | 7 | Регистрация через Telegram | **Только привязка** существующего аккаунта (signup из бота — backlog) | | 8 | История трафика `TrafficSample`| **Простая таблица PostgreSQL + TTL** (фоновая чистка старше N дней) | | 9 | Логирование | **Serilog** (Console + rolling file) | ### Продуктовые решения (поведение) | Тема | Решение | | ------------------------ | ------------------------------------------------------------------------------- | | Вход | **По username** (email в системе не используется; SMTP не нужен) | | Восстановление пароля | Через привязанный Telegram (self-service); без привязки — сброс админом | | Побуждение привязать TG | Настойчивый баннер/уведомления в UI, пока Telegram не привязан | | Регистрация | Открытая + гейт активации админом | | Конфиги в одном инбаунде | Разрешено несколько (ограничение — только общая квота роли) | | Данные ноды пользователю | Показываем только `DisplayName` + протокол; адрес/хост/порт скрыты | | Блокировка пользователя | Отключать все его конфиги в 3x-ui (`Disabled`); разблокировка — включить обратно | | Понижение роли | **Грандфазеринг**: существующие конфиги живут, новые нельзя до входа в квоту | | Скоуп Telegram-бота (MVP)| **Read-only** по конфигам (создание/отзыв — на сайте); DM-уведомления юзеру | | Подписка | Агрегированная на юзера (`AppUser.SubscriptionToken`) + по конфигу | | Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды | | Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) | | Лимит устройств | Per-config, задаёт юзер (`DeviceLimit` → `limitIp` в 3x-ui; 0 = без лимита) | | Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») | | Самоудаление аккаунта | Разрешено: отзыв всех активных конфигов в 3x-ui + удаление `AppUser`. `AuditLog` уже хранит только `Guid` без PII — отдельной анонимизации задним числом нет, сам факт удаления в аудит тоже не пишется | | Версионирование API | Без версий в MVP (`/api` без `v1`) | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | | Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage | | Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` | | Реконсиляция с 3x-ui | Не реализована активно — `TrafficSyncService` молча пропускает ноду/клиента при недоступности или несовпадении, без пометки дрейфа (см. [architecture.md](architecture.md)) | Также реализовано: Identity lockout по неудачным входам; проверка квоты под `pg_advisory_xact_lock`; схема `ClientEmail = pnv_{userIdShort}_{rand}`. Явного анти-CSRF токена на refresh-cookie нет (см. [architecture.md](architecture.md#безопасность) — обоснование, почему `SameSite=Strict` + `HttpOnly` достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами **не блокируется** — это известный пробел, не защита: `DeleteNodeCommandHandler` каскадно удаляет инбаунды ноды без проверки существующих `VpnConfig`. Не реализовано (осталось на будущее, не блокирует текущую работу): TTL для истории трафика (сейчас `TrafficRetentionService` работает, но точный порог не вынесен в решение — см. код); прод-синки Serilog (файл/Seq/OTel) и структурное обогащение логов (`UserId`/`CorrelationId`); точные TTL токенов Telegram. Email/SMTP в проекте **не используются** (вход по username, восстановление — через Telegram/админа).