Files
PnvPanel/docs/domain-model.md
T
Leonid Pershin 24cee9bb78
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions.
- Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection.
- Introduced a gifting feature for admins to grant additional billing days directly to users without a request.
- Updated the support ticket model to accommodate extension requests and their associated properties.
- Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection.
- Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features.
- Revised API documentation to reflect the new endpoints and their usage in the billing context.
2026-07-19 05:30:11 +03:00

630 lines
65 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`.
Лимиты трафика на конфиг (`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<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`) — нельзя удалить/переименовать |
| `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/*` — админ (настройки, список заявок,
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`,
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil`
(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
### 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` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем —
это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
| Поле | Тип | Заметки |
| ------------------- | ----------------- | ---------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (автор) |
| `Type` | `TicketType` | `BugReport` / `RoleRequest` / `ExtensionRequest` |
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) |
| `CreatedAt` | `DateTimeOffset` | |
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично
`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`.
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для
`ExtensionRequest``ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil`
на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active`
конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения
оплаты и гифт-дней от админа).
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
`ExtensionRequest` — отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
Баг-репорты такого ограничения не имеют.
- Создать `ExtensionRequest` может только пользователь с billing-ролью
(`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`.
- Доступ — только активированному пользователю (`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, ExtensionRequest }
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 владельцу |
| `CreateExtensionRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
| `ApproveExtensionRequestCommandHandler` | Продлевает `BillingPaidUntil`, возвращает приостановленные конфиги; `AuditLog` (`ExtensionRequestApproved`); Telegram-DM владельцу |
| `RejectExtensionRequestCommandHandler` | `AuditLog` (`ExtensionRequestRejected`); Telegram-DM владельцу |
| `GrantBillingGiftCommandHandler` | Админ дарит N дней (`/admin/billing/gift`) — та же логика продления/возврата конфигов, что и у заявок; `AuditLog` (`BillingGiftGranted`); 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).