Update documentation and clarify MVP status
CI / Backend (build + test) (push) Successful in 1m33s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s

- Revised the CLAUDE.md and README.md files to reflect the current MVP status, emphasizing completed features and intentionally omitted elements such as traffic limits and billing.
- Enhanced clarity in the documentation regarding the architecture, tech stack, and user roles.
- Removed the outdated roadmap section and streamlined references to tech stack decisions.
- Updated API design documentation to clarify the absence of versioning in the MVP and the handling of configuration details.
This commit is contained in:
Leonid Pershin
2026-07-02 21:11:59 +03:00
parent 012d08e737
commit ad94c6ef22
12 changed files with 144 additions and 426 deletions
+93 -192
View File
@@ -1,210 +1,111 @@
# Tech Stack — решения и обоснование (ADR-lite)
Формат: **Решение** → короткое обоснование → альтернативы. Отклонения фиксировать здесь же.
# Tech Stack
## 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<T>`) в
итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `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<T>` с валидацией на старте.
### 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<T>` (или 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<T>()`, чтобы схема полностью описывала
и тела запросов, и тела ответов.
### Тесты: xUnit + NSubstitute + Testcontainers
Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для
используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers
(`Testcontainers.PostgreSql` + `WebApplicationFactory<Program>`).
- **Платформа**: .NET 10, ASP.NET Core Web API (Minimal API).
- **Архитектура**: Clean Architecture, 4 проекта — `Domain / Application / Infrastructure / Api`.
- **CQRS**: собственный тонкий диспетчер (`ISender`), без MediatR. `ISender.Send()` резолвит
`ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`:
`ValidationBehavior` (FluentValidation), `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция +
`SaveChangesAsync` на команду). Авторизация проверяется на уровне эндпоинта
(`RequireAuthorization(...)`), более тонкие проверки (владение, активация) — в хендлере.
- **Валидация**: FluentValidation, подключается через `ValidationBehavior` (не для каждой команды —
только там, где есть что проверить помимо типов).
- **Маппинг**: вручную, статический метод `XxxDto.FromDomain(entity)` на самом DTO.
- **ORM**: EF Core 10 + Npgsql. Миграции, `IEntityTypeConfiguration`. Запросы-чтения — проекции в DTO
(`AsNoTracking` + `Select`).
- **БД**: PostgreSQL.
- **Auth**: ASP.NET Core Identity + JWT (access, короткий TTL) + refresh (httpOnly cookie, ротация).
- **RBAC**: динамические роли с квотой (`AppRole.MaxConfigs`). Доступ к инбаундам — по ролям
(`Inbound.AllowedRoles`). Квота на число конфигов — на роли, а не на тариф.
- **Активация пользователей**: `AppUser.IsActivated` + `ActivationRequest` (с комментарием).
Неактивированный не создаёт конфиги; решение принимает админ на сайте или в Telegram — одними и
теми же CQRS-командами.
- **Сидинг из env**: идемпотентный `DbInitializer` на старте — системные роли (`admin`/`user`),
учётка админа и Telegram id админов. Пример — [`.env.example`](../.env.example).
- **Realtime**: SignalR — авто-транспорт (WebSocket→SSE→long-poll), группы/пользователи,
JWT-авторизация хабов.
- **Telegram-бот**: Telegram.Bot, хостится в процессе Api как `BackgroundService` (long polling) и
вызывает те же CQRS-хендлеры, что и REST. Passwordless-вход выпускает те же JWT/refresh, что и веб.
Детали — [telegram-bot.md](telegram-bot.md).
- **Фоновые задачи**: `BackgroundService` + `PeriodicTimer`, без внешних зависимостей.
- **Ошибки**: собственный `Result<T>` вместо исключений для управляемых сценариев; исключения — только
для действительно исключительного.
- **Логирование**: Serilog (`Serilog.AspNetCore`), `UseSerilogRequestLogging()` +
`Enrich.FromLogContext()`. Секреты (пароли, JWT, `BotToken`) в логи не попадают.
- **API-документация**: нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar UI, без Swashbuckle.
`/openapi/v1.json` используется фронтом для `pnpm gen:api` (openapi-typescript). `/scalar` — UI.
- **Тесты**: xUnit + NSubstitute + Testcontainers (юнит-тесты домена/хендлеров, интеграционные — с
реальным PostgreSQL через `Testcontainers.PostgreSql` + `WebApplicationFactory<Program>`).
## 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<T>`) и понятные имена, которых нет в JSON
Schema. `orval` рассматривался как альтернатива (codegen хуков), не использовался.
### Графики: Recharts (установлен, графики не построены)
Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с
цифрами, без графиков. QR-коды конфигов — `qrcode.react` (реально используется).
### i18n: react-i18next, RU + EN ✅ (зафиксировано)
**Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через
ключи (`react-i18next`), не хардкод строк в компонентах.
- **React 19 + Vite + TypeScript** — SPA, без SSR.
- **Данные с сервера**: TanStack Query — кэш, инвалидация, фоновые рефетчи, статусы загрузки/ошибок.
- **Роутинг**: TanStack Router — типобезопасный, интеграция с TanStack Query.
- **UI**: shadcn/ui + Tailwind CSS v4 (компоненты на Radix), тёмная/светлая тема. Иконки —
`lucide-react`.
- **Клиентский стейт**: Zustand — только для авторизации и темы; серверный стейт — в TanStack Query.
- **Формы**: react-hook-form + zod.
- **Realtime**: `@microsoft/signalr` — подписки на события хаба обновляют кэш TanStack Query.
- **Типы API**: `pnpm gen:api` гоняет `openapi-typescript` по `/openapi/v1.json` живого бэкенда →
`shared/api/schema.gen.ts`. Фичи импортируют типы из руками написанного `shared/api/types.ts`
(см. [frontend.md](frontend.md)) — даёт нормальные generic (`PagedList<T>`) и понятные имена.
- **QR-коды**: `qrcode.react`.
- **i18n**: react-i18next, языки RU + EN (RU по умолчанию). Тексты — через ключи, не хардкод строк.
## Инфраструктура
### Упаковка: единый образ приложения + PostgreSQL
По требованию — **один контейнер на всё приложение** (REST + SignalR + Telegram-бот + статика SPA)
и отдельный контейнер БД.
Единый образ приложения (REST + SignalR + Telegram-бот + статика SPA) + отдельный контейнер PostgreSQL.
- **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 (быстрый, экономный по диску).
- Отдельного nginx для статики нет — единый origin упрощает CORS/куки/деплой.
- **TLS — внешний**: HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB) вне compose;
`app` отдаёт HTTP и доверяет `X-Forwarded-*` через `ForwardedHeaders`.
- **Миграции** — применяются автоматически на старте приложения.
- **CI** — GitHub Actions: `dotnet build`/`test`, `pnpm build`/`lint`/`typecheck`. Без деплоя.
- **Пакетный менеджер фронта**: pnpm.
## Принятые решения (по открытым вопросам)
## Ключевые решения по домену и поведению
Все ключевые развилки закрыты:
| Тема | Как сделано |
| -------------------------- | --------------------------------------------------------------------------------- |
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока |
| i18n | RU + EN (react-i18next) |
| Telegram-транспорт | Long polling |
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
| История трафика | Простая таблица PostgreSQL (`TrafficSample`) + TTL-чистка (`TrafficRetentionService`) |
| Логирование | Serilog (Console) |
| Вход | По username (email не используется; SMTP не нужен) |
| Восстановление пароля | Через привязанный Telegram (self-service); без привязки — сброс админом |
| Регистрация | Открытая + гейт активации админом |
| Конфиги в одном инбаунде | Разрешено несколько (ограничение — только общая квота роли) |
| Данные ноды пользователю | Показываем только `DisplayName` + протокол; адрес/хост/порт скрыты |
| Блокировка пользователя | Отключает все его конфиги в 3x-ui (`Disabled`); разблокировка — включает обратно |
| Понижение роли | Грандфазеринг: существующие конфиги живут, новые нельзя до входа в квоту |
| Скоуп Telegram-бота | Read-only по конфигам (создание/отзыв — на сайте); DM-уведомления юзеру |
| Подписка | Агрегированная на юзера (`AppUser.SubscriptionToken`) + по конфигу |
| Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit``limitIp` в 3x-ui; 0 = без лимита) |
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
| Версионирование API | Без версий (`/api` без `v1`) |
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС) |
| Реконсиляция с 3x-ui | `TrafficSyncService` молча пропускает ноду/клиента при недоступности или несовпадении, без активной реконсиляции (см. [architecture.md](architecture.md)) |
| # | Вопрос | Решение |
| - | ------------------------------ | ------------------------------------------------------------------- |
| 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) |
Также реализовано: Identity lockout по неудачным входам; проверка квоты конфигов под
`pg_advisory_xact_lock`; схема `ClientEmail = pnv_{userIdShort}_{rand}`. Явного анти-CSRF токена на
refresh-cookie нет — обоснование в [architecture.md](architecture.md#безопасность) (`SameSite=Strict`
+ `HttpOnly` достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами
**не блокируется** — известный пробел: `DeleteNodeCommandHandler` каскадно удаляет инбаунды ноды без
проверки существующих `VpnConfig`.
### Продуктовые решения (поведение)
| Тема | Решение |
| ------------------------ | ------------------------------------------------------------------------------- |
| Вход | **По 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/админа).
Email/SMTP в проекте не используются (вход по username, восстановление — через Telegram/админа).