- Updated the VPN configuration commands and handlers to eliminate the device limit parameter, simplifying the configuration process. - Adjusted related API documentation to reflect the removal of device limit management, clarifying that this setting is now handled directly in the 3x-ui by node administrators. - Enhanced the overall codebase by removing unnecessary device limit references across various components, ensuring a cleaner and more maintainable code structure.
28 KiB
Domain Model
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
AppUser/AppRole — часть Identity (живут в Infrastructure, т.к. расширяют IdentityUser<Guid>/
IdentityRole<Guid>); чистый PnvPanel.Domain ссылается на пользователя/роль только по Guid.
Тарифы Plan и лимиты трафика на конфиг (TrafficLimit) не реализованы — единственная квота:
число активных конфигов на роль (AppRole.MaxConfigs).
Диаграмма связей
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)
Сущности
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 |
UsedUpBytes |
long |
Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
UsedDownBytes |
long |
Синхронизируется из 3x-ui |
ExpiresAt |
DateTimeOffset? |
Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно |
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 делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).Rename(label)→ юзер меняет метку (синкается в 3x-ui как имя клиента). Лимит устройств/IP (limitIpв 3x-ui) панелью не управляется — более сложная per-node настройка, задаётся напрямую в 3x-ui администратором.UpdateTraffic(up, down)→ пишетTrafficSyncServiceпри периодической синхронизации, только для отображения.- Создание разрешено только активированному пользователю (
AppUser.IsActivated == true). - Число активных конфигов пользователя не может превышать квоту его роли (
AppRole.MaxConfigs; рольadmin— без лимита). У пользователя ровно одна роль. См.AppRoleниже. - Инбаунд должен быть доступен роли пользователя (
Inbound.AllowedRoles). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
Лимиты трафика и автоматическое истечение срока конфига не реализованы.
ExpiresAtникогда не выставляется;ConfigStatus.LimitReachedв значении enum есть, но код в него никогда не переводит конфиг. Квота на число конфигов реализована черезAppRole.MaxConfigs(см. tech-stack.md).
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 |
Показывать пользователям |
Управляется админом (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-запроса на пользователя; 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 (
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
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 }
Уведомления и аудит (без диспетчера доменных событий)
В 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) |
RegisterNodeCommandHandler / UpdateNodeCommandHandler / DeleteNodeCommandHandler |
AuditLog (NodeRegistered/NodeUpdated/NodeDeleted) |
PublishInboundCommandHandler |
AuditLog (InboundPublished/InboundUnpublished) |
NodeHealthCheckService (фон) |
Обновляет NodeStatus; realtime nodeStatusChanged группе admins |
TrafficSyncService (фон) |
UpdateTraffic(...); realtime configTrafficUpdated владельцу |
SignalR-события и группы — см. architecture.md и api-design.md.