Files
PnvPanel/docs/domain-model.md
T
Leonid Pershin 4b34c37ce3
CI / Backend (build + test) (push) Failing after 2m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 51s
Enhance user management and node health check features
- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval.
- Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface.
- Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks.
- Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes.
- Updated frontend components to display user config quotas and plan names, improving clarity in user management.
- Enhanced tests for user listing and node status handling to ensure robust functionality and coverage.
- Updated documentation to reflect changes in user and node management features.
2026-08-05 08:34:17 +03:00

918 lines
100 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`) не реализованы — квота на число активных конфигов — через
`AppUser.ConfigQuota` (самообслуживание, см. `Plan` ниже). Есть глобальная справочная цена за один
конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным
пользователям на странице смены тарифа) — используется и биллингом (см. ниже) для расчёта суммы
заявки на оплату.
**Биллинг (подписка по сроку) реализован, но опционален и включается per-роль**
(`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку).
Роль без флага живёт как раньше, без ограничений по сроку.
## Диаграмма связей
```
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken, ConfigQuota, PlanId]
├─*───1─ AppRole (ровно одна роль; роль несёт лимит устройств MaxIpLimit)
├─0..1─ Plan (последний выбранный тариф; квота — на AppUser, не live-linked)
├─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 (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
MediaImage (картинка для markdown инструкций/новостей; диск-хранилище, отдаётся анонимно по Id)
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` |
| `ConsecutiveProbeFailures` | `int` | Счётчик подряд неудачных проб для гистерезиса статуса (`RecordProbe`); обнуляется первой удачной пробой |
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
| `CreatedAt` | `DateTimeOffset`| |
Инварианты: `BaseAddress` абсолютный; при `IsEnabled == false` или `Status == Offline` **новые**
конфиги на ноде запрещены, но **существующие не трогаем** (клиенты остаются в 3x-ui). Статус ноды
показываем пользователю как индикатор «состояние сервера».
`BaseAddress` **нормализуется к завершающему `/`** (`Register`/`UpdateAddress`) — 3x-ui часто стоит за
нестандартным base path (`webBasePath`, напр. `https://host/benis`, панель тогда доступна по
`/benis/panel/...`); без завершающего слэша относительное объединение пути (RFC 3986 merge) отбрасывает
последний сегмент базы вместо добавления к нему, роняя `/benis` из итогового URL при запросах к панели.
Нормализация — единственная защита от этого на уровне PnvPanel; админ может вводить адрес и со слэшем,
и без него — результат одинаковый.
### 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`). Лимита числа клиентов на инбаунд нет — квота
ограничивается только на уровне пользователя (`AppUser.ConfigQuota`).
**Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»):
инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного
`VpnConfig` — запись просто удаляется (иначе при пересоздании того же инбаунда на панели под новым
`RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги
есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`,
`IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт
в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. Если инбаунд позже
снова появляется в ответе 3x-ui (`UpdateFromRemote`) или переопубликовывается (`Publish`) —
`IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы
недоступным навсегда, даже вернувшись на панель.
Пока запись висит с `IsAvailable=false`, админ может удалить её вручную
(`DELETE /api/admin/inbounds/{id}`, `DeleteInboundCommandHandler`) — это единственный способ
вычистить дубли, возникающие, если админ пересоздал инбаунд на самой панели под тем же remark/портом
(3x-ui выдаёт новый `RemoteInboundId`, старая запись остаётся мусором навсегда, пока её не удалить
руками). Разрешено только для `IsAvailable=false` — на живой инбаунд эта команда не действует
(`Inbounds.StillAvailable`). Перед удалением каскадно отзывает (`VpnConfig.Revoke()`) все ещё не
`Revoked` конфиги на нём — панельного клиента для них всё равно не существует, поэтому это чисто
локальная операция без вызова гейтвея. Уже `Revoked` конфиги при этом остаются в БД с `InboundId`,
указывающим на удалённую запись — сознательный компромисс: пользовательские списки конфигов Revoked
не показывают вовсе, а админский список подставляет "?" вместо локации отсутствующего инбаунда.
> `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и, несмотря на гистерезис
> (`RecordProbe`: Offline лишь после 2 подряд неудачных проб), может отставать от реальности. Реальную
> недоступность ноды ловит вызов
> `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`) — иначе два параллельных запроса могли бы пробить квоту. Тот же
ключ (`userId`) использует и `ChangePlanCommandHandler` — самостоятельная смена тарифа не может
гоняться с параллельным созданием конфига. Тот же паттерн обобщён в
`Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и используется во всех
"проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и оплат
(`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectExtensionRequestCommandHandler`,
`Create`/`GetLoginRequestStatusQueryHandler`, `Create...RequestTicket`/
`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той же заявки с сайта и из
Telegram могло бы оба пройти проверку статуса и оба начислить дни/оплату.
На нерелационном EF-провайдере (InMemory в `PnvPanel.Application.Tests`) лок автоматически
пропускается — сериализующее поведение проверяется только в `PnvPanel.IntegrationTests` (реальный
Postgres).
- `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
Если после успешного создания нового клиента в панели `SaveChangesAsync` падает (сбой БД) —
хендлер откатывает созданного клиента через `RemoveClientAsync` и возвращает `RotateFailed`, не
оставляя осиротевшего рабочего клиента, ни к одному конфигу не привязанного.
- `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-конфиг. Как и `ConfigQuota`, лимит применяется только к **новым** клиентам: смена роли/тарифа не
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
всегда `null` — «не менять»).
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
- **Shadowsocks не поддерживает обновление клиента после создания** — ограничение `ThreeXui.Net`
(`XuiClient.UpdateClient` тихо не выполняет изменение для SS, но раньше сама библиотека всё равно
отдавала «успех»). `XuiPanelGateway.UpdateClientAsync` теперь явно возвращает `Result.Failure`
для `VpnProtocol.Shadowsocks` — не пытается вызвать панель зря и не врёт об успехе PnvPanel. На
практике это значит: `Disable`/`Enable` (блокировка админом), приостановка/возврат за неуплату
(`BillingConfigResumer`) и продление `expiresAt` **не долетают до панели** для SS-конфигов — только
в локальную БД. До появления реальной поддержки в `ThreeXui.Net` SS — известное ограничение, не
скрытое молчаливым сбоем.
- **Доступ разрешён только активированному пользователю** (`AppUser.IsActivated == true`): создание,
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах.
- Число активных конфигов пользователя не может превышать **его квоту** (`AppUser.ConfigQuota`;
`-1` — без лимита, только для `admin`). Квота задаётся тарифом (`Plan`, самообслуживание, см.
ниже), не ролью. См. `Plan` ниже.
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя).
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см.
> [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?` | Когда привязан |
| `ConfigQuota` | `int` | Фактическая квота активных конфигов (замена бывшего `AppRole.MaxConfigs`); `-1` = без лимита. Меняется через `ChangePlanCommand` (самообслуживание) или `AdminSetUserPlanCommand` |
| `PlanId` | `Guid?` | Какой каталожный `Plan` выбран последним; обычная колонка без FK (см. `Inbound.AllowedRoleIds`); `null`, если квота задана вручную (кастомное число) или прямым оверрайдом админа |
Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`);
неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше);
при регистрации выдаётся роль `user`.
**Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui);
разблокировка включает их обратно. У пользователя ровно одна роль.
**`ConfigQuota = -1` (безлимит) зарезервирован за ролью `admin`** — самостоятельная смена тарифа
(`ChangePlanCommand`) не может выставить безлимит (валидатор требует конечное число в диапазоне
`Plans__MinCustomConfigCount`..`Plans__MaxCustomConfigCount`), и админский оверрайд
(`AdminSetUserPlanCommand`) тоже отказывает не-admin'у (`PlanErrors.UnlimitedOnlyForAdmin`).
`RoleService.ChangeUserRoleAsync` выставляет `ConfigQuota = -1` автоматически при назначении роли
`admin` и сбрасывает её на `Roles__DefaultUserMaxConfigs` при уходе с `admin` (если она была
безлимитной) — иначе бывший админ остался бы с безлимитом навсегда.
**Восстановление пароля**: только через привязанный Telegram (passwordless-вход → смена пароля в
настройках, либо reset-флоу в боте). Если Telegram не привязан — пароль сбрасывает **админ**
(`ResetUserPasswordCommand`). Пока Telegram не привязан,
UI **настойчиво напоминает** привязать его (единственный self-service способ восстановления).
### AppRole — роль (Identity, динамическая)
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт лимит
одновременных IP на клиента в 3x-ui, доступ к инбаундам (`Inbound.AllowedRoles`) и флаг биллинга.
**Квота конфигов на роли больше не хранится** — она у пользователя (`AppUser.ConfigQuota`, см. выше),
управляется самостоятельной сменой тарифа (`Plan`, см. ниже), не ролью.
| Поле | Тип | Заметки |
| ------------ | -------- | --------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `Name` | `string` | Напр. `admin`, `user`, `vip` |
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
| `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) |
Сидируются: `admin` (без лимита IP) и `user` (`MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`,
по умолчанию 2). **У пользователя ровно одна роль.**
**Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой
роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Тот же метод — единственная
точка смены роли (прямая смена из `/admin/users`, самостоятельной заявки на роль больше нет, см.
`SupportTicket` ниже).
**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые
пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds`
(plain `uuid[]`, без FK) мог всё ещё указывать удаляемую роль; перед `RoleManager.DeleteAsync`
хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы
висеть в массиве — не пуская никого нового, но и не давая понять почему.
### Plan — тариф (самообслуживание, квота конфигов)
Каталог квот конфигов, из которого пользователь выбирает себе тариф **сам, без подтверждения
админа** — заменяет прежнюю схему «квота = квота роли». Роль (выше) продолжает управлять только
лимитом устройств, доступом к инбаундам и флагом биллинга.
| Поле | Тип | Заметки |
| ------------ | -------- | ----------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `Name` | `string` | Напр. «Стандарт», «Плюс», «Про» |
| `ConfigCount`| `int` | Количество конфигов; **≥ 1** — каталожные тарифы не поддерживают безлимит (тот доступен только `admin`, см. `AppUser.ConfigQuota`) |
| `SortOrder` | `int` | Порядок в списке (админка и страница `/plan`) |
| `IsEnabled` | `bool` | Показывать ли тариф пользователям для выбора |
Сидируются 3 тарифа при первом старте (`IPlanSeeder`, если таблица пуста): «Стандарт» (3),
«Плюс» (6), «Про» (9) — то же соглашение, что у `PricingSettingsSeeder`. Полный CRUD только у
админа (`/api/admin/plans`); `GET /api/plans` (только `IsEnabled`, активированным) — для страницы
`/plan`.
**Выбор тарифа не привязан жёстко к каталогу** — пользователь может вместо этого ввести
произвольное количество конфигов (`CustomConfigCount`), ограниченное настройками
`Plans__MinCustomConfigCount` (по умолчанию 3) и `Plans__MaxCustomConfigCount` (по умолчанию 50);
в этом случае `AppUser.PlanId` остаётся `null` — это не "тариф", просто число. Квота
(`AppUser.ConfigQuota`) — снапшот на момент выбора, не live-ссылка на `Plan`: последующее изменение
`Plan.ConfigCount` админом не трогает уже выбравших его пользователей (симметрично тому, как
`PaymentRequest.AmountSnapshot` не меняется при правке `PricingSettings` задним числом). Поэтому
удаление тарифа (`DeletePlanCommandHandler`) не проверяет "используется ли он ещё" — `PlanId`
пользователя может молча указывать на удалённую запись, как `Inbound.AllowedRoleIds`.
**`ChangePlanCommand`** (`POST /api/plans/change`, `IRequiresActivation`) — самостоятельная смена,
без подтверждения админа:
- Ровно одно из `PlanId`/`CustomConfigCount` в запросе.
- Квота меняется **сразу**. Если новая квота **больше** текущей и у роли пользователя включён
биллинг с активным `BillingPaidUntil` — создаётся доплата (`PlanChangeTopUp`, см. Billing ниже) за
разницу в цене на оставшийся оплаченный срок — тот же принцип, что раньше был у смены роли.
- Если новая квота **меньше** текущего числа конфигов, всё ещё занимающих квоту (`Active` **и**
`Expired`), — самостоятельная смена требует явно указать, какие именно конфиги отозвать
(`ConfigIdsToRevoke`, ровно `(активные+приостановленные) − новая_квота` штук; иначе
`Plans.MustSelectConfigsToRevoke`). Это осознанное отличие от грандфазеринга при понижении:
пользователь меняет тариф сам, в реальном времени, поэтому можно и нужно спросить его сразу, а не
оставлять лишние конфиги висеть молча. `Expired` (приостановленные за неуплату) считаются наравне с
`Active` — иначе пользователь мог бы обойти пикер, понизив тариф именно во время приостановки (все
конфиги временно не `Active`), а затем оплатить: `BillingConfigResumer.ResumeConfigsAsync`
возвращает в `Active` **все** приостановленные конфиги разом и квоту не проверяет — без этого
правила старое (большее) количество тихо вернулось бы в обход новой квоты.
- Проверка/резервирование — под `AdvisoryLock` (по `UserId`, тот же ключ, что и у
`CreateVpnConfigCommandHandler`) — не даёт гонки с параллельным созданием конфига. Сам отзыв
конфигов (вызов гейтвея `RemoveClientAsync`) — вне лока, после коммита квоты, тем же общим шагом,
что и у `RevokeVpnConfigCommandHandler` (`VpnConfigRevocation.RevokeAsync` — не звонит в гейтвей,
если инбаунд уже `!IsAvailable`, не помечает `Revoked`, если гейтвей упал).
**`AdminSetUserPlanCommand`** (`PATCH /api/admin/users/{id}/plan`) — прямой оверрайд админом,
рядом с прямой сменой роли: **без** пикера конфигов при понижении (грандфазеринг — как раньше при
понижении роли: лишние конфиги не трогаются, новые блокируются, пока не войдёт в квоту) и **без**
доплаты (тот же принцип, что и у прямой смены роли — осознанный инструмент админа, может быть
использован как поощрение). `CustomConfigCount = -1` (безлимит) допустим только если целевой
пользователь уже в роли `admin` (`Plans.UnlimitedOnlyForAdmin` иначе).
### PricingSettings — глобальная справочная цена конфига
Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана
к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с
платёжными системами.
| Поле | Тип | Заметки |
| ---------------------------- | ----------------- | --------------------------------------------------- |
| `Id` | `Guid` | PK |
| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. |
| `PricePerConfigPerHalfYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в полгода, руб. Может быть ниже квартальной (скидка за оплату на полгода вперёд) |
| `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже полугодовой (скидка за годовую оплату) |
| `UpdatedAt` | `DateTimeOffset` | |
Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка ×
число_месяцев × количество_конфигов` (тарифа `Plan` или ручного ввода — см. `Plan` выше), считается
на фронте (страница тарифов в админке `/admin/plans` и страница смены тарифа `/plan`), нигде не
хранится:
- 3 месяца = `PricePerConfigPerQuarter × 3 × ConfigCount`
- полгода = `PricePerConfigPerHalfYear × 6 × ConfigCount`
- год = `PricePerConfigPerYear × 12 × ConfigCount`
Например, тариф с `ConfigCount=3` и одинаковой ставкой 200₽/мес на всех трёх периодах → 600₽/3мес,
1200₽/полгода, 2400₽/год (линейный рост, скидки за период нет — скидка за объём отдельная, см.
`PricingDiscountTier` ниже). Для `ConfigQuota = -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`-эндпоинт) — используется
страницей смены тарифа (`/plan`), чтобы показать ориентировочную стоимость каждого варианта, с
пометкой, что цены пока ознакомительные (эндпоинт исторически называется `support/pricing` — раньше
использовался диалогом заявки на роль, сейчас переиспользован страницей `/plan`, переименовывать не
стали). Это два разных Query (`GetPricingSettingsQuery` в `Admin/Pricing`, `GetSupportPricingQuery` в
`Support`) над одним и тем же общим `PricingSettingsDto` (`Common/Interfaces`). У `PricingSettingsDto`
нет чувствительных данных, поэтому шарить DTO между admin- и user-facing путями безопасно.
Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после
полного сброса панели (см. «Полный сброс панели» выше).
#### PricingDiscountTier — скидка за объём (лесенка порогов)
Стимул брать тариф с бОльшим количеством конфигов разом: плоская таблица (не навигационная
коллекция — см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не
привязана к конкретному тарифу — как и сам `PricingSettings`.
| Поле | Тип | Заметки |
| ------------------- | -------- | ------------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `PricingSettingsId` | `Guid` | FK → PricingSettings |
| `MinConfigs` | `int` | Порог: скидка действует при `количество_конфигов >= MinConfigs` |
| `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 |
Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent`
(`Domain/Pricing`): из тиров с `MinConfigs <= количество_конфигов` берётся тот, у которого `MinConfigs`
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` тариф на 8 конфигов получает 10%, а не 15%.
Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление
до целого рубля (`MidpointRounding.AwayFromZero`). `ConfigQuota = -1` (unlimited, только `admin`) скидку
не получает — как и обычный расчёт цены, для него итог не считается.
**Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять
бОльшее количество конфигов может оказаться менее выгодно, что противоречит смыслу скидки за объём).
Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler`
`AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` +
`resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется
и в списке тарифов в админке (`admin/plans.tsx`), и на странице смены тарифа (`/plan`).
`UpdatePricingSettingsCommand` при сохранении полностью заменяет набор тиров (удаляет старые,
вставляет новые) — операция редкая (правит только `admin`), сложность инкрементального diff не
оправдана.
### 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`) заявки **`Kind.Subscription`** на
пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
`Kind.PlanChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
| Поле | Тип | Заметки |
| ------------------ | ----------------------- | ------------------------------------------------------------ |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `PlanChangeTopUp` (доплата за увеличение тарифа, см. ниже) |
| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.PlanChangeTopUp` — доплата не привязана к тарифному периоду |
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × ConfigQuota пользователя × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `PlanChangeTopUp`: см. `PlanChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
| `Status` | `PaymentRequestStatus` | `AwaitingPayment``AwaitingConfirmation``Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
| `CreatedAt` | `DateTimeOffset` | |
`ConfigQuota = -1` (unlimited, только `admin`) не поддерживает биллинг по формуле —
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
`PlanChangeTopUp.Compute` (`null`, доплата не считается) — впрочем, для `admin` биллинг и не
применяется (`AppRole.BillingEnabled` для него запрещён в принципе).
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreatePlanChangeTopUp(userId, amount)`
(`Kind.PlanChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`,
админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}` (текст
уведомления зависит от `Kind` — период или «доплата за смену тарифа», см. `TelegramNotifier`).
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет
`ExpiresAt` на всех конфигах пользователя. Для `Kind.PlanChangeTopUp` `Confirm` **только** переводит
заявку в `Confirmed``BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже
выданное увеличение квоты) — см. `ConfirmPaymentRequestCommandHandler`.
#### PlanChangeTopUp — доплата при увеличении тарифа с активным периодом
Пользователь с активным `BillingPaidUntil` увеличивает тариф (`ChangePlanCommand`, самостоятельно,
без подтверждения админа) — по-хорошему должен доплатить разницу, а не доиграть увеличенную квоту
бесплатно до конца уже оплаченного срока. **Квота меняется сразу** (не блокируется ожиданием
оплаты); доплата решается отдельно через обычный флоу `PaymentRequest` (`Kind.PlanChangeTopUp`) —
тем же путём, что и обычная оплата: сайт (`billing.tsx`, `PaymentRequestPanel`) или Telegram
(`pay:approve`/`pay:reject`).
Сумма — `PlanChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs`), чистая
функция без I/O:
1. Месячная стоимость тарифа = `PricingSettings.PricePerConfigPerQuarter × количество_конфигов`,
затем скидка по лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же
формула и тот же базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от
того, за какой период пользователь платил на самом деле — упрощение, чтобы не вводить отдельное
понятие «дневная ставка по фактическому тарифу».
2. Разница месячных стоимостей новой и старой квоты, поделённая на 30 (условный «месяц» для
проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого
рубля (`MidpointRounding.AwayFromZero`).
3. `null` (доплата не создаётся), если: новая квота не больше старой (понижение — см. `ChangePlanCommand`
выше, требует пикера конфигов, а не доплаты), оплаченный период уже истёк, либо старая/новая квота
без лимита (`ConfigQuota = -1`, цена не считается — на практике не встречается вне `admin`, для
которого биллинг вообще не применяется).
Применяется только к самостоятельной смене тарифа (`ChangePlanCommandHandler`) — админский прямой
оверрайд (`PATCH /api/admin/users/{id}/plan`, `AdminSetUserPlanCommandHandler`) доплату не создаёт:
это осознанный инструмент админа, который может быть применён как поощрение (тот же принцип, что и
у прямой смены роли).
**Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest`
если у пользователя одновременно есть активная `Subscription`-заявка и `PlanChangeTopUp` (редкий
случай: тариф сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна
из них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не
блокирует основной сценарий.
### BillingService — приостановка за неуплату (фоновая джоба)
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить
(`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика.
`PlanChangeTopUp` в этот фильтр намеренно не входит — доплата за увеличение тарифа не должна спасать
от приостановки за реально просроченную подписку;
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить
(`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление
пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже
suspended) — только идемпотентная досуспензия «зависших» конфигов (самовосстановление после
недоступности ноды), без повторных уведомлений;
- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено
(`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки.
#### Синхронизация панели 3x-ui со статусом оплаты — `BillingConfigResumer`
`Application/Billing/BillingConfigResumer.cs` — общая точка для всей синхронизации панельного клиента
с оплатой; используется и `BillingService` (Infrastructure, за счёт направления зависимостей
`Infrastructure → Application`), и Application-хендлерами напрямую. Истечение по биллингу управляется
**полностью на нашей стороне** — единственный рычаг на панели это `enable`; `expiresAt` во всех трёх
операциях всегда передаётся как `DateTimeOffset.UnixEpoch` (панель/Xray трактует `expiryTime == 0` как
«без ограничения по сроку»), а не `null``UpdateClientAsync` с `expiresAt: null` означает «не
трогать», чего недостаточно, чтобы гарантированно снять унаследованный от старых версий реальный
`expiryTime`, если он когда-то был запушен. Три операции, все — через
`IXuiPanelGateway.UpdateClientAsync(..., enable: ..., expiresAt: ..., ...)`:
- **`SuspendConfigsAsync`** — приостановка: `enable: false`. Проходит и по `Active`, и по уже
`Expired` конфигам (последние могли быть временно защищены `ProtectPendingConfigsAsync` — см.
ниже, — защиту с них тоже нужно снять при отклонении заявки, не только локальный статус). Вызывается
из `BillingService` (часовой тик) и из `RejectPaymentRequestCommandHandler` (немедленно при
отклонении — см. ниже).
- **`ResumeConfigsAsync`** — подтверждённая оплата/продление/гифт: `enable: true`. Реальный новый срок
фиксируется только локально (`config.SetBillingExpiry(newPaidUntil)`, денормализация
`AppUser.BillingPaidUntil` для отображения и `Subscription-Userinfo`) — панель им не управляет.
Пушится на панель для **любого** статуса конфига, не только `Expired` — пользователь мог
продлить/доплатить заранее, пока конфиг ещё `Active`, и панель должна снять возможную блокировку
сразу, а не только когда `BillingService` в следующий раз тронет конфиг.
- **`ProtectPendingConfigsAsync`** — заявка на оплату ждёт решения админа: `enable: true`. **Не
трогает** `Status`/`ExpiresAt` в БД — это провизорная мера на панели до `Confirm`
(`ResumeConfigsAsync`) или `Reject` (`SuspendConfigsAsync` вернёт как было). Вызывается сразу при
`MarkPaymentSentCommandHandler` (не ждём часовой тик — пользователь отметил оплату, конфиги должны
остаться рабочими немедленно) и повторно на каждом тике `BillingService`, пока заявка висит
(идемпотентно).
Симметрично: если админ **отклоняет** заявку (`RejectPaymentRequestCommandHandler`) и период всё ещё
просрочен, а других Subscription-заявок на проверке нет — `SuspendConfigsAsync` вызывается немедленно,
а не через до часа ожидания следующего тика `BillingService` (поиск «других заявок» явно исключает саму
отклоняемую по Id: `request.Reject(...)` меняет статус только в трекере EF, до `SaveChangesAsync` в БД
всё ещё лежит старое `AwaitingConfirmation` — без исключения по Id проверка ложно приняла бы её за ещё
одну висящую заявку).
Каждая из трёх операций также шлёт `IRealtimeNotifier.NotifyBillingStatusChangedAsync(userId, ...)`
безадресный SignalR-пинг (`billingStatusChanged`, группа `user:{id}`) без пейлоада данных: фронт
(`/billing`) в ответ инвалидирует свой запрос статуса, а не ждёт следующего ручного рефреша/поллинга.
Админский список пользователей (`ListUsersQueryHandler`, `UserSummaryDto.BillingPendingReview`)
отдельно подмешивает "есть Subscription-заявка на `AwaitingConfirmation`" тем же способом, что и
`PaidUntilBadge.pendingReview` на `/billing` у самого пользователя — `IIdentityService` ничего не
знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в
Application-хендлере поверх результата `IIdentityService.ListUsersAsync`, а не внутри Identity.
`ListUsersAsync` также поддерживает фильтр `billingExpired` (`GET /api/admin/users`) — но не просто
`BillingPaidUntil < now`: у пользователя без billing-роли это поле тоже `null`, что не значит
"просрочено". Фильтр дополнительно ограничивает выборку пользователями, чья роль имеет
`BillingEnabled=true` (джойн `AspNetUserRoles`/`AspNetRoles` внутри `IdentityService`, единственное
место в Identity, которое смотрит на `AppRole.BillingEnabled`, — не на `PaymentRequest`, так что
граница Identity/биллинг не нарушается).
Прочие точки, не входящие в `BillingConfigResumer`:
- **Создание** (`CreateVpnConfigCommandHandler`) и **ротация** (`RotateVpnConfigCommandHandler`)
всегда передают `expiresAt: null` в `AddClientAsync` — клиент создаётся без ограничения по сроку на
панели (`null``expiryTime = 0`). Биллинговым истечением этого клиента далее управляет только
`BillingConfigResumer`/`BillingService` через `enable`; панель никогда не является источником правды
о сроке.
- Блокировка/разблокировка админом (`Disable()`/`Enable()`) — отдельная ось, управляет только
`enable`, `expiresAt: null` (не трогает срок оплаты). Переименование (`EditVpnConfigCommandHandler`)
`enable`/`expiresAt: null` (раньше по ошибке форсировало `enable:true`, тем самым молча снимая
приостановку/блокировку простым переименованием конфига — исправлено).
`CreateVpnConfigCommandHandler` дополнительно не даёт создать **новый** конфиг, если роль billing
и оплата просрочена (`ConfigErrors.BillingRequired`) — иначе приостановку можно было бы обойти
созданием свежего конфига. Фронт (`dashboard.tsx`) зеркалит эту же проверку и скрывает кнопку создания
конфига заранее, а не только реагирует на 403 от сервера (см. `CreateConfigDialog.tsx` — safety-net на
случай гонки состояний).
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
`admin`. Продление `PaidUntil` попадает в панель тремя путями — подтверждённая `PaymentRequest`,
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
же `BillingConfigResumer.ResumeConfigsAsync`, различается только вычисление `newPaidUntil` (месяцы для
оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
### ActivationRequest — запрос активации
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
| Поле | Тип | Заметки |
| ------------ | ----------------------- | ---------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (заявитель) |
| `Comment` | `string?` | Комментарий заявителя (кто и откуда), напр. «я Никита, коллега Артёма» — обязателен при создании заявки через API (`NotEmpty`, ≤500); nullable в схеме ради исторических записей |
| `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` — не переиспользуется.
`GetLoginRequestStatusQueryHandler` (поллинг статуса с сайта) при первом наблюдении `Approved`
атомарно "забирает" вход: под `AdvisoryLock` (по Id запроса) заново проверяет статус, `Consume()`-ит
и только потом выпускает JWT — без лока два одновременных поллинга (два открытых окна той же вкладки)
могли бы оба увидеть `Approved` до того, как первый допишет `Consume()`, и оба выпустить валидную пару
токенов из одного подтверждения. Как и обычный логин — отказывает **заблокированному** пользователю
(`AppUser.IsBlocked`) до выпуска токенов; так же поступает `RefreshCommandHandler` при ротации
refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном.
### SupportTicket — обращение в поддержку
Два вида: `BugReport` (свободная форма, с вложениями) и `ExtensionRequest` (продление оплаченного
периода на N дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится
отдельным полем — это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в
одной операции.
> До введения `Plan` (см. выше) существовал третий вид — `RoleRequest` (самостоятельная заявка на
> роль/квоту, с одобрением админом). С переходом квоты конфигов на `AppUser.ConfigQuota` и
> самостоятельной сменой тарифа без подтверждения (`ChangePlanCommand`) необходимость в этом виде
> отпала — роль меняет только админ напрямую (`PATCH /api/admin/users/{id}/role`).
| Поле | Тип | Заметки |
| ------------------- | ----------------- | ---------------------------------------------------------------- |
| `Id` | `Guid` | PK |
| `UserId` | `Guid` | FK → AppUser (автор) |
| `Type` | `TicketType` | `BugReport` / `ExtensionRequest` |
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) |
| `CreatedAt` | `DateTimeOffset` | |
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedDays`
заполняется только фабрикой `CreateExtensionRequest`.
- `Resolve()` — только из `Open`. Для `ExtensionRequest``ApproveExtensionRequestCommandHandler`
продлевает `AppUser.BillingPaidUntil` на `RequestedDays` (от `max(текущий, сейчас)`, как и у
`PaymentRequest`) и возвращает в `Active` конфиги, приостановленные за неуплату
(`BillingConfigResumer`, тот же helper, что и у подтверждения оплаты и гифт-дней от админа).
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `ExtensionRequest`
отклонение.
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
- **`approve-extension`/`reject-extension` — единственный путь решить `ExtensionRequest`.** Общие
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этом типе
возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения
статуса — иначе `resolve` обходил бы `ApproveExtensionRequestCommandHandler` и переводил тикет в
`Resolved`, так и не начислив дни. По той же причине `Reopen()` на `ExtensionRequest` тоже
запрещён (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на продление не имеет
осмысленного действия (дни уже выданы, откатывать их не пытаемся).
- Не более одной открытой заявки на продление (`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` навсегда, убрать
некому). Смена роли остаётся отдельным доверенным действием (`PATCH /admin/users/{id}/role`), не
связанным с тикетами, — её единственная защита (`RoleErrors.CannotRemoveLastAdmin`) живёт на уровне
`AppRole` и не пересекается с этим флоу.
- `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), не статикой — вложения могут быть чувствительными.
### MediaImage — картинка для markdown
Картинка, загруженная админом для вставки в markdown инструкций/новостей
(`POST /api/admin/media/images`). То же диск-хранилище (`IFileStorage`), те же ограничения, что и у
вложений тикетов (`image/jpeg|png|webp|gif`, ≤5 МБ), но, в отличие от них, **отдаётся анонимно** по
непрозрачному `Id`: markdown рендерится обычным `<img>`, который не шлёт `Authorization`.
| Поле | Тип | Заметки |
| ---------------- | ---------------- | ------------------------------------------------------------------ |
| `Id` | `Guid` | PK; он же — ссылка `/api/media/images/{id}` в markdown |
| `FileName` | `string` | Оригинальное имя — для `alt` и отображения, не участвует в пути на диске |
| `StoredFileName` | `string` | Серверное GUID-имя на диске |
| `ContentType` | `string` | SVG не допускается (документ со скриптами, а ссылка публичная) |
| `SizeBytes` | `long` | |
| `UploadedBy` | `Guid` | Админ-загрузчик (для расследования, отдельного экрана управления нет) |
| `CreatedAt` | `DateTimeOffset` | |
Связи с инструкцией/новостью нет — картинка живёт только как ссылка внутри markdown-текста, поэтому
удаление вкладки/новости файл не трогает; всё медиа стирается при factory reset.
## 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, 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 админам — превью текста + кнопка-ссылка на сайт |
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
| `ChangePlanCommandHandler` | Меняет `AppUser.ConfigQuota`/`PlanId`, при понижении отзывает выбранные конфиги в 3x-ui; `AuditLog` (`PlanChanged`); при доплате — создаёт `PaymentRequest` (`PlanChangeTopUp`) + 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).