Files
PnvPanel/docs/domain-model.md
T
Leonid Pershin 14b64a3140
CI / Backend (build + test) (push) Successful in 1m26s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
Implement activation checks across various commands and queries
- Introduced `IRequiresActivation` interface to enforce activation requirements for multiple commands and queries, ensuring that only activated users can create, edit, or access configurations, news, and applications.
- Updated the `RequireActivationBehavior` to handle activation checks uniformly, returning appropriate errors for unauthenticated or inactive users.
- Enhanced error handling by adding `NotActivated` error to provide clear feedback for users attempting to access restricted features.
- Updated documentation to reflect the new activation requirements and their implications on user access and functionality.
2026-07-13 18:51:03 +03:00

322 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
```
## Сущности
### 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) выставляется при создании клиента (`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`).
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
> Лимиты трафика и автоматическое истечение срока конфига не реализованы. `ExpiresAt` никогда не
> выставляется; `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда не переводит
> конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. [tech-stack.md](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`](../seed/client-apps.json), если таблица пуста.
### 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.
### 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>`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту
на число конфигов и лимит одновременных 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`) — нельзя удалить/переименовать |
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`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
```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 }
```
## Уведомления и аудит (без диспетчера доменных событий)
В `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` владельцу |
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
[api-design.md](api-design.md#signalr--hub-hubspanel).