Enhance user plan management and update related endpoints
- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`. - Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes. - Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users. - Removed deprecated role request approval endpoints from `AdminSupportEndpoints`. - Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications. - Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings. - Enhanced billing request handling to accommodate plan changes instead of role changes. - Updated various interfaces and command handlers to support new plan management features.
This commit is contained in:
+205
-142
@@ -4,10 +4,11 @@
|
||||
`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
|
||||
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
||||
|
||||
Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов —
|
||||
только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`;
|
||||
редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) —
|
||||
используется и биллингом (см. ниже) для расчёта суммы заявки на оплату.
|
||||
Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — через
|
||||
`AppUser.ConfigQuota` (самообслуживание, см. `Plan` ниже). Есть глобальная справочная цена за один
|
||||
конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным
|
||||
пользователям на странице смены тарифа) — используется и биллингом (см. ниже) для расчёта суммы
|
||||
заявки на оплату.
|
||||
|
||||
**Биллинг (подписка по сроку) реализован, но опционален и включается per-роль**
|
||||
(`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку).
|
||||
@@ -16,8 +17,9 @@
|
||||
## Диаграмма связей
|
||||
|
||||
```
|
||||
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken]
|
||||
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
|
||||
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken, ConfigQuota, PlanId]
|
||||
├─*───1─ AppRole (ровно одна роль; роль несёт лимит устройств MaxIpLimit)
|
||||
├─0..1─ Plan (последний выбранный тариф; квота — на AppUser, не live-linked)
|
||||
├─1───*─ VpnConfig
|
||||
│ └─1─ Inbound ─*─1─ Node
|
||||
│ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
|
||||
@@ -29,7 +31,7 @@ AuditLog (append-only журнал действий; сс
|
||||
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
||||
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
|
||||
AppUser
|
||||
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль)
|
||||
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление)
|
||||
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
|
||||
└─0..*─ TicketAttachment (изображения, диск-хранилище)
|
||||
```
|
||||
@@ -83,7 +85,7 @@ AppUser
|
||||
Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя
|
||||
входит в `AllowedRoles`**. Публикация инбаунда админом включает выбор `AllowedRoles` (напр.
|
||||
«Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота
|
||||
ограничивается только на уровне пользователя (`AppRole.MaxConfigs`).
|
||||
ограничивается только на уровне пользователя (`AppUser.ConfigQuota`).
|
||||
|
||||
**Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»):
|
||||
инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного
|
||||
@@ -141,13 +143,15 @@ AppUser
|
||||
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
|
||||
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
|
||||
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
|
||||
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. Тот
|
||||
же паттерн обобщён в `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и
|
||||
используется во всех "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и
|
||||
оплат (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectRoleRequestCommandHandler`,
|
||||
`Approve`/`RejectExtensionRequestCommandHandler`, `Create`/`GetLoginRequestStatusQueryHandler`,
|
||||
`Create...RequestTicket`/`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той
|
||||
же заявки с сайта и из Telegram могло бы оба пройти проверку статуса и оба начислить дни/роль/оплату.
|
||||
(`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).
|
||||
@@ -165,7 +169,7 @@ AppUser
|
||||
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента).
|
||||
- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по
|
||||
квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его
|
||||
per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не
|
||||
per-конфиг. Как и `ConfigQuota`, лимит применяется только к **новым** клиентам: смена роли/тарифа не
|
||||
трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp`
|
||||
всегда `null` — «не менять»).
|
||||
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
||||
@@ -181,13 +185,14 @@ AppUser
|
||||
просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также
|
||||
чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline
|
||||
behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах.
|
||||
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
||||
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
|
||||
- Число активных конфигов пользователя не может превышать **его квоту** (`AppUser.ConfigQuota`;
|
||||
`-1` — без лимита, только для `admin`). Квота задаётся тарифом (`Plan`, самообслуживание, см.
|
||||
ниже), не ролью. См. `Plan` ниже.
|
||||
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя).
|
||||
|
||||
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
|
||||
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см.
|
||||
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см.
|
||||
> [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже.
|
||||
|
||||
### TrafficSample — история трафика (для графиков)
|
||||
@@ -315,6 +320,8 @@ AppUser
|
||||
| `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`);
|
||||
неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше);
|
||||
@@ -322,39 +329,41 @@ AppUser
|
||||
**Блокировка** (`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.
|
||||
### AppRole — роль (Identity, динамическая)
|
||||
Расширяет `IdentityRole<Guid>`. Роли **создаёт админ** и назначает пользователям; роль несёт лимит
|
||||
одновременных IP на клиента в 3x-ui, доступ к инбаундам (`Inbound.AllowedRoles`) и флаг биллинга.
|
||||
**Квота конфигов на роли больше не хранится** — она у пользователя (`AppUser.ConfigQuota`, см. выше),
|
||||
управляется самостоятельной сменой тарифа (`Plan`, см. ниже), не ролью.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ------------ | -------- | --------------------------------------------------------------- |
|
||||
| `Id` | `Guid` | PK |
|
||||
| `Name` | `string` | Напр. `admin`, `user`, `vip` |
|
||||
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
||||
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
||||
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
||||
| `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) |
|
||||
|
||||
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
||||
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
||||
**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита).
|
||||
|
||||
**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих
|
||||
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
|
||||
пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем.
|
||||
Сидируются: `admin` (без лимита IP) и `user` (`MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`,
|
||||
по умолчанию 2). **У пользователя ровно одна роль.**
|
||||
|
||||
**Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой
|
||||
роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно
|
||||
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Единая точка защиты — работает
|
||||
и при прямой смене роли из `/admin/users`, и при одобрении заявки на роль через `SupportTicket`
|
||||
(`ApproveRoleRequestCommandHandler` вызывает тот же `ChangeUserRoleAsync`), в том числе когда админ
|
||||
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
|
||||
плодить тикеты, которые некому обработать, если админ единственный.
|
||||
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Тот же метод — единственная
|
||||
точка смены роли (прямая смена из `/admin/users`, самостоятельной заявки на роль больше нет, см.
|
||||
`SupportTicket` ниже).
|
||||
|
||||
**Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые
|
||||
пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds`
|
||||
@@ -362,6 +371,63 @@ UI **настойчиво напоминает** привязать его (ед
|
||||
хендлер подчищает такие ссылки (`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) — цена за один конфиг, редактируется админом. Не привязана
|
||||
к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с
|
||||
@@ -376,15 +442,17 @@ UI **настойчиво напоминает** привязать его (ед
|
||||
| `UpdatedAt` | `DateTimeOffset` | |
|
||||
|
||||
Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка ×
|
||||
число_месяцев × AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не
|
||||
число_месяцев × количество_конфигов` (тарифа `Plan` или ручного ввода — см. `Plan` выше), считается
|
||||
на фронте (страница тарифов в админке `/admin/plans` и страница смены тарифа `/plan`), нигде не
|
||||
хранится:
|
||||
- 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs`
|
||||
- полгода = `PricePerConfigPerHalfYear × 6 × MaxConfigs`
|
||||
- год = `PricePerConfigPerYear × 12 × MaxConfigs`
|
||||
- 3 месяца = `PricePerConfigPerQuarter × 3 × ConfigCount`
|
||||
- полгода = `PricePerConfigPerHalfYear × 6 × ConfigCount`
|
||||
- год = `PricePerConfigPerYear × 12 × ConfigCount`
|
||||
|
||||
Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
|
||||
1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1`
|
||||
(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано».
|
||||
Например, тариф с `ConfigCount=3` и одинаковой ставкой 200₽/мес на всех трёх периодах → 600₽/3мес,
|
||||
1200₽/полгода, 2400₽/год (линейный рост, скидки за период нет — скидка за объём отдельная, см.
|
||||
`PricingDiscountTier` ниже). Для `ConfigQuota = -1` (unlimited, только `admin`) итог не считается —
|
||||
отображается как «не задано».
|
||||
|
||||
**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько
|
||||
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
|
||||
@@ -394,46 +462,47 @@ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfi
|
||||
PricePerConfigPerQuarter × 3`).
|
||||
|
||||
`GET/PUT /api/admin/pricing` — только `admin` (редактирование). `GET /api/support/pricing` — то же
|
||||
чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется в
|
||||
диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с
|
||||
пометкой, что цены пока ознакомительные. Это два разных Query (`GetPricingSettingsQuery` в
|
||||
`Admin/Pricing`, `GetSupportPricingQuery` в `Support`) над одним и тем же общим `PricingSettingsDto`
|
||||
(`Common/Interfaces`) — по аналогии с `ListRolesQuery`/`ListSelectableRolesQuery` для ролей. В отличие
|
||||
от `RoleDto`, у `PricingSettingsDto` нет чувствительных per-роль данных, поэтому шарить DTO между
|
||||
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
||||
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
||||
чтение, но доступно любому активированному пользователю (не `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`.
|
||||
Стимул брать тариф с бОльшим количеством конфигов разом: плоская таблица (не навигационная
|
||||
коллекция — см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не
|
||||
привязана к конкретному тарифу — как и сам `PricingSettings`.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ------------------- | -------- | ------------------------------------------------------------------ |
|
||||
| `Id` | `Guid` | PK |
|
||||
| `PricingSettingsId` | `Guid` | FK → PricingSettings |
|
||||
| `MinConfigs` | `int` | Порог: скидка действует при `AppRole.MaxConfigs >= MinConfigs` |
|
||||
| `MinConfigs` | `int` | Порог: скидка действует при `количество_конфигов >= MinConfigs` |
|
||||
| `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 |
|
||||
|
||||
Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent`
|
||||
(`Domain/Pricing`): из тиров с `MinConfigs <= MaxConfigs` берётся тот, у которого `MinConfigs`
|
||||
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` роль с `MaxConfigs=8` получает 10%, а не 15%.
|
||||
(`Domain/Pricing`): из тиров с `MinConfigs <= количество_конфигов` берётся тот, у которого `MinConfigs`
|
||||
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` тариф на 8 конфигов получает 10%, а не 15%.
|
||||
Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление
|
||||
до целого рубля (`MidpointRounding.AwayFromZero`). Роли с `MaxConfigs = -1` (unlimited) скидку не
|
||||
получают — как и обычный расчёт цены, для них итог не считается.
|
||||
до целого рубля (`MidpointRounding.AwayFromZero`). `ConfigQuota = -1` (unlimited, только `admin`) скидку
|
||||
не получает — как и обычный расчёт цены, для него итог не считается.
|
||||
|
||||
**Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности
|
||||
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с
|
||||
бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём).
|
||||
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять
|
||||
бОльшее количество конфигов может оказаться менее выгодно, что противоречит смыслу скидки за объём).
|
||||
|
||||
Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler`
|
||||
— `AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` +
|
||||
`resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется
|
||||
и в списке ролей в админке (`admin/roles.tsx`), и в оценке стоимости при смене роли тикетом
|
||||
(`CreateRoleRequestDialog.tsx`). `UpdatePricingSettingsCommand` при сохранении полностью заменяет набор
|
||||
тиров (удаляет старые, вставляет новые) — операция редкая (правит только `admin`), сложность
|
||||
инкрементального diff не оправдана.
|
||||
и в списке тарифов в админке (`admin/plans.tsx`), и на странице смены тарифа (`/plan`).
|
||||
`UpdatePricingSettingsCommand` при сохранении полностью заменяет набор тиров (удаляет старые,
|
||||
вставляет новые) — операция редкая (правит только `admin`), сложность инкрементального diff не
|
||||
оправдана.
|
||||
|
||||
### Billing — подписка по сроку
|
||||
|
||||
@@ -467,79 +536,82 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
||||
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
|
||||
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на
|
||||
пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на
|
||||
`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
|
||||
`Kind.PlanChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ------------------ | ----------------------- | ------------------------------------------------------------ |
|
||||
| `Id` | `Guid` | PK |
|
||||
| `UserId` | `Guid` | FK → AppUser (заявитель) |
|
||||
| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) |
|
||||
| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.RoleChangeTopUp` — доплата не привязана к тарифному периоду |
|
||||
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `RoleChangeTopUp`: см. `RoleChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
|
||||
| `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` | |
|
||||
|
||||
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
|
||||
`ConfigQuota = -1` (unlimited, только `admin`) не поддерживает биллинг по формуле —
|
||||
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в
|
||||
`RoleChangeTopUp.Compute` (`null`, доплата не считается).
|
||||
`PlanChangeTopUp.Compute` (`null`, доплата не считается) — впрочем, для `admin` биллинг и не
|
||||
применяется (`AppRole.BillingEnabled` для него запрещён в принципе).
|
||||
|
||||
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
|
||||
- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)`
|
||||
(`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь
|
||||
- `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`).
|
||||
уведомления зависит от `Kind` — период или «доплата за смену тарифа», см. `TelegramNotifier`).
|
||||
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
|
||||
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
|
||||
Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) +
|
||||
период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги,
|
||||
приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет
|
||||
`ExpiresAt` на всех конфигах пользователя. Для `Kind.RoleChangeTopUp` `Confirm` **только** переводит
|
||||
`ExpiresAt` на всех конфигах пользователя. Для `Kind.PlanChangeTopUp` `Confirm` **только** переводит
|
||||
заявку в `Confirmed` — `BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже
|
||||
выданный апгрейд) — см. `ConfirmPaymentRequestCommandHandler`.
|
||||
выданное увеличение квоты) — см. `ConfirmPaymentRequestCommandHandler`.
|
||||
|
||||
#### RoleChangeTopUp — доплата при апгрейде роли с активным периодом
|
||||
#### PlanChangeTopUp — доплата при увеличении тарифа с активным периодом
|
||||
|
||||
Пользователь с активным `BillingPaidUntil` меняет роль (тикетом `SupportTicket.RoleRequest`,
|
||||
`ApproveRoleRequestCommandHandler`) на более дорогую — по-хорошему должен доплатить разницу, а не
|
||||
доиграть апгрейд бесплатно до конца уже оплаченного срока. **Роль меняется сразу** (не блокируется
|
||||
ожиданием оплаты); доплата решается отдельно через обычный флоу `PaymentRequest`
|
||||
(`Kind.RoleChangeTopUp`) — тем же путём, что и обычная оплата: сайт (`billing.tsx`,
|
||||
`PaymentRequestPanel`) или Telegram (`pay:approve`/`pay:reject`).
|
||||
Пользователь с активным `BillingPaidUntil` увеличивает тариф (`ChangePlanCommand`, самостоятельно,
|
||||
без подтверждения админа) — по-хорошему должен доплатить разницу, а не доиграть увеличенную квоту
|
||||
бесплатно до конца уже оплаченного срока. **Квота меняется сразу** (не блокируется ожиданием
|
||||
оплаты); доплата решается отдельно через обычный флоу `PaymentRequest` (`Kind.PlanChangeTopUp`) —
|
||||
тем же путём, что и обычная оплата: сайт (`billing.tsx`, `PaymentRequestPanel`) или Telegram
|
||||
(`pay:approve`/`pay:reject`).
|
||||
|
||||
Сумма — `RoleChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs`), чистая
|
||||
Сумма — `PlanChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs`), чистая
|
||||
функция без I/O:
|
||||
1. Месячная стоимость роли = `PricingSettings.PricePerConfigPerQuarter × MaxConfigs`, затем скидка по
|
||||
лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же формула и тот же
|
||||
базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период
|
||||
пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка
|
||||
по фактическому тарифу».
|
||||
2. Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для
|
||||
1. Месячная стоимость тарифа = `PricingSettings.PricePerConfigPerQuarter × количество_конфигов`,
|
||||
затем скидка по лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же
|
||||
формула и тот же базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от
|
||||
того, за какой период пользователь платил на самом деле — упрощение, чтобы не вводить отдельное
|
||||
понятие «дневная ставка по фактическому тарифу».
|
||||
2. Разница месячных стоимостей новой и старой квоты, поделённая на 30 (условный «месяц» для
|
||||
проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого
|
||||
рубля (`MidpointRounding.AwayFromZero`).
|
||||
3. `null` (доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся
|
||||
грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без
|
||||
лимита конфигов (`MaxConfigs = -1`, цена не считается).
|
||||
3. `null` (доплата не создаётся), если: новая квота не больше старой (понижение — см. `ChangePlanCommand`
|
||||
выше, требует пикера конфигов, а не доплаты), оплаченный период уже истёк, либо старая/новая квота
|
||||
без лимита (`ConfigQuota = -1`, цена не считается — на практике не встречается вне `admin`, для
|
||||
которого биллинг вообще не применяется).
|
||||
|
||||
Применяется только к самостоятельной заявке на роль (`ApproveRoleRequestCommandHandler`) — админская
|
||||
прямая смена роли (`PATCH /api/admin/users/{id}/role`, `UserManageDialog`) доплату не создаёт: это
|
||||
осознанный инструмент админа, который может быть применён как поощрение.
|
||||
Применяется только к самостоятельной смене тарифа (`ChangePlanCommandHandler`) — админский прямой
|
||||
оверрайд (`PATCH /api/admin/users/{id}/plan`, `AdminSetUserPlanCommandHandler`) доплату не создаёт:
|
||||
это осознанный инструмент админа, который может быть применён как поощрение (тот же принцип, что и
|
||||
у прямой смены роли).
|
||||
|
||||
**Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest` —
|
||||
если у пользователя одновременно есть активная `Subscription`-заявка и `RoleChangeTopUp` (редкий
|
||||
случай: роль сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна из
|
||||
них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует
|
||||
основной сценарий.
|
||||
если у пользователя одновременно есть активная `Subscription`-заявка и `PlanChangeTopUp` (редкий
|
||||
случай: тариф сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна
|
||||
из них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не
|
||||
блокирует основной сценарий.
|
||||
|
||||
### BillingService — приостановка за неуплату (фоновая джоба)
|
||||
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
|
||||
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
|
||||
- есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить
|
||||
(`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика.
|
||||
`RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от
|
||||
приостановки за реально просроченную подписку;
|
||||
`PlanChangeTopUp` в этот фильтр намеренно не входит — доплата за увеличение тарифа не должна спасать
|
||||
от приостановки за реально просроченную подписку;
|
||||
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить
|
||||
(`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление
|
||||
пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже
|
||||
@@ -679,49 +751,43 @@ Application-хендлере поверх результата `IIdentityService
|
||||
refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном.
|
||||
|
||||
### SupportTicket — обращение в поддержку
|
||||
Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли —
|
||||
кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
|
||||
дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем —
|
||||
это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
|
||||
Два вида: `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` / `RoleRequest` / `ExtensionRequest` |
|
||||
| `Type` | `TicketType` | `BugReport` / `ExtensionRequest` |
|
||||
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
|
||||
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
|
||||
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
|
||||
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
|
||||
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
|
||||
| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) |
|
||||
| `CreatedAt` | `DateTimeOffset` | |
|
||||
|
||||
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
|
||||
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
|
||||
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично
|
||||
`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`.
|
||||
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
|
||||
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
|
||||
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для
|
||||
`ExtensionRequest` — `ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil`
|
||||
на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active`
|
||||
конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения
|
||||
оплаты и гифт-дней от админа).
|
||||
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
|
||||
`ExtensionRequest` — отклонение.
|
||||
Инварианты и переходы (`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`/`reject` — единственный путь решить `RoleRequest`/`ExtensionRequest`.** Общие
|
||||
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этих двух типах
|
||||
- **`approve-extension`/`reject-extension` — единственный путь решить `ExtensionRequest`.** Общие
|
||||
`/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этом типе
|
||||
возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения
|
||||
статуса — иначе `resolve` обходил бы `ApproveRoleRequestCommandHandler`/
|
||||
`ApproveExtensionRequestCommandHandler` и переводил тикет в `Resolved`, так и не выдав роль/дни.
|
||||
По той же причине `Reopen()` на `RoleRequest`/`ExtensionRequest` тоже запрещён
|
||||
(`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на роль не имеет осмысленного
|
||||
действия (роль/дни уже выданы, откатывать их не пытаемся).
|
||||
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
|
||||
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
|
||||
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
|
||||
статуса — иначе `resolve` обходил бы `ApproveExtensionRequestCommandHandler` и переводил тикет в
|
||||
`Resolved`, так и не начислив дни. По той же причине `Reopen()` на `ExtensionRequest` тоже
|
||||
запрещён (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на продление не имеет
|
||||
осмысленного действия (дни уже выданы, откатывать их не пытаемся).
|
||||
- Не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) на
|
||||
пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
|
||||
Баг-репорты такого ограничения не имеют.
|
||||
- Создать `ExtensionRequest` может только пользователь с billing-ролью
|
||||
(`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`.
|
||||
@@ -730,10 +796,9 @@ refresh-токена — иначе блокировка обходилась б
|
||||
ролевой проверкой, без завязки на активацию.
|
||||
- Resolve/close/reject **собственного** тикета админом разрешены — они не трогают роль, риска нет
|
||||
(запрет ломал бы самообслуживание: тикет единственного админа застревал бы в `Open` навсегда, убрать
|
||||
некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет
|
||||
роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см. `AppRole`
|
||||
(`RoleErrors.CannotRemoveLastAdmin`), и одинаково работает что для approve своей заявки, что для
|
||||
прямой смены роли через `/admin/users`.
|
||||
некому). Смена роли остаётся отдельным доверенным действием (`PATCH /admin/users/{id}/role`), не
|
||||
связанным с тикетами, — её единственная защита (`RoleErrors.CannotRemoveLastAdmin`) живёт на уровне
|
||||
`AppRole` и не пересекается с этим флоу.
|
||||
- `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка
|
||||
«Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/
|
||||
`TicketAttachment` (+ файлы на диске), необратимо.
|
||||
@@ -790,7 +855,7 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
||||
enum ActivationStatus { Pending, Approved, Rejected }
|
||||
enum AuditSource { Web, Telegram, System }
|
||||
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
||||
enum TicketType { BugReport, RoleRequest, ExtensionRequest }
|
||||
enum TicketType { BugReport, ExtensionRequest }
|
||||
enum TicketStatus { Open, Resolved, Closed }
|
||||
```
|
||||
|
||||
@@ -818,10 +883,8 @@ enum TicketStatus { Open, Resolved, Closed }
|
||||
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
||||
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
|
||||
| `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт |
|
||||
| `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
|
||||
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
|
||||
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
|
||||
| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу |
|
||||
| `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 владельцу |
|
||||
|
||||
Reference in New Issue
Block a user