# Domain Model Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах. `AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser`/ `IdentityRole`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`. Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) — используется и биллингом (см. ниже) для расчёта суммы заявки на оплату. **Биллинг (подписка по сроку) реализован, но опционален и включается per-роль** (`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку). Роль без флага живёт как раньше, без ограничений по сроку. ## Диаграмма связей ``` AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken] ├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs) ├─1───*─ VpnConfig │ └─1─ Inbound ─*─1─ Node │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде) ├─0..1─* ActivationRequest (запрос активации у админа, с комментарием) ├─1───*─ TelegramLinkToken (короткоживущие токены привязки) └─0..1─* TelegramLoginRequest (passwordless-вход) VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService) AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям) AppUser └─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль) └─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование) └─0..*─ TicketAttachment (изображения, диск-хранилище) ``` ## Сущности ### 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` | `string` | Id inbound в 3x-ui (ThreeXui.Net отдаёт его как string, не число) | | `Protocol` | `VpnProtocol` | `Vless` / `Vmess` / `Trojan` / `Shadowsocks` | | `Remark` | `string` | Метка из 3x-ui | | `Port` | `int` | | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | | `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `LastSyncAt` | `DateTimeOffset?` | | Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя входит в `AllowedRoles`**. Публикация инбаунда админом включает выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота ограничивается только на уровне пользователя (`AppRole.MaxConfigs`). **Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»): инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного `VpnConfig` — запись просто удаляется (иначе при пересоздании того же инбаунда на панели под новым `RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`, `IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов > `IXuiPanelGateway.AddClientAsync` в момент создания — с честной ошибкой и компенсацией > зарезервированной квоты, а не заранее закэшированным статусом. > **Пользователю показываем только `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}` (уникален в рамках панели, виден владелец) | | `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) | | `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) | | `UsedDownBytes` | `long` | Синхронизируется из 3x-ui | | `ExpiresAt` | `DateTimeOffset?`| Для billing-ролей — денормализованный `AppUser.BillingPaidUntil` (см. Billing); иначе `null`, конфиг живёт бессрочно. Уходит в `Subscription-Userinfo` для VPN-клиента | | `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`| | `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` | | `LastSyncAt` | `DateTimeOffset?`| | | `CreatedAt` | `DateTimeOffset` | | Инварианты и переходы (методы на `VpnConfig`, `backend/src/PnvPanel.Domain/Configs/VpnConfig.cs`): - `Create(...)` → статус `Active`, `ClientExternalId` пуст до ответа от 3x-ui; хендлер вызывает `IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация). - **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. - `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui. - `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui, удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). - `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()` — приостановка за неуплату (биллинг) не должна конфликтовать с блокировкой админом: разблокировка возвращает в `Active` только то, что было погашено именно блокировкой, и наоборот (см. Billing). - `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). - Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` всегда `null` — «не менять»). - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. - **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание, просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах. - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже. - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли). > Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него > никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. > [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже. ### TrafficSample — история трафика (для графиков) Точки потребления во времени; пишутся синхронизацией. | Поле | Тип | Заметки | | ------------ | ---------------- | -------------------------- | | `Id` | `long` | PK | | `ConfigId` | `Guid` | FK → VpnConfig | | `Timestamp` | `DateTimeOffset` | | | `UpBytes` | `long` | Накопительно или дельта | | `DownBytes` | `long` | | > Обычная таблица PostgreSQL + **TTL** — фоновая чистка записей старше N дней (`TrafficRetentionService`). ### 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` | Показывать пользователям | | `IsRecommended` | `bool` | Показывать первыми в группе ОС + значок на фронте | Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`/`IsRecommended`, сгруппировано по `OperatingSystem`; внутри группы `IsRecommended` (сначала true) → `SortOrder`. Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание», `DELETE /api/admin/maintenance/apps/disabled`. ### InstructionIntro — вводный текст страницы инструкций Единственная строка в таблице (singleton) — markdown-текст над вкладками на странице «Инструкции», редактируется админом. Никакой поддержки нескольких версий/языков нет. | Поле | Тип | Заметки | | ----------- | ----------------- | ------------------------------------------------ | | `Id` | `Guid` | PK | | `Body` | `string` | Markdown-текст | | `UpdatedAt` | `DateTimeOffset` | | `GET /api/instructions/intro` (активированным) читает; `PUT /api/admin/instructions/intro` (админ) делает get-or-create — если строки ещё нет (не сидировано), создаёт, иначе обновляет на месте. Сидируется дефолтным текстом при старте (`IInstructionIntroSeeder`, если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше). ### InstructionTab — дополнительные вкладки инструкций Заголовок + markdown-текст, ведёт админ; на странице «Инструкции» отображаются вкладками рядом с встроенной вкладкой «Приложения» (каталог `ClientApp`, не хранится как `InstructionTab`). | Поле | Тип | Заметки | | ----------- | ----------------- | ------------------------------------------------------- | | `Id` | `Guid` | PK | | `Title` | `string` | Заголовок вкладки | | `Body` | `string` | Markdown-текст | | `SortOrder` | `int` | Порядок вкладок (та же конвенция, что у `ClientApp`) | | `CreatedAt` | `DateTimeOffset` | | | `UpdatedAt` | `DateTimeOffset?` | | Нет статуса черновик/опубликовано — публикация мгновенная, как у `NewsPost`. Полный CRUD только у админа (`/api/admin/instructions/tabs`); чтение — `GET /api/instructions/tabs` (активированным). Не пересеивается дефолтными вкладками — при полном сбросе панели просто удаляются. ### NewsPost — новости для пользователей Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте. | Поле | Тип | Заметки | | ----------- | ----------------- | ------------------------------------------------------- | | `Id` | `Guid` | PK | | `Title` | `string` | Заголовок | | `Body` | `string` | Markdown-текст | | `CreatedAt` | `DateTimeOffset` | Момент публикации (= момент создания, нет черновиков) | | `UpdatedAt` | `DateTimeOffset?` | Момент последней правки (опц.) | Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished` (SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr). ### 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 — из приложения ничего не удаляет и не редактирует записи. Исключения — retention-очистка по возрасту (вкладка «Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`) и полный сброс панели (см. ниже), оба доступны только админу; отдельной кнопки «удалить весь журнал» без сброса всей панели осознанно нет. ### Полный сброс панели Вкладка «Обслуживание» → «Опасная зона» (спойлер + подтверждение фразой в диалоге, не просто `confirm()`) — `DELETE /api/admin/maintenance/factory-reset`. Возвращает панель к состоянию свежего деплоя: удаляет всех пользователей кроме текущего админа, конфиги (сначала best-effort отзываются на нодах 3x-ui), ноды/инбаунды, тикеты, новости, вводный текст и вкладки инструкций, весь аудит и кастомные роли; каталог приложений и вводный текст инструкций пересеиваются дефолтными значениями, вкладки инструкций — нет (пусто, как у новостей). Необратимо, не атомарно целиком — подробности и полный список удаляемого см. [api-design.md](api-design.md#admin--maintenance). ### 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`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту на число конфигов и лимит одновременных IP на клиента в 3x-ui. | Поле | Тип | Заметки | | ------------ | -------- | --------------------------------------------------------------- | | `Id` | `Guid` | PK | | `Name` | `string` | Напр. `admin`, `user`, `vip` | | `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) | | `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | | `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) | Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2). **У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита). **Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**, пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем. **Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Единая точка защиты — работает и при прямой смене роли из `/admin/users`, и при одобрении заявки на роль через `SupportTicket` (`ApproveRoleRequestCommandHandler` вызывает тот же `ChangeUserRoleAsync`), в том числе когда админ одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не плодить тикеты, которые некому обработать, если админ единственный. ### PricingSettings — глобальная справочная цена конфига Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с платёжными системами. | Поле | Тип | Заметки | | ---------------------------- | ----------------- | --------------------------------------------------- | | `Id` | `Guid` | PK | | `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. | | `PricePerConfigPerHalfYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в полгода, руб. Может быть ниже квартальной (скидка за оплату на полгода вперёд) | | `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже полугодовой (скидка за годовую оплату) | | `UpdatedAt` | `DateTimeOffset` | | Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × число_месяцев × AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не хранится: - 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs` - полгода = `PricePerConfigPerHalfYear × 6 × MaxConfigs` - год = `PricePerConfigPerYear × 12 × MaxConfigs` Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес, 1200₽/полгода, 2400₽/год (линейный рост, скидки нет). Для ролей с `MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано». **Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и не продлевать, чем платить за короткий. Формально: `PricePerConfigPerHalfYear × 6 ≥ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfigPerHalfYear × 6` (если полугодовая ставка не задана — год сверяется напрямую с кварталом: `PricePerConfigPerYear × 12 ≥ PricePerConfigPerQuarter × 3`). `GET/PUT /api/admin/pricing` — только `admin` (редактирование). `GET /api/support/pricing` — то же чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется в диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с пометкой, что цены пока ознакомительные. Это два разных Query (`GetPricingSettingsQuery` в `Admin/Pricing`, `GetSupportPricingQuery` в `Support`) над одним и тем же общим `PricingSettingsDto` (`Common/Interfaces`) — по аналогии с `ListRolesQuery`/`ListSelectableRolesQuery` для ролей. В отличие от `RoleDto`, у `PricingSettingsDto` нет чувствительных per-роль данных, поэтому шарить DTO между admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше). ### Billing — подписка по сроку Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`. Роль без флага не затрагивается — конфиги живут бессрочно, как без биллинга вообще. **`AppUser` (доп. поля, только для billing-ролей):** | Поле | Тип | Заметки | | --------------------------------- | ------------------ | ------------------------------------------------------------ | | `BillingPaidUntil` | `DateTimeOffset?` | Оплачено до этой даты; `null` — оплата ещё ни разу не выставлялась | | `BillingSuspended` | `bool` | Конфиги приостановлены за неуплату (см. `BillingService`) | | `BillingLastWarnedForPaidUntil` | `DateTimeOffset?` | Для какого `PaidUntil` уже отправлено предупреждение «истекает через N дней» — не даёт слать повторно на каждый тик джобы | **Грейс-период**: когда пользователю впервые назначается billing-роль (или роли, где он уже состоит, включают `BillingEnabled`) и `BillingPaidUntil == null` — `RoleService` выставляет `BillingPaidUntil = now + BillingSettings.GraceDays` автоматически (`ChangeUserRoleAsync`/ `UpdateRoleAsync`). Без этого пользователь был бы «просрочен» с первой секунды. ### BillingSettings — глобальные настройки биллинга Singleton (как `PricingSettings`) — реквизиты для оплаты и длина грейс-периода, редактирует `admin`. | Поле | Тип | Заметки | | ----------------- | ---------------- | ----------------------------------------------------------- | | `Id` | `Guid` | PK | | `RequisitesText` | `string` | Произвольный текст реквизитов (карта/крипто-адрес/СБП и т.д.), показывается пользователю с заявкой | | `GraceDays` | `int` | По умолчанию 7 (`BillingSettings.DefaultGraceDays`) | | `UpdatedAt` | `DateTimeOffset` | | ### PaymentRequest — заявка на оплату Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler`. | Поле | Тип | Заметки | | ------------------ | ----------------------- | ------------------------------------------------------------ | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | | `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) | | `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`. Последующее изменение прайса админом не меняет уже созданные заявки | | `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` | | `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) | | `CreatedAt` | `DateTimeOffset` | | Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле — `CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`. **Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`): - `Create(userId, period, amount)` → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки. - `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`, админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}`. - `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и `AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку). `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех конфигах пользователя. ### BillingService — приостановка за неуплату (фоновая джоба) `Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`): - есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не решит (не по вине пользователя, что админ не успел проверить оплату); - `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active` конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(enable:false)`, идемпотентно как в `BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности ноды), без повторных уведомлений; - до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено (`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки. `CreateVpnConfigCommandHandler` дополнительно не даёт создать **новый** конфиг, если роль billing и оплата просрочена (`ConfigErrors.BillingRequired`) — иначе приостановку можно было бы обойти созданием свежего конфига. `GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок, подтверждение/отклонение), только `admin`. ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. | Поле | Тип | Заметки | | ------------ | ----------------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | | `Comment` | `string?` | Комментарий заявителя, напр. «я Никита» — чтобы админ понял, кто это | | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedAt` | `DateTimeOffset?` | | | `RejectionReason` | `string?` | Комментарий админа при отклонении (опционально) | | `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` — не переиспользуется. ### SupportTicket — обращение в поддержку Два вида: `BugReport` (свободная форма, с вложениями) и `RoleRequest` (запрос существующей роли — кроме `admin` — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции. | Поле | Тип | Заметки | | ------------------- | ----------------- | ---------------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (автор) | | `Type` | `TicketType` | `BugReport` / `RoleRequest` | | `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` | | `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли | | `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли | | `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) | | `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) | | `CreatedAt` | `DateTimeOffset` | | Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId` и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками (`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. - `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application (`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. - `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest` — отклонение. - `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается. - Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`) на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. Баг-репорты такого ограничения не имеют. - Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей); админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с ролевой проверкой, без завязки на активацию. - Resolve/close/reject **собственного** тикета админом разрешены — они не трогают роль, риска нет (запрет ломал бы самообслуживание: тикет единственного админа застревал бы в `Open` навсегда, убрать некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см. `AppRole` (`RoleErrors.CannotRemoveLastAdmin`), и одинаково работает что для approve своей заявки, что для прямой смены роли через `/admin/users`. - `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка «Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/ `TicketAttachment` (+ файлы на диске), необратимо. ### TicketComment — сообщение в переписке Плоская сущность (не навигационная коллекция на `SupportTicket` — конвенция проекта, см. `TrafficSample`), одна на любое сообщение (включая первое, созданное вместе с тикетом). | Поле | Тип | Заметки | | ----------- | ---------------- | --------------------------------------------------------- | | `Id` | `Guid` | PK | | `TicketId` | `Guid` | FK → SupportTicket | | `AuthorId` | `Guid` | FK → AppUser (владелец тикета либо админ) | | `Body` | `string` | | | `CreatedAt` | `DateTimeOffset` | | Комментарий запрещён на `Closed`-тикете; на `Open`/`Resolved` — можно (для `Resolved` это не переоткрывает тикет автоматически, переоткрытие — отдельное явное действие пользователя `Reopen()`). ### TicketAttachment — вложение (изображение) Хранится на диске контейнера (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads` в docker-compose) — первая в проекте функциональность загрузки файлов. Вайтлист `image/jpeg|png|webp|gif`, до 5 МБ на файл, до 5 файлов на сообщение. | Поле | Тип | Заметки | | ---------------- | ---------------- | ------------------------------------------------------------------ | | `Id` | `Guid` | PK | | `CommentId` | `Guid` | FK → TicketComment | | `FileName` | `string` | Оригинальное имя — только для отображения, не участвует в пути на диске | | `StoredFileName` | `string` | Серверное GUID-имя на диске (не доверяем пользовательскому вводу) | | `ContentType` | `string` | | | `SizeBytes` | `long` | | | `CreatedAt` | `DateTimeOffset` | | Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом или роли admin), не статикой — вложения могут быть чувствительными. ## Value Objects - **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст, `ISecretProtector`/ASP.NET Data Protection); пароль не сериализуется наружу. Connection string для клиента строит `IXuiPanelGateway` (обёртка над `ThreeXui.Net`) на лету при запросе `GET /api/configs/{id}/link` — отдельного value object под это не заводили. QR-код из готовой строки генерируется **на фронте** (`qrcode.react`), сервер картинку не рендерит. ## 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 } enum TicketType { BugReport, RoleRequest } enum TicketStatus { Open, Resolved, Closed } ``` ## Уведомления и аудит (без диспетчера доменных событий) В `Domain` нет маркера `IDomainEvent` и диспетчера событий. CQRS-хендлеры сами вызывают порты `IRealtimeNotifier` / `ITelegramNotifier` и пишут `AuditLog` напрямую, после того как изменение состояния сохранено. Так проще проследить, что именно произойдёт при вызове конкретной команды — не нужно искать обработчик события где-то ещё. | Хендлер / фоновый сервис | Что происходит | | ------------------------------------ | -------------------------------------------------------------------------- | | `CreateVpnConfigCommandHandler` | Создаёт клиента в 3x-ui + `VpnConfig` | | `RevokeVpnConfigCommandHandler` / `RotateVpnConfigCommandHandler` | Меняют клиента в 3x-ui и запись | | `RequestActivationCommandHandler` | Realtime `activationRequested` группе `admins` + Telegram-уведомление админам (`AdminTelegramUserIds`) | | `ApproveActivationCommandHandler` | `AuditLog` (`ActivationApproved`); realtime `userActivated` владельцу + Telegram-DM, если привязан | | `RejectActivationCommandHandler` | `AuditLog` (`ActivationRejected`) | | `BlockUserCommandHandler` / `UnblockUserCommandHandler` | Отключают/включают все активные конфиги в 3x-ui; `AuditLog`; Telegram-DM владельцу | | `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) | | `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу | | `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) | | `DeleteUserCommandHandler` | Отзывает все конфиги пользователя в 3x-ui; `AuditLog` (`UserDeleted`); Telegram-DM владельцу; затем удаляет `AppUser`. Админ не может удалить себя | | `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) | | `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) | | `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` | | `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу | | `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт | | `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» | | `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам | | `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу | | `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу | | `ResolveTicketCommandHandler` / `CloseTicketCommandHandler` | `AuditLog` (`TicketResolved`/`TicketClosed`); realtime `ticketUpdated` владельцу | SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и [api-design.md](api-design.md#signalr--hub-hubspanel).