# Domain Model Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах. `AppUser` — часть Identity (в `Infrastructure`); домен ссылается на пользователя по `UserId : Guid`. ## Диаграмма связей ``` AppUser (Identity) [+ IsActivated, TelegramUserId] ├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs) ├─1───*─ VpnConfig │ *─┐ │ ├─1─ Inbound ─*─1─ Node │ │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде) │ └─*─ TrafficSample ├─0..1─* ActivationRequest (запрос активации у админа, с комментарием) ├─1───*─ TelegramLinkToken (короткоживущие токены привязки) └─0..1─* TelegramLoginRequest (passwordless-вход) Plan ─1───*─ VpnConfig (опционально; квота по числу конфигов — на роли, не на Plan) AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) ``` ## Сущности ### Node — VPN-сервер (панель 3x-ui) Подключённая администратором панель 3x-ui. | Поле | Тип | Заметки | | ---------------- | --------------- | ------------------------------------------------------------ | | `Id` | `Guid` | PK | | `Name` | `string` | Отображаемое имя | | `BaseAddress` | `Uri` | Напр. `https://panel.example.com:2053/` | | `Credentials` | `NodeCredentials` (VO) | Логин + **зашифрованный** пароль (`ISecretProtector`) | | `Location` | `string?` | Страна/город/тег для выбора пользователем | | `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` | | `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания | | `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация | | `CreatedAt` | `DateTimeOffset`| | Инварианты: `BaseAddress` абсолютный; при `IsEnabled == false` или `Status == Offline` **новые** конфиги на ноде запрещены, но **существующие не трогаем** (клиенты остаются в 3x-ui). Статус ноды показываем пользователю как индикатор «состояние сервера». ### Inbound — прокси-inbound на ноде Проекция inbound из 3x-ui; определяет протокол и параметры подключения. | Поле | Тип | Заметки | | ----------------- | ------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK (внутренний) | | `NodeId` | `Guid` | FK → Node | | `RemoteInboundId` | `int` | Id inbound в 3x-ui | | `Protocol` | `VpnProtocol` | `Vless` / `Vmess` / `Trojan` / `Shadowsocks` | | `Remark` | `string` | Метка из 3x-ui | | `Port` | `int` | | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | | `AllowedRoles` | `AppRole[]` (M:N) | Роли, которым разрешено создавать конфиги в этом инбаунде | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `MaxClients` | `int?` | Лимит клиентов (null = без лимита) | | `LastSyncAt` | `DateTimeOffset?` | | Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, **роль пользователя входит в `AllowedRoles`**, и при заданном `MaxClients` он не достигнут. Публикация инбаунда админом включает выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`). > **Пользователю показываем только `DisplayName` + протокол.** Адрес/хост ноды, `RemoteInboundId`, > `Port` и прочие детали 3x-ui в пользовательские DTO не попадают (только в админские). ### VpnConfig — конфиг пользователя (клиент в 3x-ui) Центральная сущность. Одна запись = один клиент внутри inbound + его привязка к пользователю. | Поле | Тип | Заметки | | ------------------ | ---------------- | -------------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (владелец) | | `InboundId` | `Guid` | FK → Inbound | | `Label` | `string?` | Пользовательская метка («Мой телефон»); редактируется юзером | | `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) | | `ClientUuid` | `Guid` | UUID клиента (VLESS/VMess) | | `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui | | `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) | | `UsedUpBytes` | `long` | Синхронизируется из 3x-ui | | `UsedDownBytes` | `long` | Синхронизируется из 3x-ui | | `ExpiresAt` | `DateTimeOffset?`| null = бессрочно | | `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`| | `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` | | `LastSyncAt` | `DateTimeOffset?`| | | `CreatedAt` | `DateTimeOffset` | | Инварианты и переходы: - Создаётся в статусе `Active`; поля клиента в 3x-ui и запись в БД создаются атомарно (компенсация при сбое). - **Проверка квоты выполняется в транзакции с блокировкой** (иначе два параллельных создания пробьют лимит). - `Revoke()` → удаляет клиента в 3x-ui, статус `Revoked` (запись остаётся для истории/аудита). - `Rotate()` → перевыпуск: удаляет старого клиента в 3x-ui и создаёт нового (новый UUID/ссылка); квоту **не тратит**. Для случая утечки ссылки. - `Disable()`/`Enable()` → отключение/включение клиента в 3x-ui без удаления (используется при блокировке юзера). - `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui). - Синхронизация: если `Used ≥ TrafficLimit` → `LimitReached` (+ событие); если `now ≥ ExpiresAt` → `Expired`. - **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`). - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже. - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли). ### Plan — тариф (опционально, backlog) Шаблон лимитов трафика/срока для конфига. **Квота на число конфигов — это `AppRole.MaxConfigs`, а не Plan.** Plan остаётся опциональным механизмом для лимитов трафика/срока и в MVP не обязателен. | Поле | Тип | Заметки | | ------------------ | ----------- | ------------------------------ | | `Id` | `Guid` | PK | | `Name` | `string` | | | `TrafficLimit` | `TrafficLimit` (VO) | Байты | | `DurationDays` | `int?` | Срок действия конфига | | `MaxConfigs` | `int` | Сколько конфигов даёт тариф | | `IsActive` | `bool` | | ### TrafficSample — история трафика (для графиков) Точки потребления во времени; пишутся синхронизацией. | Поле | Тип | Заметки | | ------------ | ---------------- | -------------------------- | | `Id` | `long` | PK | | `ConfigId` | `Guid` | FK → VpnConfig | | `Timestamp` | `DateTimeOffset` | | | `UpBytes` | `long` | Накопительно или дельта | | `DownBytes` | `long` | | > **Решение**: обычная таблица PostgreSQL + **TTL** — фоновая чистка записей старше N дней > (`TrafficRetentionService`). TimescaleDB/агрегация — вне MVP. ### ClientApp — каталог приложений для подключения Приложения-клиенты, которые админ рекомендует пользователям. На странице инструкций отображаются **сгруппированными по ОС**; клик открывает ссылку на скачивание. | Поле | Тип | Заметки | | ----------------- | ------------- | --------------------------------------------------- | | `Id` | `Guid` | PK | | `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» | | `DownloadUrl` | `Uri` | Ссылка на скачивание/стор | | `OperatingSystem` | `OsPlatform` | `iOS` / `Android` / `Windows` / `MacOS` / `Linux` | | `Description` | `string?` | Короткая подсказка (опц.) | | `IconUrl` | `string?` | Иконка (опц.) | | `SortOrder` | `int` | Порядок внутри группы ОС | | `IsEnabled` | `bool` | Показывать пользователям | Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. ### AuditLog — журнал действий Аудит значимых действий (прежде всего админских) для расследований и прозрачности. | Поле | Тип | Заметки | | ------------ | ---------------- | -------------------------------------------------------------- | | `Id` | `long` | PK | | `ActorId` | `Guid?` | Кто выполнил (null — система/фон) | | `Action` | `string` | Напр. `UserActivated`, `UserBlocked`, `RoleChanged`, `ConfigRevoked`, `NodeAdded`, `InboundPublished` | | `TargetType` | `string` | Сущность (`User`/`Config`/`Node`/`Inbound`/`Role`) | | `TargetId` | `string` | Идентификатор цели | | `Metadata` | `jsonb` | Доп. детали (старое/новое значение, комментарий) | | `Source` | `AuditSource` | `Web` / `Telegram` / `System` | | `CreatedAt` | `DateTimeOffset` | | Пишется из хендлеров (или обработчиков доменных событий), append-only. ### AppUser — расширения (Identity) `AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный). **Email в системе не используется** — поле не заполняем/не требуем (стандартная колонка Identity остаётся пустой). Помимо стандартных полей Identity: | Поле | Тип | Заметки | | ------------------- | ----------------- | ---------------------------------------------------- | | `IsActivated` | `bool` | По умолчанию `false` при регистрации; активирует админ | | `ActivatedAt` | `DateTimeOffset?` | Когда активирован | | `ActivatedBy` | `Guid?` | Какой админ активировал | | `IsBlocked` | `bool` | Блокировка админом: вход запрещён + все конфиги отключены в 3x-ui | | `SubscriptionToken` | `string` | Секрет для **агрегированной** подписки `/sub/{token}` (все активные конфиги юзера) | | `TelegramUserId` | `long?` | Id пользователя Telegram; **уникальный**; null до привязки | | `TelegramUsername` | `string?` | @username на момент привязки (для отображения) | | `TelegramLinkedAt` | `DateTimeOffset?` | Когда привязан | Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`); неактивированный пользователь не может создавать конфиги; при регистрации выдаётся роль `user`. **Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui); разблокировка включает их обратно. У пользователя ровно одна роль. **Восстановление пароля**: только через привязанный Telegram (passwordless-вход → смена пароля в настройках, либо reset-флоу в боте). Если Telegram не привязан — пароль сбрасывает **админ** (`ResetUserPasswordCommand`). Пока Telegram не привязан, UI **настойчиво напоминает** привязать его (единственный self-service способ восстановления). ### AppRole — роль с квотой (Identity, динамическая) Расширяет `IdentityRole`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту на число конфигов. | Поле | Тип | Заметки | | ------------ | -------- | --------------------------------------------------------------- | | `Id` | `Guid` | PK | | `Name` | `string` | Напр. `admin`, `user`, `vip` | | `MaxConfigs` | `int` | Квота активных конфигов (для `admin` игнорируется — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | Сидируются: `admin` (без лимита) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3). **У пользователя ровно одна роль**; его квота = `MaxConfigs` этой роли (`admin` → без лимита). **Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**, пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем. ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. | Поле | Тип | Заметки | | ------------ | ----------------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | | `Comment` | `string?` | Комментарий заявителя, напр. «я Никита» — чтобы админ понял, кто это | | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedAt` | `DateTimeOffset?` | | | `CreatedAt` | `DateTimeOffset` | | Инварианты: одновременно не более одного `Pending`-запроса на пользователя; `Approved` → `AppUser.IsActivated = true`. Создание запроса и решение шлют realtime/Telegram-уведомления. ### TelegramLinkToken — токен привязки Короткоживущий одноразовый токен для флоу привязки Telegram. | Поле | Тип | Заметки | | ------------ | ----------------- | ---------------------------------------- | | `Id` | `Guid` | PK | | `Token` | `string` | Высокоэнтропийный секрет (в deep-link) | | `UserId` | `Guid` | FK → AppUser (кто привязывает) | | `ExpiresAt` | `DateTimeOffset` | ≈2–5 минут | | `ConsumedAt` | `DateTimeOffset?` | Одноразовый: гасится при использовании | ### TelegramLoginRequest — запрос passwordless-входа Запрос входа на сайт без пароля, подтверждаемый в боте. | Поле | Тип | Заметки | | ------------ | ----------------------- | -------------------------------------------------------- | | `Id` | `Guid` | PK; `nonce` в deep-link | | `Status` | `TelegramLoginStatus` | `Pending` / `Approved` / `Rejected` / `Expired` / `Consumed` | | `UserId` | `Guid?` | Проставляется после подтверждения (по `TelegramUserId`) | | `Context` | `string?` | IP/устройство инициатора — показывается при подтверждении| | `CreatedAt` | `DateTimeOffset` | | | `ExpiresAt` | `DateTimeOffset` | ≈2–5 минут | Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту). После `Consumed`/`Expired` — не переиспользуется. ## Value Objects - **NodeCredentials** — `Username` + `ProtectedPassword` (шифротекст); равенство по значению; пароль не сериализуется наружу. - **TrafficLimit** — байты; помощники `IsUnlimited`, `IsExceededBy(used)`, форматирование в ГБ. - **ConnectionLink** — построенная ThreeXui.Net строка подключения + производные (подписка, QR-payload). ## Enums ```csharp enum VpnProtocol { Vless, Vmess, Trojan, Shadowsocks } enum NodeStatus { Unknown, Online, Offline } enum ConfigStatus { Active, Disabled, Expired, LimitReached, Revoked } enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed } enum ActivationStatus { Pending, Approved, Rejected } enum AuditSource { Web, Telegram, System } enum OsPlatform { iOS, Android, Windows, MacOS, Linux } ``` ## Доменные события | Событие | Когда | Реакция | | ----------------------- | --------------------------------------- | --------------------------------------------------- | | `VpnConfigCreated` | Успешно создан конфиг | Realtime-пуш владельцу; аудит | | `VpnConfigRevoked` | Конфиг отозван | Realtime-пуш; аудит | | `TrafficLimitReached` | `Used ≥ Limit` при синхронизации | (опц.) отключить клиента в 3x-ui; пуш; статус | | `NodeWentOffline` | Health-probe вернул недоступность | Пуш группе `admins`; пометка статуса | | `ActivationRequested` | Пользователь запросил активацию | Пуш `admins` + уведомление админам в Telegram | | `UserActivated` | Админ одобрил активацию | Пуш владельцу + Telegram-DM (если привязан); аудит | | `UserBlocked` / `UserUnblocked` | Админ (раз)блокировал пользователя | Отключить/включить конфиги в 3x-ui; пуш + Telegram-DM; аудит | | `VpnConfigRotated` | Пользователь перевыпустил конфиг | Новый линк владельцу; аудит | События публикуются из сущностей/хендлеров и обрабатываются `IDomainEventHandler` в Application (диспетчеризация — собственным диспетчером после `SaveChanges`); внешние эффекты (SignalR, 3x-ui) — через порты, реализуемые в Infrastructure.