Files
PnvPanel/docs/domain-model.md
T

26 KiB
Raw Blame History

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 string Id inbound в 3x-ui (ThreeXui.Net отдаёт его как string, не число)
Protocol VpnProtocol Vless / Vmess / Trojan / Shadowsocks
Remark string Метка из 3x-ui
Port int
IsPublished bool Доступен ли для самообслуживания пользователями
AllowedRoleIds Guid[] Id ролей, которым разрешено создавать конфиги (native PostgreSQL uuid[]; не навигация на AppRole — тот в Infrastructure/Identity, Domain на него не ссылается)
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} (уникален в рамках панели, виден владелец)
ClientExternalId string Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string)
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 ≥ TrafficLimitLimitReached (+ событие); если now ≥ ExpiresAtExpired.
  • Создание разрешено только активированному пользователю (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. Стартовый набор сидируется из seed/client-apps.json, если таблица пуста.

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<Guid>. Роли создаёт админ и назначает пользователям; роль несёт квоту на число конфигов.

Поле Тип Заметки
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?
RejectionReason string? Комментарий админа при отклонении (опционально)
CreatedAt DateTimeOffset

Инварианты: одновременно не более одного Pending-запроса на пользователя; ApprovedAppUser.IsActivated = true. Создание запроса и решение шлют realtime/Telegram-уведомления.

TelegramLinkToken — токен привязки

Короткоживущий одноразовый токен для флоу привязки Telegram.

Поле Тип Заметки
Id Guid PK
Token string Высокоэнтропийный секрет (в deep-link)
UserId Guid FK → AppUser (кто привязывает)
ExpiresAt DateTimeOffset ≈25 минут
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 ≈25 минут

Переходы: Pending → Approved/Rejected/Expired; Approved → Consumed (после выпуска JWT сайту). После Consumed/Expired — не переиспользуется.

Value Objects

  • NodeCredentialsUsername + ProtectedPassword (шифротекст); равенство по значению; пароль не сериализуется наружу.
  • TrafficLimit — байты; помощники IsUnlimited, IsExceededBy(used), форматирование в ГБ.
  • ConnectionLink — построенная ThreeXui.Net строка подключения + производные (подписка, QR-payload).

Enums

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<T> в Application (диспетчеризация — собственным диспетчером после SaveChanges); внешние эффекты (SignalR, 3x-ui) — через порты, реализуемые в Infrastructure.