- 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.
918 lines
100 KiB
Markdown
918 lines
100 KiB
Markdown
# 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).
|